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;
10using HtmlAgilityPack;
12using System.Text.Json; // for Browser instance if needed
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 OriginalHtmlLength { get; set; }
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 public static NextJsHydrationResult Extract(Browser.Browser browser, string htmlSource)
80 {
81 var result = new NextJsHydrationResult
82 {
83 OriginalHtmlLength = htmlSource?.Length ?? 0,
84 Url = browser.GetSetCurrentUrl()
85 };
86
87 if (string.IsNullOrWhiteSpace(htmlSource))
88 {
89 result.Message = "Empty HTML";
90 return result;
91 }
92
93 try
94 {
95 var doc = new HtmlDocument();
96 doc.LoadHtml(htmlSource);
97
98 // Always resolve a title so even a "nothing detected" file is labeled usefully.
99 result.Title = ExtractTitle(doc) ?? "Untitled";
100
101 // 1. Try Pages Router first (clean JSON)
102 var nextDataNode = doc.DocumentNode.SelectSingleNode("//script[@id='__NEXT_DATA__']");
103 if (nextDataNode != null)
104 {
105 result.IsPagesRouter = true;
106 result.RawNextDataJson = nextDataNode.InnerText.Trim();
107 result.ParsedStructuredData = result.RawNextDataJson; // already JSON
108 result.Success = true;
109 }
110
111 // 2. App Router - collect all flight chunks
112 var flightScripts = doc.DocumentNode.SelectNodes("//script")
113 ?.Where(s => s.InnerText?.Contains("self.__next_f.push") == true)
114 ?.Select(s => s.InnerText.Trim())
115 ?.ToList() ?? new List<string>();
116
117 if (flightScripts.Any())
118 {
119 result.IsAppRouter = true;
120 result.FlightChunks = flightScripts;
121
122 // Decode the React Flight (RSC) stream into navigable JSON. Falls back to the raw joined
123 // chunks if decoding fails, so nothing is ever lost.
124 result.ParsedStructuredData = DecodeFlightChunks(flightScripts) ?? string.Join("\n", flightScripts);
125
126 result.Success = true;
127 result.Message = $"Found {flightScripts.Count} React Flight chunks (App Router)";
128 }
129 else
130 {
131 result.Message = "No __NEXT_DATA__ or self.__next_f.push found. Not a detectable Next.js site or SSR disabled.";
132 }
133 }
134 catch (Exception ex)
135 {
136 result.Success = false;
137 result.Message = ex.Message;
138 }
139
140 return result;
141 }
142
143 private static string ExtractTitle(HtmlDocument doc)
144 {
145 return doc.DocumentNode.SelectSingleNode("//title")?.InnerText.Trim()
146 ?? doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim();
147 }
148
157 internal static string DecodeFlightChunks(List<string> flightScripts)
158 {
159 if (flightScripts == null || flightScripts.Count == 0)
160 return null;
161
162 try
163 {
164 // 1. Pull the string payload out of every push([...]) and concatenate in document order.
165 var streamBuilder = new StringBuilder();
166 foreach (var script in flightScripts)
167 foreach (var arr in ExtractPushArrays(script))
168 {
169 try
170 {
171 using (var doc = JsonDocument.Parse(arr))
172 {
173 var root = doc.RootElement;
174 if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() >= 2 &&
175 root[1].ValueKind == JsonValueKind.String)
176 streamBuilder.Append(root[1].GetString());
177 }
178 }
179 catch { /* skip a malformed push, keep going */ }
180 }
181
182 string stream = streamBuilder.ToString();
183 if (string.IsNullOrWhiteSpace(stream))
184 return null;
185
186 // 2. Split into id:value rows and bucket them.
187 var data = new List<string>();
188 var modules = new List<string>();
189 var other = new List<string>();
190 var seen = new HashSet<string>(StringComparer.Ordinal);
191
192 string UniqueKey(string id)
193 {
194 string k = id; int n = 1;
195 while (seen.Contains(k)) k = id + "_" + (++n);
196 seen.Add(k);
197 return k;
198 }
199
200 string curId = null;
201 var curContent = new StringBuilder();
202
203 void Flush()
204 {
205 if (curId == null) return;
206
207 string content = curContent.ToString();
208 string key = UniqueKey(curId);
209
210 if (IsValidJson(content))
211 data.Add($"\"{key}\":{content}");
212 else if (content.Length > 1 && char.IsLetter(content[0]) && IsValidJson(content.Substring(1)))
213 {
214 string val = content.Substring(1);
215 if ('I' == content[0]) modules.Add($"\"{key}\":{val}");
216 else other.Add($"\"{key}\":{val}");
217 }
218 else
219 other.Add($"\"{key}\":{JsonSerializer.Serialize(content)}");
220
221 curId = null;
222 curContent.Clear();
223 }
224
225 foreach (var line in stream.Split('\n'))
226 {
227 int colon = line.IndexOf(':');
228 string prefix = colon > 0 ? line.Substring(0, colon) : null;
229 bool isRowStart = prefix != null && Regex.IsMatch(prefix, "^[0-9a-fA-F]+$");
230
231 if (isRowStart)
232 {
233 Flush();
234 curId = prefix;
235 curContent.Append(line.Substring(colon + 1));
236 }
237 else if (curId != null)
238 {
239 // continuation of a rare multi-line value
240 curContent.Append('\n').Append(line);
241 }
242 }
243 Flush();
244
245 if (0 == data.Count && 0 == modules.Count && 0 == other.Count)
246 return null;
247
248 // 3. Assemble one navigable object and pretty-print it.
249 var combined = new StringBuilder();
250 combined.Append("{\"data\":{").Append(string.Join(",", data)).Append("}");
251 combined.Append(",\"modules\":{").Append(string.Join(",", modules)).Append("}");
252 if (other.Count > 0)
253 combined.Append(",\"other\":{").Append(string.Join(",", other)).Append("}");
254 combined.Append("}");
255
256 return PrettyPrintOrRaw(combined.ToString());
257 }
258 catch
259 {
260 return null;
261 }
262 }
263
268 private static IEnumerable<string> ExtractPushArrays(string script)
269 {
270 if (string.IsNullOrEmpty(script))
271 yield break;
272
273 int idx = 0;
274 while ((idx = script.IndexOf("__next_f.push(", idx, StringComparison.Ordinal)) >= 0)
275 {
276 int open = script.IndexOf('[', idx);
277 if (open < 0)
278 yield break;
279
280 int depth = 0;
281 bool inStr = false, esc = false, closed = false;
282 int end = -1;
283
284 for (int i = open; i < script.Length; i++)
285 {
286 char c = script[i];
287 if (inStr)
288 {
289 if (esc) esc = false;
290 else if ('\\' == c) esc = true;
291 else if ('"' == c) inStr = false;
292 }
293 else
294 {
295 if ('"' == c) inStr = true;
296 else if ('[' == c) depth++;
297 else if (']' == c) { depth--; if (0 == depth) { end = i; closed = true; break; } }
298 }
299 }
300
301 if (false == closed)
302 yield break;
303
304 yield return script.Substring(open, end - open + 1);
305 idx = end + 1;
306 }
307 }
308
309 private static bool IsValidJson(string s)
310 {
311 if (string.IsNullOrWhiteSpace(s))
312 return false;
313 char c = s[0];
314 // fast reject: JSON values start with one of these
315 if ('{' != c && '[' != c && '"' != c && '-' != c && false == char.IsDigit(c)
316 && "tfn".IndexOf(c) < 0)
317 return false;
318 try { using (JsonDocument.Parse(s)) return true; }
319 catch { return false; }
320 }
321
322 public static void SaveToFile(NextJsHydrationResult result, string filePath)
323 {
324 var sb = new System.Text.StringBuilder();
325 sb.AppendLine($"# Next.js Hydration Data - {result.Title}");
326 sb.AppendLine($"URL: {result.Url}");
327 sb.AppendLine($"Type: {DescribeType(result)}");
328 sb.AppendLine($"Saved: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
329 sb.AppendLine($"Original HTML length: {result.OriginalHtmlLength / 1024} KB");
330 sb.AppendLine("---");
331
332 if (result.IsPagesRouter && !string.IsNullOrEmpty(result.RawNextDataJson))
333 {
334 sb.AppendLine("## __NEXT_DATA__ (JSON)");
335 sb.AppendLine(PrettyPrintOrRaw(result.RawNextDataJson));
336 }
337 else if (result.IsAppRouter)
338 {
339 sb.AppendLine($"## React Flight (decoded from {result.FlightChunks.Count} chunks)");
340 sb.AppendLine(result.ParsedStructuredData);
341 }
342 else
343 {
344 // Nothing detected: record why, so the file is not just an unexplained empty header.
345 sb.AppendLine(string.IsNullOrWhiteSpace(result.Message)
346 ? "No Next.js hydration data (__NEXT_DATA__ or self.__next_f.push) found on this page."
347 : result.Message);
348 }
349
350 File.WriteAllText(filePath, sb.ToString());
351 }
352
354 private static string DescribeType(NextJsHydrationResult result)
355 {
356 if (result.IsPagesRouter) return "Pages Router (__NEXT_DATA__)";
357 if (result.IsAppRouter) return "App Router (React Flight)";
358 return "None detected (no Next.js SSR hydration data)";
359 }
360
365 private static string PrettyPrintOrRaw(string json)
366 {
367 try
368 {
369 using (var doc = JsonDocument.Parse(json))
370 return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
371 {
372 WriteIndented = true,
373 Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
374 });
375 }
376 catch
377 {
378 return json;
379 }
380 }
381
382 }
383}
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:2406