GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
HtmlDigestConverter.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.IO;
20using System.Linq;
21using System.Text;
22using System.Text.RegularExpressions;
24using HtmlAgilityPack;
25using ReverseMarkdown;
26using static GenerallyPositive.Enums;
27
28// written by grok
29// inspired by https://substack.thewebscraping.club/p/why-scraping-return-markdown-llm-ai?utm_source=post-email-title&publication_id=1023328&post_id=184656666
30// Why LLM-Ready Scrapers Return Content in Markdown: A Deep Dive
31namespace GenerallyPositive
32{
33 public class LLMDigestResult
34 {
35 public bool Success { get; set; }
36 public string Markdown { get; set; } = string.Empty;
37 public string Url { get; set; } = string.Empty;
38 public string Title { get; set; } = "Untitled";
39 public int OriginalLength { get; set; }
40 public int CleanedLength { get; set; }
41 public string Message { get; set; } = string.Empty;
42
44 public string RuleSetUsed { get; set; } = "generic";
45 }
46
47 internal static class HtmlToOptimizedMarkdown
48 {
55 public static LLMDigestResult CreateDigest(Browser.Browser browser, string htmlSource, string ruleSetName = null)
56 {
57 var result = new LLMDigestResult { OriginalLength = htmlSource?.Length ?? 0 };
58
59 if (string.IsNullOrWhiteSpace(htmlSource))
60 {
61 result.Message = "Empty input";
62 return result;
63 }
64
65 try
66 {
67 result.Url = browser.GetSetCurrentUrl();
68
69 DigestRuleSet rules = DigestRulesConfig.Load().SelectFor(result.Url, ruleSetName);
70 result.RuleSetUsed = rules.Name;
71
72 var doc = new HtmlDocument();
73 doc.OptionAutoCloseOnEnd = true;
74 doc.OptionFixNestedTags = true;
75 doc.LoadHtml(htmlSource);
76
77 var products = doc.DocumentNode.SelectNodes("//a[contains(@href,'/p/')]");
78 if (products != null)
79 {
80 foreach (var link in products)
81 {
82 var price = link.SelectSingleNode(".//span[contains(@class,'price') or contains(text(),'$')] | .//following-sibling::*[contains(text(),'$')]");
83 if (price != null && !string.IsNullOrWhiteSpace(price.InnerText))
84 {
85 var priceNode = doc.CreateElement("span");
86 priceNode.InnerHtml = $" <strong>{price.InnerText.Trim()}</strong>";
87 link.ParentNode.InsertAfter(priceNode, link);
88 }
89 }
90 }
91
92 // Extract title if possible
93 var titleNode = doc.DocumentNode.SelectSingleNode("//title");
94 if (titleNode != null) result.Title = titleNode.InnerText.Trim();
95
96 // Aggressive cleanup
97 RemoveJunkNodes(doc, rules.JunkSelectors);
98
99 // Try to find main content area (heuristic)
100 string mainContentHtml = ExtractMainContent(doc, rules.MainContentCandidates) ?? doc.DocumentNode.InnerHtml;
101
102 // Convert to Markdown
103 var config = new Config
104 {
105 GithubFlavored = true
106 };
107 config.Formatting.RemoveComments = true;
108 config.Links.SmartHref = true;
109 // Aggressive unknown tag handling
110 config.Tags.Unknown = Config.UnknownTagsOption.Bypass;
111
112 var converter = new Converter(config);
113
114 if (true == result.Url.ToLower().Contains("sitemap") || // does url contain 'sitemap' it just might be
115 true == result.Url.ToLower().EndsWith(".xml") ||
116 true == SitemapExtractor.IsValidSitemapContent(mainContentHtml))
117 {
118 // Handle as plain-text sitemap (no HTML converter needed)
119 string sitemapBaseUrl = string.IsNullOrWhiteSpace(rules.SitemapBaseUrl)
120 ? DeriveOrigin(result.Url)
121 : rules.SitemapBaseUrl;
122
123 result.Markdown = ExtractSitemapAsMarkdown(mainContentHtml, sitemapBaseUrl);
124 // Optionally log or tag: result.IsSitemap = true;
125 }
126 else
127 {
128 result.Markdown = converter.Convert(mainContentHtml);
129 }
130
131 // === POST-CONVERSION READABILITY CLEANUP ===
132
133 // 1. Collapse only very excessive blanks (keep section gaps)
134 if (rules.EnabledPasses.Contains("CollapseExcessiveBlanks"))
135 result.Markdown = Regex.Replace(result.Markdown, @"(\r?\n\s*){5,}", "\n\n\n", RegexOptions.Multiline);
136
137 // 2. Remove counters and empty icons
138 if (rules.EnabledPasses.Contains("RemoveCountersAndEmptyIcons"))
139 {
140 result.Markdown = Regex.Replace(result.Markdown, @"\‍(\d+\‍)", "", RegexOptions.Multiline);
141 result.Markdown = Regex.Replace(result.Markdown, @"\‍[\‍]\‍(.*?\‍)\s*|\‍[\‍]\s*", "", RegexOptions.Multiline);
142 }
143
144 // 2a. Drop images that never resolved a URL (lazy-loaded placeholders come through as
145 // ![alt]() ) and the stray "!" / "!!" bangs such removals leave behind. These dominate the
146 // noise on image-heavy pages (carousels, hero grids) and carry zero information for an LLM.
147 if (rules.EnabledPasses.Contains("RemoveEmptyImages"))
148 {
149 result.Markdown = Regex.Replace(result.Markdown, @"!\‍[[^\‍]]*\‍]\‍(\s*\‍)", "", RegexOptions.Multiline);
150 result.Markdown = Regex.Replace(result.Markdown, @"!{2,}", "", RegexOptions.Multiline);
151 }
152
153 // 2b. Strip standalone media-player control text (video.js and friends emit lines like
154 // "Play", "Unmute", "Current Time 0:00", "Duration 0:14", "Stream Type LIVE", "Fullscreen").
155 // The vjs- junk selectors remove most of it at the HTML stage; this catches any that survive
156 // or come from non-video.js players.
157 if (rules.EnabledPasses.Contains("StripMediaChrome"))
158 result.Markdown = Regex.Replace(result.Markdown,
159 @"(?im)^[ \t]*(Play|Pause|Unmute|Mute|Fullscreen|Exit Fullscreen|Picture-in-Picture|Playback Rate|Captions|Subtitles|Descriptions|Chapters|Watch|Stream Type\s+LIVE|Current Time\s+[\d:]+|Duration\s+[\d:]+|Remaining Time\s+-?[\d:]+|Loaded:\s*[\d.%]*|Progress:\s*[\d.%]*|Seek to live[^\r\n]*|This is a modal window[^\r\n]*|(Beginning|End) of dialog[^\r\n]*|/|-?\d{1,2}:\d{2})[ \t]*\r?$",
160 "", RegexOptions.Multiline);
161
162 // 3. Nuclear grid breaker: repeatedly split ALL consecutive images until none remain
163 // Insert \n after ) in every occurrence of ")[!\‍["
164 if (rules.EnabledPasses.Contains("ImageGridBreaker"))
165 {
166 string pattern = @")[!\‍[";
167 int startIndex = 0;
168
169 // Guard the search start: only ONE newline is inserted per match, so we advance by 2
170 // (past the ) and the inserted \n). The while-guard keeps startIndex within bounds so
171 // IndexOf can never be handed a startIndex past the (now longer) string and throw.
172 while (startIndex <= result.Markdown.Length)
173 {
174 int found = result.Markdown.IndexOf(pattern, startIndex);
175 if (found == -1)
176 break;
177
178 // Insert \n right after the ) — i.e. after position found
179 result.Markdown = result.Markdown.Insert(found + 1, "\n");
180
181 // Resume just past the ) and the newline we inserted so we do not re-match here
182 startIndex = found + 2;
183 }
184 }
185
186 // 4. Ensure every image ends with at least two newlines (breathing room). The (?!\‍]) guard
187 // skips images that are the inner part of a linked image ( [![alt](img)](url) ) so we do not
188 // split the link's closing ](url) onto its own line.
189 if (rules.EnabledPasses.Contains("ImageSpacing"))
190 result.Markdown = Regex.Replace(result.Markdown,
191 @"(!\‍[.*?\‍]\‍(.*?\‍))(?!\n{2,})(?!\‍])",
192 "$1\n\n",
193 RegexOptions.Multiline);
194
195 // 4-fix. Rejoin any linked image whose closing ](url) still got separated onto its own line
196 // (by an earlier pass or by the converter). Turns "[![alt](img)\n\n](url)" back into
197 // "[![alt](img)](url)" so the product link stays intact instead of leaving an orphan ](url).
198 if (rules.EnabledPasses.Contains("RejoinLinkedImages"))
199 result.Markdown = Regex.Replace(result.Markdown,
200 @"(!\‍[[^\‍]]*\‍]\‍([^)]*\‍))\s*\n\s*(\‍]\‍([^)]*\‍))",
201 "$1$2",
202 RegexOptions.Multiline);
203
204 // 4a. Collapse runs of the exact same image (identical alt AND url) that carousels/sliders
205 // emit repeatedly. Blank lines between images are preserved and do not break a run.
206 if (rules.EnabledPasses.Contains("DedupeConsecutiveImages"))
207 result.Markdown = DedupeConsecutiveImageLines(result.Markdown);
208
209 // 5. Product fix: image > bold brand/price
210 if (rules.EnabledPasses.Contains("ProductImageBrandPrice"))
211 result.Markdown = Regex.Replace(result.Markdown,
212 @"\‍[!\‍[([^\‍]]*)\‍]\‍([^\‍)]+\‍)]\‍(([^)]+)\‍)\s*\n?\s*\‍[([^\‍]]+)\‍]",
213 "![$1]($2)\n\n**$3**",
214 RegexOptions.Multiline);
215
216 // 6. Glue prices to brand if separated
217 if (rules.EnabledPasses.Contains("PriceGluing"))
218 result.Markdown = Regex.Replace(result.Markdown,
219 @"(\*\*[A-Za-z& ]+?\*\*)\s*\n\s*(\$[\d,]+(?:\.\d{2})?)",
220 "$1 $2",
221 RegexOptions.Multiline);
222
223 // 7. Headings spacing
224 if (rules.EnabledPasses.Contains("HeadingSpacing"))
225 {
226 result.Markdown = Regex.Replace(result.Markdown, @"(^[ \t]*#[^\n]+)", "\n\n$1\n", RegexOptions.Multiline);
227 result.Markdown = Regex.Replace(result.Markdown, @"(^[ \t]*##[^\n]+)", "\n$1\n", RegexOptions.Multiline);
228 }
229
230 // 8. Strip leaked script/JSON artifacts: literal escape sequences (backslash-n/t/r as text,
231 // which never occur in readable prose) and orphaned JS array/string fragments like "]) or ']).
232 // These leak from review/data widgets whose content is not a real <script> element.
233 if (rules.EnabledPasses.Contains("StripEscapeArtifacts"))
234 {
235 result.Markdown = Regex.Replace(result.Markdown, @"(\\‍[nrtbf])+", " ");
236 result.Markdown = Regex.Replace(result.Markdown, @"[""']\‍]\‍)", "");
237 // drop lines left with nothing but whitespace/quotes/brackets after the above
238 result.Markdown = Regex.Replace(result.Markdown, @"(?m)^[ \t""'\‍]\‍)]*\r?$", "");
239 }
240
241 // 9. Collapse duplicate sections: pages that ship a no-JS fallback plus a hydrated copy render the
242 // same heading + body twice (e.g. Nike "Back to School"/"TRENDING"). Keep one copy per section.
243 if (rules.EnabledPasses.Contains("DedupeSections"))
244 result.Markdown = DedupeSections(result.Markdown);
245
246 // 10. Drop headings whose section has no content (e.g. a "## Reviews" whose reviews were
247 // JS-loaded and stripped), while keeping parent headings that still have populated subsections.
248 if (rules.EnabledPasses.Contains("DropEmptyHeadings"))
249 result.Markdown = DropEmptyHeadings(result.Markdown);
250
251 // Final light trim (allow up to 2 newlines for section breaks)
252 result.Markdown = result.Markdown.Trim();
253 result.Markdown = Regex.Replace(result.Markdown, @"\n{5,}", "\n\n", RegexOptions.Multiline);
254
255 result.CleanedLength = result.Markdown.Length;
256 result.Success = true;
257 }
258 catch (Exception ex)
259 {
260 result.Success = false;
261 result.Message = ex.Message;
262
263 // Last-ditch recovery: some pages make a library step throw (e.g. ReverseMarkdown can throw
264 // "startIndex cannot be larger than length of string" on certain malformed HTML). Rather than
265 // fail the whole digest - which aborts a batch walk - salvage the readable text so the page
266 // still yields something usable and the run continues.
267 try
268 {
269 string fallback = HtmlToPlainText(htmlSource);
270 if (false == string.IsNullOrWhiteSpace(fallback))
271 {
272 result.Markdown = fallback;
273 result.CleanedLength = fallback.Length;
274 result.Success = true;
275 result.Message = "Recovered as plain text after error: " + ex.Message;
276 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
277 $"Digest conversion failed, recovered [{result.Url}] as plain text.",
278 null, GPALObjectType.None, ex);
279 }
280 }
281 catch
282 {
283 // keep the original failure if even plain-text extraction cannot run
284 }
285 }
286
287 return result;
288 }
289
294 private static string HtmlToPlainText(string html)
295 {
296 if (string.IsNullOrWhiteSpace(html))
297 return string.Empty;
298
299 try
300 {
301 var doc = new HtmlDocument();
302 doc.LoadHtml(html);
303
304 // Drop non-text nodes so their contents never leak into the salvaged text.
305 foreach (var node in doc.DocumentNode.SelectNodes("//script | //style | //noscript")?.ToList() ?? new List<HtmlNode>())
306 {
307 try { node.Remove(); } catch { /* ignore */ }
308 }
309
310 // Raw InnerText glues block elements together ("Black Friday in JulyEnds Sunday"). Insert a
311 // newline after each block-level element and after <br> so the salvaged text stays readable.
312 string markup = doc.DocumentNode.InnerHtml ?? string.Empty;
313 markup = Regex.Replace(markup,
314 @"(?i)</(p|div|li|ul|ol|section|article|header|footer|nav|main|aside|tr|table|h[1-6]|blockquote|figure|figcaption)\s*>|<br\s*/?>",
315 "$0\n");
316
317 var stripped = new HtmlDocument();
318 stripped.LoadHtml(markup);
319 string text = HtmlEntity.DeEntitize(stripped.DocumentNode.InnerText ?? string.Empty);
320
321 // collapse runs of spaces/tabs, tidy the inserted breaks, keep paragraph gaps
322 text = Regex.Replace(text, @"[ \t]+", " ");
323 text = Regex.Replace(text, @" *\n *", "\n");
324 text = Regex.Replace(text, @"\n{3,}", "\n\n");
325 return text.Trim();
326 }
327 catch
328 {
329 return html;
330 }
331 }
332
339 private static string DedupeConsecutiveImageLines(string markdown)
340 {
341 if (string.IsNullOrEmpty(markdown))
342 return markdown;
343
344 var sb = new StringBuilder(markdown.Length);
345 string lastImage = null;
346
347 foreach (var line in markdown.Split('\n'))
348 {
349 string trimmed = line.Trim();
350
351 if (string.IsNullOrEmpty(trimmed))
352 {
353 // blanks pass through untouched and do not break a run of duplicate images
354 sb.Append(line).Append('\n');
355 continue;
356 }
357
358 bool isImage = trimmed.StartsWith("![");
359
360 if (isImage && trimmed == lastImage)
361 continue; // exact duplicate of the previous image - drop it
362
363 sb.Append(line).Append('\n');
364 lastImage = isImage ? trimmed : null;
365 }
366
367 return sb.ToString();
368 }
369
377 private static string DedupeSections(string markdown)
378 {
379 if (string.IsNullOrEmpty(markdown))
380 return markdown;
381
382 var lines = markdown.Split('\n');
383
384 var headingIdx = new List<int>();
385 for (int i = 0; i < lines.Length; i++)
386 if (Regex.IsMatch(lines[i], @"^\s*#{1,6}\s"))
387 headingIdx.Add(i);
388
389 if (headingIdx.Count < 2)
390 return markdown; // nothing to compare
391
392 // Build the block ranges: an optional preamble before the first heading, then one block per heading.
393 var ranges = new List<(int start, int end)>();
394 if (headingIdx[0] > 0)
395 ranges.Add((0, headingIdx[0]));
396 for (int k = 0; k < headingIdx.Count; k++)
397 ranges.Add((headingIdx[k], k + 1 < headingIdx.Count ? headingIdx[k + 1] : lines.Length));
398
399 string Text((int start, int end) r)
400 {
401 var sb = new StringBuilder();
402 for (int j = r.start; j < r.end; j++) sb.Append(lines[j]).Append('\n');
403 return sb.ToString();
404 }
405
406 string Key(string block)
407 {
408 string s = Regex.Replace(block, @"!\‍[[^\‍]]*\‍]\‍([^)]*\‍)", ""); // drop images
409 s = Regex.Replace(s, @"\s+", " ");
410 return s.Trim().ToLowerInvariant();
411 }
412
413 var texts = ranges.Select(Text).ToList();
414 bool hasPreamble = headingIdx[0] > 0;
415
416 // For each de-dupable key, remember the index of the longest block carrying it.
417 var bestForKey = new Dictionary<string, int>();
418 for (int i = 0; i < ranges.Count; i++)
419 {
420 if (i == 0 && hasPreamble) continue; // preamble is unique, never deduped
421 string key = Key(texts[i]);
422 if (key.Length < 40) continue; // too small to safely treat as a duplicate section
423 if (false == bestForKey.TryGetValue(key, out int cur) || texts[i].Trim().Length > texts[cur].Trim().Length)
424 bestForKey[key] = i;
425 }
426
427 var dropped = new HashSet<int>();
428 for (int i = 0; i < ranges.Count; i++)
429 {
430 if (i == 0 && hasPreamble) continue;
431 string key = Key(texts[i]);
432 if (key.Length < 40) continue;
433 if (bestForKey.TryGetValue(key, out int keep) && keep != i)
434 dropped.Add(i);
435 }
436
437 if (0 == dropped.Count)
438 return markdown;
439
440 var outSb = new StringBuilder();
441 for (int i = 0; i < ranges.Count; i++)
442 if (false == dropped.Contains(i))
443 outSb.Append(texts[i]);
444
445 return outSb.ToString();
446 }
447
454 private static string DropEmptyHeadings(string markdown)
455 {
456 if (string.IsNullOrEmpty(markdown))
457 return markdown;
458
459 var lines = markdown.Split('\n');
460 var drop = new bool[lines.Length];
461
462 for (int i = 0; i < lines.Length; i++)
463 {
464 var m = Regex.Match(lines[i], @"^\s*(#{1,6})\s");
465 if (false == m.Success) continue;
466
467 int level = m.Groups[1].Value.Length;
468 bool hasContent = false;
469
470 for (int j = i + 1; j < lines.Length; j++)
471 {
472 var hm = Regex.Match(lines[j], @"^\s*(#{1,6})\s");
473 if (hm.Success)
474 {
475 if (hm.Groups[1].Value.Length <= level) break; // end of this heading's section
476 continue; // deeper heading: not our content
477 }
478 if (false == string.IsNullOrWhiteSpace(lines[j])) { hasContent = true; break; }
479 }
480
481 if (false == hasContent) drop[i] = true;
482 }
483
484 var sb = new StringBuilder();
485 for (int i = 0; i < lines.Length; i++)
486 if (false == drop[i]) sb.Append(lines[i]).Append('\n');
487
488 return sb.ToString();
489 }
490
491 private static void RemoveJunkNodes(HtmlDocument doc, List<string> junkSelectors)
492 {
493 foreach (var selector in junkSelectors)
494 {
495 var nodes = doc.DocumentNode.SelectNodes(selector)?.ToList() ?? new List<HtmlNode>();
496 foreach (var node in nodes)
497 {
498 try { node.Remove(); } catch { /* ignore */ }
499 }
500 }
501
502 // Strip most attributes except href, src, alt
503 foreach (var node in doc.DocumentNode.DescendantsAndSelf().ToList())
504 {
505 if (node.Attributes.Count == 0) continue;
506 var attrsToKeep = new[] { "href", "src", "alt" };
507 var attrsToRemove = node.Attributes.Where(a => !attrsToKeep.Contains(a.Name)).ToList();
508 foreach (var attr in attrsToRemove) node.Attributes.Remove(attr);
509 }
510 }
511
512 private static string ExtractSitemapAsMarkdown(string html, string baseUrl)
513 {
514 var sb = new StringBuilder();
515 sb.AppendLine("# Sitemap Extract");
516 sb.AppendLine("");
517
518 var doc = new HtmlDocument();
519 doc.LoadHtml(html);
520
521 // Find the main content container (h1 + div + h2 + p's)
522 var mainContainer = doc.DocumentNode.SelectSingleNode("//h1/ancestor::div") ?? doc.DocumentNode;
523
524 // Get title from <h1>
525 var titleNode = mainContainer.SelectSingleNode(".//h1");
526 if (titleNode != null)
527 {
528 sb.AppendLine($"## {titleNode.InnerText.Trim()}");
529 sb.AppendLine("");
530 }
531
532 // Get intro text (List from ... links)
533 var introDiv = mainContainer.SelectSingleNode(".//div[not(@class)]"); // first generic div
534 if (introDiv != null)
535 {
536 sb.AppendLine(introDiv.InnerText.Trim());
537 sb.AppendLine("");
538 }
539
540 // Get last updated
541 var updatedNode = mainContainer.SelectSingleNode(".//h2[contains(text(), 'Last updated')]");
542 if (updatedNode != null)
543 {
544 sb.AppendLine($"*{updatedNode.InnerText.Trim()}*");
545 sb.AppendLine("");
546 }
547
548 // Extract all <p> entries (each is a link + timestamp)
549 var entries = mainContainer.SelectNodes(".//p");
550 if (entries != null && entries.Count > 0)
551 {
552 foreach (var p in entries)
553 {
554 var link = p.SelectSingleNode(".//a");
555 if (link != null)
556 {
557 string title = link.InnerText.Trim();
558 string url = link.GetAttributeValue("href", "");
559
560 if (!string.IsNullOrEmpty(url))
561 {
562 // Make full URL if relative
563 if (!url.StartsWith("http"))
564 url = baseUrl + url;
565
566 sb.AppendLine($"- [{title}]({url})");
567 }
568 else
569 {
570 sb.AppendLine($"- {title}");
571 }
572 }
573 else
574 {
575 // Timestamp or other text in <p>
576 sb.AppendLine($" *{p.InnerText.Trim()}*");
577 }
578
579 sb.AppendLine("");
580 }
581 }
582 else
583 {
584 // Fallback: if no <p>, try text nodes or links directly
585 var links = mainContainer.SelectNodes(".//a");
586 if (links != null)
587 {
588 foreach (var link in links)
589 {
590 string title = link.InnerText.Trim();
591 string url = link.GetAttributeValue("href", "");
592 if (!string.IsNullOrEmpty(url))
593 {
594 if (!url.StartsWith("http"))
595 url = baseUrl + url;
596
597 sb.AppendLine($"- [{title}]({url})");
598 }
599 }
600 }
601 }
602
603 string final = sb.ToString();
604
605 // Final cleanup
606 final = Regex.Replace(final, @"(\r?\n\s*){4,}", "\n\n", RegexOptions.Multiline);
607 final = Regex.Replace(final, @"^\s*$\n", "", RegexOptions.Multiline);
608 final = final.Trim();
609
610 return final;
611 }
612
613 private static string ExtractMainContent(HtmlDocument doc, List<string> mainContentCandidates)
614 {
615 // Configured candidates: main, content wrappers, body fallback - tried in order
616 var candidates = mainContentCandidates
617 .Select(selector => doc.DocumentNode.SelectSingleNode(selector))
618 .Where(n => n != null)
619 .ToList();
620
621 // Add all large divs/section with reasonable size
622 var largeBlocks = doc.DocumentNode.SelectNodes("//div | //section")
623 ?.Where(n => n.InnerText.Length > 500 || n.SelectNodes(".//img")?.Count > 3)
624 ?.ToList() ?? new List<HtmlNode>();
625 candidates.AddRange(largeBlocks);
626
627 if (candidates.Any())
628 {
629 // Score: text length + images + prices ($) + links
630 var best = candidates
631 .OrderByDescending(n => n.InnerText.Length)
632 .ThenByDescending(n => n.SelectNodes(".//img")?.Count ?? 0)
633 .ThenByDescending(n => n.InnerText.Count(c => c == '$'))
634 .ThenByDescending(n => n.SelectNodes(".//a")?.Count ?? 0)
635 .FirstOrDefault();
636
637 if (best != null)
638 {
639 return best.InnerHtml;
640 }
641 }
642
643 // Additional fallback cleanup: remove empty nodes, short meaningless lines, duplicates
644 var allTextNodes = doc.DocumentNode.SelectNodes("//text()")
645 ?.Select(n => n.InnerText.Trim())
646 ?.Where(t => !string.IsNullOrWhiteSpace(t) && t.Length > 5) // skip very short
647 ?.ToList() ?? new List<string>();
648
649 // Remove duplicates (common in Amazon skeletons)
650 var uniqueText = new HashSet<string>(allTextNodes);
651 var cleanedText = string.Join("\n\n", uniqueText);
652
653 // If still empty, return full ParsedText as last resort
654 if (string.IsNullOrWhiteSpace(cleanedText))
655 {
656 cleanedText = doc.ParsedText.Trim();
657 }
658
659 return cleanedText;
660 }
661
662 private static int ScoreContentDensity(HtmlNode node)
663 {
664 if (node == null) return 0;
665 var textLen = node.InnerText?.Trim().Length ?? 0;
666 var htmlLen = node.InnerHtml?.Length ?? 0;
667 return htmlLen > 0 ? textLen * 100 / htmlLen : 0; // rough % text vs markup
668 }
669
674 private static string DeriveOrigin(string url)
675 {
676 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
677 return $"{uri.Scheme}://{uri.Authority}";
678
679 return "";
680 }
681
682 public static void SaveToFile(LLMDigestResult digest, string filePath)
683 {
684 if (!digest.Success)
685 {
686 // Do not abort the caller's run over a single unrecoverable page. Write a visible stub so the
687 // failure is recorded on disk and a batch walk moves on to the next URL.
688 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
689 $"Digest failed for [{digest.Url}] [{digest.Message}], writing stub file.", null, GPALObjectType.None);
690 File.WriteAllText(filePath, $"# Digest failed [{digest.Url}]{Environment.NewLine}{Environment.NewLine}{digest.Message}{Environment.NewLine}");
691 return;
692 }
693
694 var header = $@"# {digest.Title} [{digest.Url}]
695 **Extracted digest** — Original length: {digest.OriginalLength / 1024} KB -> Cleaned Markdown: ≈{digest.CleanedLength / 1024} KB
696 ---
697 ";
698 File.WriteAllText(filePath, header + digest.Markdown);
699 }
700 }
701}
List< string > EnabledPasses
Names of the post-conversion markdown cleanup passes to run, e.g. "PriceGluing".
List< string > JunkSelectors
XPath expressions for nodes to strip before conversion (ads, nav, cookie banners, etc....
string Name
Name of this rule set, e.g. "generic" or "indeed".
List< string > MainContentCandidates
XPath expressions tried in order to find the main content container.
string SitemapBaseUrl
Base URL used to resolve relative links when a page is treated as a sitemap. Empty means derive it fr...
string RuleSetUsed
Name of the DigestRuleSet (from LLMDigestRules.yaml) used to build this digest.