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). The candidates name ids and classes, so this has to
100 // run while the document still has them
101 string mainContentHtml = ExtractMainContent(doc, rules.MainContentCandidates) ?? doc.DocumentNode.InnerHtml;
102
103 // and the attributes come off once the choosing is done. Stripping them with the junk, which is
104 // where this used to happen, left every rule set's main content candidates matching nothing
105 mainContentHtml = StripAttributes(mainContentHtml);
106
107 // Convert to Markdown
108 var config = new Config
109 {
110 GithubFlavored = true
111 };
112 config.Formatting.RemoveComments = true;
113 config.Links.SmartHref = true;
114 // Aggressive unknown tag handling
115 config.Tags.Unknown = Config.UnknownTagsOption.Bypass;
116
117 var converter = new Converter(config);
118
119 if (true == result.Url.ToLower().Contains("sitemap") || // does url contain 'sitemap' it just might be
120 true == result.Url.ToLower().EndsWith(".xml") ||
121 true == SitemapExtractor.IsValidSitemapContent(mainContentHtml))
122 {
123 // Handle as plain-text sitemap (no HTML converter needed)
124 string sitemapBaseUrl = string.IsNullOrWhiteSpace(rules.SitemapBaseUrl)
125 ? DeriveOrigin(result.Url)
126 : rules.SitemapBaseUrl;
127
128 result.Markdown = ExtractSitemapAsMarkdown(mainContentHtml, sitemapBaseUrl);
129 // Optionally log or tag: result.IsSitemap = true;
130 }
131 else
132 {
133 result.Markdown = converter.Convert(mainContentHtml);
134 }
135
136 // The cleanup passes below match on LF, so CRLF runs are invisible to them: three CRLFs are
137 // not a run of three LFs. The sitemap route builds with AppendLine (CRLF on Windows) and the
138 // html converter returns LF. SaveToFile applies the platform ending once, on write
139 result.Markdown = result.Markdown?.Replace("\r\n", "\n").Replace("\r", "\n") ?? string.Empty;
140
141 // === POST-CONVERSION READABILITY CLEANUP ===
142
143 // 1. Collapse only very excessive blanks (keep section gaps). Four is the first run that is
144 // more than a section gap, and asking for five let a run of exactly four through untouched
145 if (rules.EnabledPasses.Contains("CollapseExcessiveBlanks"))
146 result.Markdown = Regex.Replace(result.Markdown, @"(\r?\n\s*){4,}", "\n\n\n", RegexOptions.Multiline);
147
148 // 2. Remove counters and empty icons
149 if (rules.EnabledPasses.Contains("RemoveCountersAndEmptyIcons"))
150 {
151 result.Markdown = Regex.Replace(result.Markdown, @"\‍(\d+\‍)", "", RegexOptions.Multiline);
152 result.Markdown = Regex.Replace(result.Markdown, @"\‍[\‍]\‍(.*?\‍)\s*|\‍[\‍]\s*", "", RegexOptions.Multiline);
153 }
154
155 // 2a. Drop images that never resolved a URL (lazy-loaded placeholders come through as
156 // ![alt]() ) and the stray "!" / "!!" bangs such removals leave behind. These dominate the
157 // noise on image-heavy pages (carousels, hero grids) and carry zero information for an LLM.
158 if (rules.EnabledPasses.Contains("RemoveEmptyImages"))
159 {
160 result.Markdown = Regex.Replace(result.Markdown, @"!\‍[[^\‍]]*\‍]\‍(\s*\‍)", "", RegexOptions.Multiline);
161 result.Markdown = Regex.Replace(result.Markdown, @"!{2,}", "", RegexOptions.Multiline);
162 }
163
164 // 2b. Strip standalone media-player control text (video.js and friends emit lines like
165 // "Play", "Unmute", "Current Time 0:00", "Duration 0:14", "Stream Type LIVE", "Fullscreen").
166 // The vjs- junk selectors remove most of it at the HTML stage; this catches any that survive
167 // or come from non-video.js players.
168 if (rules.EnabledPasses.Contains("StripMediaChrome"))
169 result.Markdown = Regex.Replace(result.Markdown,
170 @"(?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?$",
171 "", RegexOptions.Multiline);
172
173 // 3. Nuclear grid breaker: repeatedly split ALL consecutive images until none remain
174 // Insert \n after ) in every occurrence of ")[!\‍["
175 if (rules.EnabledPasses.Contains("ImageGridBreaker"))
176 {
177 string pattern = @")[!\‍[";
178 int startIndex = 0;
179
180 // Guard the search start: only ONE newline is inserted per match, so we advance by 2
181 // (past the ) and the inserted \n). The while-guard keeps startIndex within bounds so
182 // IndexOf can never be handed a startIndex past the (now longer) string and throw.
183 while (startIndex <= result.Markdown.Length)
184 {
185 int found = result.Markdown.IndexOf(pattern, startIndex);
186 if (found == -1)
187 break;
188
189 // Insert \n right after the ) — i.e. after position found
190 result.Markdown = result.Markdown.Insert(found + 1, "\n");
191
192 // Resume just past the ) and the newline we inserted so we do not re-match here
193 startIndex = found + 2;
194 }
195 }
196
197 // 4. Ensure every image ends with at least two newlines (breathing room). The (?!\‍]) guard
198 // skips images that are the inner part of a linked image ( [![alt](img)](url) ) so we do not
199 // split the link's closing ](url) onto its own line.
200 if (rules.EnabledPasses.Contains("ImageSpacing"))
201 result.Markdown = Regex.Replace(result.Markdown,
202 @"(!\‍[.*?\‍]\‍(.*?\‍))(?!\n{2,})(?!\‍])",
203 "$1\n\n",
204 RegexOptions.Multiline);
205
206 // 4-fix. Rejoin any linked image whose closing ](url) still got separated onto its own line
207 // (by an earlier pass or by the converter). Turns "[![alt](img)\n\n](url)" back into
208 // "[![alt](img)](url)" so the product link stays intact instead of leaving an orphan ](url).
209 if (rules.EnabledPasses.Contains("RejoinLinkedImages"))
210 result.Markdown = Regex.Replace(result.Markdown,
211 @"(!\‍[[^\‍]]*\‍]\‍([^)]*\‍))\s*\n\s*(\‍]\‍([^)]*\‍))",
212 "$1$2",
213 RegexOptions.Multiline);
214
215 // 4a. Collapse runs of the exact same image (identical alt AND url) that carousels/sliders
216 // emit repeatedly. Blank lines between images are preserved and do not break a run.
217 if (rules.EnabledPasses.Contains("DedupeConsecutiveImages"))
218 result.Markdown = DedupeConsecutiveImageLines(result.Markdown);
219
220 // 5. Product fix: image > bold brand/price
221 if (rules.EnabledPasses.Contains("ProductImageBrandPrice"))
222 result.Markdown = Regex.Replace(result.Markdown,
223 @"\‍[!\‍[([^\‍]]*)\‍]\‍([^\‍)]+\‍)]\‍(([^)]+)\‍)\s*\n?\s*\‍[([^\‍]]+)\‍]",
224 "![$1]($2)\n\n**$3**",
225 RegexOptions.Multiline);
226
227 // 6. Glue prices to brand if separated
228 if (rules.EnabledPasses.Contains("PriceGluing"))
229 result.Markdown = Regex.Replace(result.Markdown,
230 @"(\*\*[A-Za-z& ]+?\*\*)\s*\n\s*(\$[\d,]+(?:\.\d{2})?)",
231 "$1 $2",
232 RegexOptions.Multiline);
233
234 // 7. Headings spacing
235 if (rules.EnabledPasses.Contains("HeadingSpacing"))
236 {
237 result.Markdown = Regex.Replace(result.Markdown, @"(^[ \t]*#[^\n]+)", "\n\n$1\n", RegexOptions.Multiline);
238 result.Markdown = Regex.Replace(result.Markdown, @"(^[ \t]*##[^\n]+)", "\n$1\n", RegexOptions.Multiline);
239 }
240
241 // 8. Strip leaked script/JSON artifacts: literal escape sequences (backslash-n/t/r as text,
242 // which never occur in readable prose) and orphaned JS array/string fragments like "]) or ']).
243 // These leak from review/data widgets whose content is not a real <script> element.
244 if (rules.EnabledPasses.Contains("StripEscapeArtifacts"))
245 {
246 result.Markdown = Regex.Replace(result.Markdown, @"(\\‍[nrtbf])+", " ");
247 result.Markdown = Regex.Replace(result.Markdown, @"[""']\‍]\‍)", "");
248 // drop lines left with nothing but whitespace/quotes/brackets after the above
249 result.Markdown = Regex.Replace(result.Markdown, @"(?m)^[ \t""'\‍]\‍)]*\r?$", "");
250 }
251
252 // 9. Collapse duplicate sections: pages that ship a no-JS fallback plus a hydrated copy render the
253 // same heading + body twice (e.g. Nike "Back to School"/"TRENDING"). Keep one copy per section.
254 if (rules.EnabledPasses.Contains("DedupeSections"))
255 result.Markdown = DedupeSections(result.Markdown);
256
257 // 10. Drop headings whose section has no content (e.g. a "## Reviews" whose reviews were
258 // JS-loaded and stripped), while keeping parent headings that still have populated subsections.
259 if (rules.EnabledPasses.Contains("DropEmptyHeadings"))
260 result.Markdown = DropEmptyHeadings(result.Markdown);
261
262 // 11. One link per line. A row of cards converts to link after link on one line, which reads
263 // as one run-on sentence, so the split goes between one link closing and the next opening
264 // the space between the links is optional: a nav list converts to [a](url)[b](url), where a card
265 // grid converts to [a](url) [b](url), and matching only the first left the second as one line
266 if (rules.EnabledPasses.Contains("SplitConsecutiveLinks"))
267 result.Markdown = Regex.Replace(result.Markdown, @"\‍)[ \t]*\‍[(?=[^\‍]\n]*\‍]\‍()", ")\n[", RegexOptions.Multiline);
268
269 // 12. A digest is read on its own, away from the page, so a link to /docs resolves to nothing.
270 // Every root-relative target is given the page its own origin
271 if (rules.EnabledPasses.Contains("ResolveRelativeLinks"))
272 {
273 string digestOrigin = DeriveOrigin(result.Url)?.TrimEnd('/');
274
275 if (false == string.IsNullOrWhiteSpace(digestOrigin))
276 result.Markdown = Regex.Replace(result.Markdown, @"\‍]\‍((/[^)\s]*)\‍)", "](" + digestOrigin + "$1)");
277 }
278
279 // Final light trim (allow up to 2 newlines for section breaks)
280 result.Markdown = result.Markdown.Trim();
281 result.Markdown = Regex.Replace(result.Markdown, @"\n{4,}", "\n\n", RegexOptions.Multiline);
282
283 result.CleanedLength = result.Markdown.Length;
284 result.Success = true;
285 }
286 catch (Exception ex)
287 {
288 result.Success = false;
289 result.Message = ex.Message;
290
291 // Last-ditch recovery: some pages make a library step throw (e.g. ReverseMarkdown can throw
292 // "startIndex cannot be larger than length of string" on certain malformed HTML). Rather than
293 // fail the whole digest - which aborts a batch walk - salvage the readable text so the page
294 // still yields something usable and the run continues.
295 try
296 {
297 string fallback = HtmlToPlainText(htmlSource);
298 if (false == string.IsNullOrWhiteSpace(fallback))
299 {
300 result.Markdown = fallback;
301 result.CleanedLength = fallback.Length;
302 result.Success = true;
303 result.Message = "Recovered as plain text after error: " + ex.Message;
304 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
305 $"Digest conversion failed, recovered [{result.Url}] as plain text.",
306 null, GPALObjectType.None, ex);
307 }
308 }
309 catch
310 {
311 // keep the original failure if even plain-text extraction cannot run
312 }
313 }
314
315 return result;
316 }
317
322 private static string HtmlToPlainText(string html)
323 {
324 if (string.IsNullOrWhiteSpace(html))
325 return string.Empty;
326
327 try
328 {
329 var doc = new HtmlDocument();
330 doc.LoadHtml(html);
331
332 // Drop non-text nodes so their contents never leak into the salvaged text.
333 foreach (var node in doc.DocumentNode.SelectNodes("//script | //style | //noscript")?.ToList() ?? new List<HtmlNode>())
334 {
335 try { node.Remove(); } catch { /* ignore */ }
336 }
337
338 // Raw InnerText glues block elements together ("Black Friday in JulyEnds Sunday"). Insert a
339 // newline after each block-level element and after <br> so the salvaged text stays readable.
340 string markup = doc.DocumentNode.InnerHtml ?? string.Empty;
341 markup = Regex.Replace(markup,
342 @"(?i)</(p|div|li|ul|ol|section|article|header|footer|nav|main|aside|tr|table|h[1-6]|blockquote|figure|figcaption)\s*>|<br\s*/?>",
343 "$0\n");
344
345 var stripped = new HtmlDocument();
346 stripped.LoadHtml(markup);
347 string text = HtmlEntity.DeEntitize(stripped.DocumentNode.InnerText ?? string.Empty);
348
349 // collapse runs of spaces/tabs, tidy the inserted breaks, keep paragraph gaps
350 text = Regex.Replace(text, @"[ \t]+", " ");
351 text = Regex.Replace(text, @" *\n *", "\n");
352 text = Regex.Replace(text, @"\n{3,}", "\n\n");
353 return text.Trim();
354 }
355 catch
356 {
357 return html;
358 }
359 }
360
367 private static string DedupeConsecutiveImageLines(string markdown)
368 {
369 if (string.IsNullOrEmpty(markdown))
370 return markdown;
371
372 var sb = new StringBuilder(markdown.Length);
373 string lastImage = null;
374
375 foreach (var line in markdown.Split('\n'))
376 {
377 string trimmed = line.Trim();
378
379 if (string.IsNullOrEmpty(trimmed))
380 {
381 // blanks pass through untouched and do not break a run of duplicate images
382 sb.Append(line).Append('\n');
383 continue;
384 }
385
386 bool isImage = trimmed.StartsWith("![");
387
388 if (isImage && trimmed == lastImage)
389 continue; // exact duplicate of the previous image - drop it
390
391 sb.Append(line).Append('\n');
392 lastImage = isImage ? trimmed : null;
393 }
394
395 return sb.ToString();
396 }
397
405 private static string DedupeSections(string markdown)
406 {
407 if (string.IsNullOrEmpty(markdown))
408 return markdown;
409
410 var lines = markdown.Split('\n');
411
412 var headingIdx = new List<int>();
413 for (int i = 0; i < lines.Length; i++)
414 if (Regex.IsMatch(lines[i], @"^\s*#{1,6}\s"))
415 headingIdx.Add(i);
416
417 if (headingIdx.Count < 2)
418 return markdown; // nothing to compare
419
420 // Build the block ranges: an optional preamble before the first heading, then one block per heading.
421 var ranges = new List<(int start, int end)>();
422 if (headingIdx[0] > 0)
423 ranges.Add((0, headingIdx[0]));
424 for (int k = 0; k < headingIdx.Count; k++)
425 ranges.Add((headingIdx[k], k + 1 < headingIdx.Count ? headingIdx[k + 1] : lines.Length));
426
427 string Text((int start, int end) r)
428 {
429 var sb = new StringBuilder();
430 for (int j = r.start; j < r.end; j++) sb.Append(lines[j]).Append('\n');
431 return sb.ToString();
432 }
433
434 string Key(string block)
435 {
436 string s = Regex.Replace(block, @"!\‍[[^\‍]]*\‍]\‍([^)]*\‍)", ""); // drop images
437 s = Regex.Replace(s, @"\s+", " ");
438 return s.Trim().ToLowerInvariant();
439 }
440
441 var texts = ranges.Select(Text).ToList();
442 bool hasPreamble = headingIdx[0] > 0;
443
444 // For each de-dupable key, remember the index of the longest block carrying it.
445 var bestForKey = new Dictionary<string, int>();
446 for (int i = 0; i < ranges.Count; i++)
447 {
448 if (i == 0 && hasPreamble) continue; // preamble is unique, never deduped
449 string key = Key(texts[i]);
450 if (key.Length < 40) continue; // too small to safely treat as a duplicate section
451 if (false == bestForKey.TryGetValue(key, out int cur) || texts[i].Trim().Length > texts[cur].Trim().Length)
452 bestForKey[key] = i;
453 }
454
455 var dropped = new HashSet<int>();
456 for (int i = 0; i < ranges.Count; i++)
457 {
458 if (i == 0 && hasPreamble) continue;
459 string key = Key(texts[i]);
460 if (key.Length < 40) continue;
461 if (bestForKey.TryGetValue(key, out int keep) && keep != i)
462 dropped.Add(i);
463 }
464
465 if (0 == dropped.Count)
466 return markdown;
467
468 var outSb = new StringBuilder();
469 for (int i = 0; i < ranges.Count; i++)
470 if (false == dropped.Contains(i))
471 outSb.Append(texts[i]);
472
473 return outSb.ToString();
474 }
475
482 private static string DropEmptyHeadings(string markdown)
483 {
484 if (string.IsNullOrEmpty(markdown))
485 return markdown;
486
487 var lines = markdown.Split('\n');
488 var drop = new bool[lines.Length];
489
490 for (int i = 0; i < lines.Length; i++)
491 {
492 var m = Regex.Match(lines[i], @"^\s*(#{1,6})\s");
493 if (false == m.Success) continue;
494
495 int level = m.Groups[1].Value.Length;
496 bool hasContent = false;
497
498 for (int j = i + 1; j < lines.Length; j++)
499 {
500 var hm = Regex.Match(lines[j], @"^\s*(#{1,6})\s");
501 if (hm.Success)
502 {
503 if (hm.Groups[1].Value.Length <= level) break; // end of this heading's section
504 continue; // deeper heading: not our content
505 }
506 if (false == string.IsNullOrWhiteSpace(lines[j])) { hasContent = true; break; }
507 }
508
509 if (false == hasContent) drop[i] = true;
510 }
511
512 var sb = new StringBuilder();
513 for (int i = 0; i < lines.Length; i++)
514 if (false == drop[i]) sb.Append(lines[i]).Append('\n');
515
516 return sb.ToString();
517 }
518
519 private static void RemoveJunkNodes(HtmlDocument doc, List<string> junkSelectors)
520 {
521 foreach (var selector in junkSelectors)
522 {
523 var nodes = doc.DocumentNode.SelectNodes(selector)?.ToList() ?? new List<HtmlNode>();
524 foreach (var node in nodes)
525 {
526 // no junk rule means "delete the page", and one that matches html or body says exactly that:
527 // a class on the body, like the didomi-popup-open a consent banner adds, is enough to take
528 // the whole document out and leave a digest holding the title and nothing else
529 if (true == "html".Equals(node.Name, StringComparison.OrdinalIgnoreCase)
530 || true == "body".Equals(node.Name, StringComparison.OrdinalIgnoreCase))
531 {
532 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"A junk selector matched the [{node.Name}] element itself and was not applied to it: [{selector}]", null, GPALObjectType.None);
533 continue;
534 }
535
536 try { node.Remove(); } catch { /* ignore */ }
537 }
538 }
539
540 }
541
550 private static string StripAttributes(string html)
551 {
552 string retVal = html;
553
554 if (false == string.IsNullOrWhiteSpace(html))
555 {
556 var doc = new HtmlDocument();
557 doc.OptionAutoCloseOnEnd = true;
558 doc.OptionFixNestedTags = true;
559 doc.LoadHtml(html);
560
561 string[] attrsToKeep = new[] { "href", "src", "alt" };
562
563 foreach (var node in doc.DocumentNode.DescendantsAndSelf().ToList())
564 {
565 if (node.Attributes.Count == 0) continue;
566 var attrsToRemove = node.Attributes.Where(a => !attrsToKeep.Contains(a.Name)).ToList();
567 foreach (var attr in attrsToRemove) node.Attributes.Remove(attr);
568 }
569
570 retVal = doc.DocumentNode.InnerHtml;
571 }
572
573 return retVal;
574 }
575
576 private static string ExtractSitemapAsMarkdown(string html, string baseUrl)
577 {
578 var sb = new StringBuilder();
579 sb.AppendLine("# Sitemap Extract");
580 sb.AppendLine("");
581
582 var doc = new HtmlDocument();
583 doc.LoadHtml(html);
584
585 // Find the main content container (h1 + div + h2 + p's)
586 var mainContainer = doc.DocumentNode.SelectSingleNode("//h1/ancestor::div") ?? doc.DocumentNode;
587
588 // Get title from <h1>
589 var titleNode = mainContainer.SelectSingleNode(".//h1");
590 if (titleNode != null)
591 {
592 sb.AppendLine($"## {titleNode.InnerText.Trim()}");
593 sb.AppendLine("");
594 }
595
596 // Get intro text (List from ... links)
597 var introDiv = mainContainer.SelectSingleNode(".//div[not(@class)]"); // first generic div
598 if (introDiv != null)
599 {
600 sb.AppendLine(introDiv.InnerText.Trim());
601 sb.AppendLine("");
602 }
603
604 // Get last updated
605 var updatedNode = mainContainer.SelectSingleNode(".//h2[contains(text(), 'Last updated')]");
606 if (updatedNode != null)
607 {
608 sb.AppendLine($"*{updatedNode.InnerText.Trim()}*");
609 sb.AppendLine("");
610 }
611
612 // Extract all <p> entries (each is a link + timestamp)
613 var entries = mainContainer.SelectNodes(".//p");
614 if (entries != null && entries.Count > 0)
615 {
616 foreach (var p in entries)
617 {
618 var link = p.SelectSingleNode(".//a");
619 if (link != null)
620 {
621 string title = link.InnerText.Trim();
622 string url = link.GetAttributeValue("href", "");
623
624 if (!string.IsNullOrEmpty(url))
625 {
626 // Make full URL if relative
627 if (!url.StartsWith("http"))
628 url = baseUrl + url;
629
630 sb.AppendLine($"- [{title}]({url})");
631 }
632 else
633 {
634 sb.AppendLine($"- {title}");
635 }
636 }
637 else
638 {
639 // Timestamp or other text in <p>
640 sb.AppendLine($" *{p.InnerText.Trim()}*");
641 }
642
643 sb.AppendLine("");
644 }
645 }
646 else
647 {
648 // Fallback: if no <p>, try text nodes or links directly
649 var links = mainContainer.SelectNodes(".//a");
650 if (links != null)
651 {
652 foreach (var link in links)
653 {
654 string title = link.InnerText.Trim();
655 string url = link.GetAttributeValue("href", "");
656 if (!string.IsNullOrEmpty(url))
657 {
658 if (!url.StartsWith("http"))
659 url = baseUrl + url;
660
661 sb.AppendLine($"- [{title}]({url})");
662 }
663 }
664 }
665 }
666
667 string final = sb.ToString();
668
669 // Final cleanup
670 final = Regex.Replace(final, @"(\r?\n\s*){4,}", "\n\n", RegexOptions.Multiline);
671 final = Regex.Replace(final, @"^\s*$\n", "", RegexOptions.Multiline);
672 final = final.Trim();
673
674 return final;
675 }
676
677 private static string ExtractMainContent(HtmlDocument doc, List<string> mainContentCandidates)
678 {
679 // Configured candidates: main, content wrappers, body fallback - tried in order
680 var candidates = mainContentCandidates
681 .Select(selector => doc.DocumentNode.SelectSingleNode(selector))
682 .Where(n => n != null)
683 .ToList();
684
685 // Add all large divs/section with reasonable size
686 var largeBlocks = doc.DocumentNode.SelectNodes("//div | //section")
687 ?.Where(n => n.InnerText.Length > 500 || n.SelectNodes(".//img")?.Count > 3)
688 ?.ToList() ?? new List<HtmlNode>();
689 candidates.AddRange(largeBlocks);
690
691 if (candidates.Any())
692 {
693 // Score: text length + images + prices ($) + links
694 var best = candidates
695 .OrderByDescending(n => n.InnerText.Length)
696 .ThenByDescending(n => n.SelectNodes(".//img")?.Count ?? 0)
697 .ThenByDescending(n => n.InnerText.Count(c => c == '$'))
698 .ThenByDescending(n => n.SelectNodes(".//a")?.Count ?? 0)
699 .FirstOrDefault();
700
701 if (best != null)
702 {
703 return best.InnerHtml;
704 }
705 }
706
707 // Additional fallback cleanup: remove empty nodes, short meaningless lines, duplicates
708 var allTextNodes = doc.DocumentNode.SelectNodes("//text()")
709 ?.Select(n => n.InnerText.Trim())
710 ?.Where(t => !string.IsNullOrWhiteSpace(t) && t.Length > 5) // skip very short
711 ?.ToList() ?? new List<string>();
712
713 // Remove duplicates (common in Amazon skeletons)
714 var uniqueText = new HashSet<string>(allTextNodes);
715 var cleanedText = string.Join("\n\n", uniqueText);
716
717 // If still empty, return full ParsedText as last resort
718 if (string.IsNullOrWhiteSpace(cleanedText))
719 {
720 cleanedText = doc.ParsedText.Trim();
721 }
722
723 return cleanedText;
724 }
725
726 private static int ScoreContentDensity(HtmlNode node)
727 {
728 if (node == null) return 0;
729 var textLen = node.InnerText?.Trim().Length ?? 0;
730 var htmlLen = node.InnerHtml?.Length ?? 0;
731 return htmlLen > 0 ? textLen * 100 / htmlLen : 0; // rough % text vs markup
732 }
733
738 private static string DeriveOrigin(string url)
739 {
740 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
741 return $"{uri.Scheme}://{uri.Authority}";
742
743 return "";
744 }
745
746 public static void SaveToFile(LLMDigestResult digest, string filePath)
747 {
748 if (!digest.Success)
749 {
750 // Do not abort the caller's run over a single unrecoverable page. Write a visible stub so the
751 // failure is recorded on disk and a batch walk moves on to the next URL.
752 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
753 $"Digest failed for [{digest.Url}] [{digest.Message}], writing stub file.", null, GPALObjectType.None);
754 File.WriteAllText(filePath, $"# Digest failed [{digest.Url}]{Environment.NewLine}{Environment.NewLine}{digest.Message}{Environment.NewLine}");
755 return;
756 }
757
758 // Built line by line. A multi-line @"" literal writes this file's own indentation into the output
759 string header = string.Join(Environment.NewLine, new[]
760 {
761 $"# {digest.Title} [{digest.Url}]",
762 $"**Extracted digest** - Original length: {digest.OriginalLength / 1024} KB -> Cleaned Markdown: about {digest.CleanedLength / 1024} KB",
763 "---",
764 ""
765 });
766
767 // The digest is held in LF throughout; the platform ending is applied here
768 string body = (digest.Markdown ?? string.Empty).Replace("\r\n", "\n").Replace("\n", Environment.NewLine);
769
770 File.WriteAllText(filePath, header + body);
771 }
772 }
773}
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.