18using System.Collections.Generic;
22using System.Text.RegularExpressions;
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;
47 internal static class HtmlToOptimizedMarkdown
55 public static LLMDigestResult CreateDigest(Browser.Browser browser,
string htmlSource,
string ruleSetName =
null)
57 var result =
new LLMDigestResult { OriginalLength = htmlSource?.Length ?? 0 };
59 if (
string.IsNullOrWhiteSpace(htmlSource))
61 result.Message =
"Empty input";
67 result.Url = browser.GetSetCurrentUrl();
69 DigestRuleSet rules = DigestRulesConfig.Load().SelectFor(result.Url, ruleSetName);
70 result.RuleSetUsed = rules.
Name;
72 var doc =
new HtmlDocument();
73 doc.OptionAutoCloseOnEnd =
true;
74 doc.OptionFixNestedTags =
true;
75 doc.LoadHtml(htmlSource);
77 var products = doc.DocumentNode.SelectNodes(
"//a[contains(@href,'/p/')]");
80 foreach (var link
in products)
82 var price = link.SelectSingleNode(
".//span[contains(@class,'price') or contains(text(),'$')] | .//following-sibling::*[contains(text(),'$')]");
83 if (price !=
null && !
string.IsNullOrWhiteSpace(price.InnerText))
85 var priceNode = doc.CreateElement(
"span");
86 priceNode.InnerHtml = $
" <strong>{price.InnerText.Trim()}</strong>";
87 link.ParentNode.InsertAfter(priceNode, link);
93 var titleNode = doc.DocumentNode.SelectSingleNode(
"//title");
94 if (titleNode !=
null) result.Title = titleNode.InnerText.Trim();
101 string mainContentHtml = ExtractMainContent(doc, rules.
MainContentCandidates) ?? doc.DocumentNode.InnerHtml;
105 mainContentHtml = StripAttributes(mainContentHtml);
108 var config =
new Config
110 GithubFlavored =
true
112 config.Formatting.RemoveComments =
true;
113 config.Links.SmartHref =
true;
115 config.Tags.Unknown = Config.UnknownTagsOption.Bypass;
117 var converter =
new Converter(config);
119 if (
true == result.Url.ToLower().Contains(
"sitemap") ||
120 true == result.Url.ToLower().EndsWith(
".xml") ||
121 true == SitemapExtractor.IsValidSitemapContent(mainContentHtml))
124 string sitemapBaseUrl =
string.IsNullOrWhiteSpace(rules.
SitemapBaseUrl)
125 ? DeriveOrigin(result.Url)
126 : rules.SitemapBaseUrl;
128 result.Markdown = ExtractSitemapAsMarkdown(mainContentHtml, sitemapBaseUrl);
133 result.Markdown = converter.Convert(mainContentHtml);
139 result.Markdown = result.Markdown?.Replace(
"\r\n",
"\n").Replace(
"\r",
"\n") ??
string.Empty;
146 result.Markdown = Regex.Replace(result.Markdown,
@"(\r?\n\s*){4,}",
"\n\n\n", RegexOptions.Multiline);
149 if (rules.
EnabledPasses.Contains(
"RemoveCountersAndEmptyIcons"))
151 result.Markdown = Regex.Replace(result.Markdown,
@"\(\d+\)",
"", RegexOptions.Multiline);
152 result.Markdown = Regex.Replace(result.Markdown,
@"\[\]\(.*?\)\s*|\[\]\s*",
"", RegexOptions.Multiline);
160 result.Markdown = Regex.Replace(result.Markdown,
@"!\[[^\]]*\]\(\s*\)",
"", RegexOptions.Multiline);
161 result.Markdown = Regex.Replace(result.Markdown,
@"!{2,}",
"", RegexOptions.Multiline);
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);
177 string pattern =
@")[!\[";
183 while (startIndex <= result.Markdown.Length)
185 int found = result.Markdown.IndexOf(pattern, startIndex);
190 result.Markdown = result.Markdown.Insert(found + 1,
"\n");
193 startIndex = found + 2;
201 result.Markdown = Regex.Replace(result.Markdown,
202 @"(!\[.*?\]\(.*?\))(?!\n{2,})(?!\])",
204 RegexOptions.Multiline);
210 result.Markdown = Regex.Replace(result.Markdown,
211 @"(!\[[^\]]*\]\([^)]*\))\s*\n\s*(\]\([^)]*\))",
213 RegexOptions.Multiline);
218 result.Markdown = DedupeConsecutiveImageLines(result.Markdown);
222 result.Markdown = Regex.Replace(result.Markdown,
223 @"\[!\[([^\]]*)\]\([^\)]+\)]\(([^)]+)\)\s*\n?\s*\[([^\]]+)\]",
224 "\n\n**$3**",
225 RegexOptions.Multiline);
229 result.Markdown = Regex.Replace(result.Markdown,
230 @"(\*\*[A-Za-z& ]+?\*\*)\s*\n\s*(\$[\d,]+(?:\.\d{2})?)",
232 RegexOptions.Multiline);
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);
246 result.Markdown = Regex.Replace(result.Markdown,
@"(\\[nrtbf])+",
" ");
247 result.Markdown = Regex.Replace(result.Markdown,
@"[""']\]\)",
"");
249 result.Markdown = Regex.Replace(result.Markdown,
@"(?m)^[ \t""'\]\)]*\r?$",
"");
255 result.Markdown = DedupeSections(result.Markdown);
260 result.Markdown = DropEmptyHeadings(result.Markdown);
267 result.Markdown = Regex.Replace(result.Markdown,
@"\)[ \t]*\[(?=[^\]\n]*\]\()",
")\n[", RegexOptions.Multiline);
273 string digestOrigin = DeriveOrigin(result.Url)?.TrimEnd(
'/');
275 if (
false ==
string.IsNullOrWhiteSpace(digestOrigin))
276 result.Markdown = Regex.Replace(result.Markdown,
@"\]\((/[^)\s]*)\)",
"](" + digestOrigin +
"$1)");
280 result.Markdown = result.Markdown.Trim();
281 result.Markdown = Regex.Replace(result.Markdown,
@"\n{4,}",
"\n\n", RegexOptions.Multiline);
283 result.CleanedLength = result.Markdown.Length;
284 result.Success =
true;
288 result.Success =
false;
289 result.Message = ex.Message;
297 string fallback = HtmlToPlainText(htmlSource);
298 if (
false ==
string.IsNullOrWhiteSpace(fallback))
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);
322 private static string HtmlToPlainText(
string html)
324 if (
string.IsNullOrWhiteSpace(html))
329 var doc =
new HtmlDocument();
333 foreach (var node
in doc.DocumentNode.SelectNodes(
"//script | //style | //noscript")?.ToList() ??
new List<HtmlNode>())
335 try { node.Remove(); }
catch { }
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*/?>",
345 var stripped =
new HtmlDocument();
346 stripped.LoadHtml(markup);
347 string text = HtmlEntity.DeEntitize(stripped.DocumentNode.InnerText ??
string.Empty);
350 text = Regex.Replace(text,
@"[ \t]+",
" ");
351 text = Regex.Replace(text,
@" *\n *",
"\n");
352 text = Regex.Replace(text,
@"\n{3,}",
"\n\n");
367 private static string DedupeConsecutiveImageLines(
string markdown)
369 if (
string.IsNullOrEmpty(markdown))
372 var sb =
new StringBuilder(markdown.Length);
373 string lastImage =
null;
375 foreach (var line
in markdown.Split(
'\n'))
377 string trimmed = line.Trim();
379 if (
string.IsNullOrEmpty(trimmed))
382 sb.Append(line).Append(
'\n');
386 bool isImage = trimmed.StartsWith(
"![");
388 if (isImage && trimmed == lastImage)
391 sb.Append(line).Append(
'\n');
392 lastImage = isImage ? trimmed :
null;
395 return sb.ToString();
405 private static string DedupeSections(
string markdown)
407 if (
string.IsNullOrEmpty(markdown))
410 var lines = markdown.Split(
'\n');
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"))
417 if (headingIdx.Count < 2)
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));
427 string Text((
int start,
int end) r)
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();
434 string Key(
string block)
436 string s = Regex.Replace(block,
@"!\[[^\]]*\]\([^)]*\)",
"");
437 s = Regex.Replace(s,
@"\s+",
" ");
438 return s.Trim().ToLowerInvariant();
441 var texts = ranges.Select(Text).ToList();
442 bool hasPreamble = headingIdx[0] > 0;
445 var bestForKey =
new Dictionary<string, int>();
446 for (
int i = 0; i < ranges.Count; i++)
448 if (i == 0 && hasPreamble)
continue;
449 string key = Key(texts[i]);
450 if (key.Length < 40)
continue;
451 if (
false == bestForKey.TryGetValue(key, out
int cur) || texts[i].Trim().Length > texts[cur].Trim().Length)
455 var dropped =
new HashSet<int>();
456 for (
int i = 0; i < ranges.Count; i++)
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)
465 if (0 == dropped.Count)
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]);
473 return outSb.ToString();
482 private static string DropEmptyHeadings(
string markdown)
484 if (
string.IsNullOrEmpty(markdown))
487 var lines = markdown.Split(
'\n');
488 var drop =
new bool[lines.Length];
490 for (
int i = 0; i < lines.Length; i++)
492 var m = Regex.Match(lines[i],
@"^\s*(#{1,6})\s");
493 if (
false == m.Success)
continue;
495 int level = m.Groups[1].Value.Length;
496 bool hasContent =
false;
498 for (
int j = i + 1; j < lines.Length; j++)
500 var hm = Regex.Match(lines[j],
@"^\s*(#{1,6})\s");
503 if (hm.Groups[1].Value.Length <= level)
break;
506 if (
false ==
string.IsNullOrWhiteSpace(lines[j])) { hasContent =
true;
break; }
509 if (
false == hasContent) drop[i] =
true;
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');
516 return sb.ToString();
519 private static void RemoveJunkNodes(HtmlDocument doc, List<string> junkSelectors)
521 foreach (var selector
in junkSelectors)
523 var nodes = doc.DocumentNode.SelectNodes(selector)?.ToList() ??
new List<HtmlNode>();
524 foreach (var node
in nodes)
529 if (
true ==
"html".Equals(node.Name, StringComparison.OrdinalIgnoreCase)
530 ||
true ==
"body".Equals(node.Name, StringComparison.OrdinalIgnoreCase))
532 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $
"A junk selector matched the [{node.Name}] element itself and was not applied to it: [{selector}]",
null, GPALObjectType.None);
536 try { node.Remove(); }
catch { }
550 private static string StripAttributes(
string html)
552 string retVal = html;
554 if (
false ==
string.IsNullOrWhiteSpace(html))
556 var doc =
new HtmlDocument();
557 doc.OptionAutoCloseOnEnd =
true;
558 doc.OptionFixNestedTags =
true;
561 string[] attrsToKeep =
new[] {
"href",
"src",
"alt" };
563 foreach (var node
in doc.DocumentNode.DescendantsAndSelf().ToList())
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);
570 retVal = doc.DocumentNode.InnerHtml;
576 private static string ExtractSitemapAsMarkdown(
string html,
string baseUrl)
578 var sb =
new StringBuilder();
579 sb.AppendLine(
"# Sitemap Extract");
582 var doc =
new HtmlDocument();
586 var mainContainer = doc.DocumentNode.SelectSingleNode(
"//h1/ancestor::div") ?? doc.DocumentNode;
589 var titleNode = mainContainer.SelectSingleNode(
".//h1");
590 if (titleNode !=
null)
592 sb.AppendLine($
"## {titleNode.InnerText.Trim()}");
597 var introDiv = mainContainer.SelectSingleNode(
".//div[not(@class)]");
598 if (introDiv !=
null)
600 sb.AppendLine(introDiv.InnerText.Trim());
605 var updatedNode = mainContainer.SelectSingleNode(
".//h2[contains(text(), 'Last updated')]");
606 if (updatedNode !=
null)
608 sb.AppendLine($
"*{updatedNode.InnerText.Trim()}*");
613 var entries = mainContainer.SelectNodes(
".//p");
614 if (entries !=
null && entries.Count > 0)
616 foreach (var p
in entries)
618 var link = p.SelectSingleNode(
".//a");
621 string title = link.InnerText.Trim();
622 string url = link.GetAttributeValue(
"href",
"");
624 if (!
string.IsNullOrEmpty(url))
627 if (!url.StartsWith(
"http"))
630 sb.AppendLine($
"- [{title}]({url})");
634 sb.AppendLine($
"- {title}");
640 sb.AppendLine($
" *{p.InnerText.Trim()}*");
649 var links = mainContainer.SelectNodes(
".//a");
652 foreach (var link
in links)
654 string title = link.InnerText.Trim();
655 string url = link.GetAttributeValue(
"href",
"");
656 if (!
string.IsNullOrEmpty(url))
658 if (!url.StartsWith(
"http"))
661 sb.AppendLine($
"- [{title}]({url})");
667 string final = sb.ToString();
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();
677 private static string ExtractMainContent(HtmlDocument doc, List<string> mainContentCandidates)
680 var candidates = mainContentCandidates
681 .Select(selector => doc.DocumentNode.SelectSingleNode(selector))
682 .Where(n => n !=
null)
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);
691 if (candidates.Any())
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)
703 return best.InnerHtml;
708 var allTextNodes = doc.DocumentNode.SelectNodes(
"//text()")
709 ?.Select(n => n.InnerText.Trim())
710 ?.Where(t => !
string.IsNullOrWhiteSpace(t) && t.Length > 5)
711 ?.ToList() ??
new List<string>();
714 var uniqueText =
new HashSet<string>(allTextNodes);
715 var cleanedText =
string.Join(
"\n\n", uniqueText);
718 if (
string.IsNullOrWhiteSpace(cleanedText))
720 cleanedText = doc.ParsedText.Trim();
726 private static int ScoreContentDensity(HtmlNode node)
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;
738 private static string DeriveOrigin(
string url)
740 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
741 return $
"{uri.Scheme}://{uri.Authority}";
746 public static void SaveToFile(LLMDigestResult digest,
string filePath)
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}");
759 string header =
string.Join(Environment.NewLine,
new[]
761 $
"# {digest.Title} [{digest.Url}]",
762 $
"**Extracted digest** - Original length: {digest.OriginalLength / 1024} KB -> Cleaned Markdown: about {digest.CleanedLength / 1024} KB",
768 string body = (digest.Markdown ??
string.Empty).Replace(
"\r\n",
"\n").Replace(
"\n", Environment.NewLine);
770 File.WriteAllText(filePath, header + body);
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.