GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
NextJsHydrationExtractor.cs
1// written by grok
2// inspired by the Web Scraping Club article + React Flight discussions
3
4using System;
5using System.Collections.Generic;
6using System.IO;
7using System.Linq;
8using System.Text;
9using System.Text.RegularExpressions;
11using System.Text.Json; // for Browser instance if needed
12using Newtonsoft.Json.Linq;
13
14namespace GenerallyPositive
15{
17 {
18 public bool Success { get; set; }
19 public string RawNextDataJson { get; set; } = string.Empty; // from __NEXT_DATA__ if present
20 public List<string> FlightChunks { get; set; } = new List<string>(); // raw self.__next_f.push(...) contents
21 public string ParsedStructuredData { get; set; } = string.Empty; // future: cleaned/parsed version
22 public string Url { get; set; } = string.Empty;
23 public string Title { get; set; } = "Untitled";
24 public int HydrationPayloadLength { get; set; } // characters of hydration script content, not of the page
25 public string Message { get; set; } = string.Empty;
26 public bool IsPagesRouter { get; set; } // true if __NEXT_DATA__ was found
27 public bool IsAppRouter { get; set; } // true if flight chunks were found
28
29 private JsonDocument _parsedJson;
30
31 public JsonDocument GetJson()
32 {
33 if (_parsedJson != null)
34 return _parsedJson;
35
36 if (string.IsNullOrWhiteSpace(RawNextDataJson))
37 {
38 Message = "RawNextDataJson is empty";
39 return null;
40 }
41
42 try
43 {
44 _parsedJson = JsonDocument.Parse(RawNextDataJson);
45 Message = "JSON parsed successfully";
46 return _parsedJson;
47 }
48 catch (JsonException ex)
49 {
50 Message = $"JSON parse failed: [{ex.Message}]";
51
52 // Show where it fails
53 int errorPos = (int)(ex.LineNumber > 0 ? ex.LineNumber : 0);
54 string snippet = RawNextDataJson.Length > 300
55 ? RawNextDataJson.Substring(0, 300) + "..."
56 : RawNextDataJson;
57
58 GPAL.PublishSimpleEvent(Enums.GPALEventType.ERROR, Message);
59 GPAL.PublishSimpleEvent(Enums.GPALEventType.ERROR, $"First 300 chars: [{snippet}]");
60
61 return null;
62 }
63 catch (Exception ex)
64 {
65 Message = $"Unexpected error parsing JSON: {ex.Message}";
66 return null;
67 }
68 }
69
70 public void DisposeJson()
71 {
72 _parsedJson?.Dispose();
73 _parsedJson = null;
74 }
75 }
76
77 internal static class NextJsHydrationExtractor
78 {
79 // Reads the three values from the document rather than parsing its markup. Script content is character
80 // data, so the payloads are unescaped, and one json string crosses the transport
81 internal const string HydrationScript =
82 "(function(){" +
83 " var nextData = document.querySelector('script#__NEXT_DATA__');" +
84 " var heading = document.querySelector('h1');" +
85 " var chunks = [];" +
86 " var scripts = document.querySelectorAll('script');" +
87 " for (var idx = 0; idx < scripts.length; idx++) {" +
88 " var text = scripts[idx].textContent;" +
89 " if (text && text.indexOf('self.__next_f.push') >= 0) chunks.push(text.trim());" +
90 " }" +
91 " return JSON.stringify({" +
92 " title: document.title || (heading ? heading.textContent.trim() : null)," +
93 " nextData: nextData ? nextData.textContent.trim() : null," +
94 " flightChunks: chunks" +
95 " });" +
96 "})()";
97
105 public static NextJsHydrationResult ExtractFromScripts(Browser.Browser browser, string scriptsJson)
106 {
107 var result = new NextJsHydrationResult
108 {
109 HydrationPayloadLength = scriptsJson?.Length ?? 0,
110 Url = browser.GetSetCurrentUrl()
111 };
112
113 if (string.IsNullOrWhiteSpace(scriptsJson))
114 {
115 result.Message = "The page reported nothing to extract";
116 return result;
117 }
118
119 try
120 {
121 JObject reported = JObject.Parse(scriptsJson);
122
123 result.Title = (string)reported["title"] ?? "Untitled";
124
125 string nextData = (string)reported["nextData"];
126
127 if (false == string.IsNullOrWhiteSpace(nextData))
128 {
129 result.IsPagesRouter = true;
130 result.RawNextDataJson = nextData;
131 result.ParsedStructuredData = nextData;
132 result.Success = true;
133 }
134
135 var chunks = reported["flightChunks"] as JArray;
136
137 if (null != chunks && 0 < chunks.Count)
138 {
139 result.IsAppRouter = true;
140 result.FlightChunks = chunks.Select(chunk => (string)chunk).ToList();
141 result.ParsedStructuredData = DecodeFlightChunks(result.FlightChunks)
142 ?? string.Join(Environment.NewLine, result.FlightChunks);
143 result.Success = true;
144 result.Message = $"Found {result.FlightChunks.Count} React Flight chunks (App Router)";
145 }
146 else if (false == result.Success)
147 result.Message = "No __NEXT_DATA__ or self.__next_f.push found. Not a detectable Next.js site or SSR disabled.";
148 }
149 catch (Exception ex)
150 {
151 result.Success = false;
152 result.Message = ex.Message;
153 }
154
155 return result;
156 }
165 internal static string DecodeFlightChunks(List<string> flightScripts)
166 {
167 if (flightScripts == null || flightScripts.Count == 0)
168 return null;
169
170 try
171 {
172 // 1. Pull the string payload out of every push([...]) and concatenate in document order.
173 var streamBuilder = new StringBuilder();
174 foreach (var script in flightScripts)
175 foreach (var arr in ExtractPushArrays(script))
176 {
177 try
178 {
179 using (var doc = JsonDocument.Parse(arr))
180 {
181 var root = doc.RootElement;
182 if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() >= 2 &&
183 root[1].ValueKind == JsonValueKind.String)
184 streamBuilder.Append(root[1].GetString());
185 }
186 }
187 catch { /* skip a malformed push, keep going */ }
188 }
189
190 string stream = streamBuilder.ToString();
191 if (string.IsNullOrWhiteSpace(stream))
192 return null;
193
194 // 2. Split into id:value rows and bucket them.
195 var data = new List<string>();
196 var modules = new List<string>();
197 var other = new List<string>();
198 var seen = new HashSet<string>(StringComparer.Ordinal);
199
200 string UniqueKey(string id)
201 {
202 string k = id; int n = 1;
203 while (seen.Contains(k)) k = id + "_" + (++n);
204 seen.Add(k);
205 return k;
206 }
207
208 string curId = null;
209 var curContent = new StringBuilder();
210
211 void Flush()
212 {
213 if (curId == null) return;
214
215 string content = curContent.ToString();
216 string key = UniqueKey(curId);
217
218 if (IsValidJson(content))
219 data.Add($"\"{key}\":{content}");
220 else if (content.Length > 1 && char.IsLetter(content[0]) && IsValidJson(content.Substring(1)))
221 {
222 string val = content.Substring(1);
223 if ('I' == content[0]) modules.Add($"\"{key}\":{val}");
224 else other.Add($"\"{key}\":{val}");
225 }
226 else
227 other.Add($"\"{key}\":{JsonSerializer.Serialize(content)}");
228
229 curId = null;
230 curContent.Clear();
231 }
232
233 foreach (var line in stream.Split('\n'))
234 {
235 int colon = line.IndexOf(':');
236 string prefix = colon > 0 ? line.Substring(0, colon) : null;
237 bool isRowStart = prefix != null && Regex.IsMatch(prefix, "^[0-9a-fA-F]+$");
238
239 if (isRowStart)
240 {
241 Flush();
242 curId = prefix;
243 curContent.Append(line.Substring(colon + 1));
244 }
245 else if (curId != null)
246 {
247 // continuation of a rare multi-line value
248 curContent.Append('\n').Append(line);
249 }
250 }
251 Flush();
252
253 if (0 == data.Count && 0 == modules.Count && 0 == other.Count)
254 return null;
255
256 // 3. Assemble one navigable object and pretty-print it.
257 var combined = new StringBuilder();
258 combined.Append("{\"data\":{").Append(string.Join(",", data)).Append("}");
259 combined.Append(",\"modules\":{").Append(string.Join(",", modules)).Append("}");
260 if (other.Count > 0)
261 combined.Append(",\"other\":{").Append(string.Join(",", other)).Append("}");
262 combined.Append("}");
263
264 return PrettyPrintOrRaw(combined.ToString());
265 }
266 catch
267 {
268 return null;
269 }
270 }
271
276 private static IEnumerable<string> ExtractPushArrays(string script)
277 {
278 if (string.IsNullOrEmpty(script))
279 yield break;
280
281 int idx = 0;
282 while ((idx = script.IndexOf("__next_f.push(", idx, StringComparison.Ordinal)) >= 0)
283 {
284 int open = script.IndexOf('[', idx);
285 if (open < 0)
286 yield break;
287
288 int depth = 0;
289 bool inStr = false, esc = false, closed = false;
290 int end = -1;
291
292 for (int i = open; i < script.Length; i++)
293 {
294 char c = script[i];
295 if (inStr)
296 {
297 if (esc) esc = false;
298 else if ('\\' == c) esc = true;
299 else if ('"' == c) inStr = false;
300 }
301 else
302 {
303 if ('"' == c) inStr = true;
304 else if ('[' == c) depth++;
305 else if (']' == c) { depth--; if (0 == depth) { end = i; closed = true; break; } }
306 }
307 }
308
309 if (false == closed)
310 yield break;
311
312 yield return script.Substring(open, end - open + 1);
313 idx = end + 1;
314 }
315 }
316
317 private static bool IsValidJson(string s)
318 {
319 if (string.IsNullOrWhiteSpace(s))
320 return false;
321 char c = s[0];
322 // fast reject: JSON values start with one of these
323 if ('{' != c && '[' != c && '"' != c && '-' != c && false == char.IsDigit(c)
324 && "tfn".IndexOf(c) < 0)
325 return false;
326 try { using (JsonDocument.Parse(s)) return true; }
327 catch { return false; }
328 }
329
330 public static void SaveToFile(NextJsHydrationResult result, string filePath)
331 {
332 var sb = new System.Text.StringBuilder();
333 sb.AppendLine($"# Next.js Hydration Data - {result.Title}");
334 sb.AppendLine($"URL: {result.Url}");
335 sb.AppendLine($"Type: {DescribeType(result)}");
336 sb.AppendLine($"Saved: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
337 sb.AppendLine($"Hydration payload length: {result.HydrationPayloadLength / 1024} KB");
338 sb.AppendLine("---");
339
340 if (result.IsPagesRouter && !string.IsNullOrEmpty(result.RawNextDataJson))
341 {
342 sb.AppendLine("## __NEXT_DATA__ (JSON)");
343 sb.AppendLine(PrettyPrintOrRaw(result.RawNextDataJson));
344 }
345 else if (result.IsAppRouter)
346 {
347 sb.AppendLine($"## React Flight (decoded from {result.FlightChunks.Count} chunks)");
348 sb.AppendLine(result.ParsedStructuredData);
349 }
350 else
351 {
352 // Nothing detected: record why, so the file is not just an unexplained empty header.
353 sb.AppendLine(string.IsNullOrWhiteSpace(result.Message)
354 ? "No Next.js hydration data (__NEXT_DATA__ or self.__next_f.push) found on this page."
355 : result.Message);
356 }
357
358 File.WriteAllText(filePath, sb.ToString());
359 }
360
362 private static string DescribeType(NextJsHydrationResult result)
363 {
364 if (result.IsPagesRouter) return "Pages Router (__NEXT_DATA__)";
365 if (result.IsAppRouter) return "App Router (React Flight)";
366 return "None detected (no Next.js SSR hydration data)";
367 }
368
373 private static string PrettyPrintOrRaw(string json)
374 {
375 try
376 {
377 using (var doc = JsonDocument.Parse(json))
378 return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
379 {
380 WriteIndented = true,
381 Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
382 });
383 }
384 catch
385 {
386 return json;
387 }
388 }
389
390 }
391}
Browser object that contains the fluent methods to create your Browser workflow. Instatiated using G...
Definition Browser.cs:68
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2568