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();
100 string mainContentHtml = ExtractMainContent(doc, rules.
MainContentCandidates) ?? doc.DocumentNode.InnerHtml;
103 var config =
new Config
105 GithubFlavored =
true
107 config.Formatting.RemoveComments =
true;
108 config.Links.SmartHref =
true;
110 config.Tags.Unknown = Config.UnknownTagsOption.Bypass;
112 var converter =
new Converter(config);
114 if (
true == result.Url.ToLower().Contains(
"sitemap") ||
115 true == result.Url.ToLower().EndsWith(
".xml") ||
116 true == SitemapExtractor.IsValidSitemapContent(mainContentHtml))
119 string sitemapBaseUrl =
string.IsNullOrWhiteSpace(rules.
SitemapBaseUrl)
120 ? DeriveOrigin(result.Url)
121 : rules.SitemapBaseUrl;
123 result.Markdown = ExtractSitemapAsMarkdown(mainContentHtml, sitemapBaseUrl);
128 result.Markdown = converter.Convert(mainContentHtml);
135 result.Markdown = Regex.Replace(result.Markdown,
@"(\r?\n\s*){5,}",
"\n\n\n", RegexOptions.Multiline);
138 if (rules.
EnabledPasses.Contains(
"RemoveCountersAndEmptyIcons"))
140 result.Markdown = Regex.Replace(result.Markdown,
@"\(\d+\)",
"", RegexOptions.Multiline);
141 result.Markdown = Regex.Replace(result.Markdown,
@"\[\]\(.*?\)\s*|\[\]\s*",
"", RegexOptions.Multiline);
149 result.Markdown = Regex.Replace(result.Markdown,
@"!\[[^\]]*\]\(\s*\)",
"", RegexOptions.Multiline);
150 result.Markdown = Regex.Replace(result.Markdown,
@"!{2,}",
"", RegexOptions.Multiline);
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);
166 string pattern =
@")[!\[";
172 while (startIndex <= result.Markdown.Length)
174 int found = result.Markdown.IndexOf(pattern, startIndex);
179 result.Markdown = result.Markdown.Insert(found + 1,
"\n");
182 startIndex = found + 2;
190 result.Markdown = Regex.Replace(result.Markdown,
191 @"(!\[.*?\]\(.*?\))(?!\n{2,})(?!\])",
193 RegexOptions.Multiline);
199 result.Markdown = Regex.Replace(result.Markdown,
200 @"(!\[[^\]]*\]\([^)]*\))\s*\n\s*(\]\([^)]*\))",
202 RegexOptions.Multiline);
207 result.Markdown = DedupeConsecutiveImageLines(result.Markdown);
211 result.Markdown = Regex.Replace(result.Markdown,
212 @"\[!\[([^\]]*)\]\([^\)]+\)]\(([^)]+)\)\s*\n?\s*\[([^\]]+)\]",
213 "\n\n**$3**",
214 RegexOptions.Multiline);
218 result.Markdown = Regex.Replace(result.Markdown,
219 @"(\*\*[A-Za-z& ]+?\*\*)\s*\n\s*(\$[\d,]+(?:\.\d{2})?)",
221 RegexOptions.Multiline);
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);
235 result.Markdown = Regex.Replace(result.Markdown,
@"(\\[nrtbf])+",
" ");
236 result.Markdown = Regex.Replace(result.Markdown,
@"[""']\]\)",
"");
238 result.Markdown = Regex.Replace(result.Markdown,
@"(?m)^[ \t""'\]\)]*\r?$",
"");
244 result.Markdown = DedupeSections(result.Markdown);
249 result.Markdown = DropEmptyHeadings(result.Markdown);
252 result.Markdown = result.Markdown.Trim();
253 result.Markdown = Regex.Replace(result.Markdown,
@"\n{5,}",
"\n\n", RegexOptions.Multiline);
255 result.CleanedLength = result.Markdown.Length;
256 result.Success =
true;
260 result.Success =
false;
261 result.Message = ex.Message;
269 string fallback = HtmlToPlainText(htmlSource);
270 if (
false ==
string.IsNullOrWhiteSpace(fallback))
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);
294 private static string HtmlToPlainText(
string html)
296 if (
string.IsNullOrWhiteSpace(html))
301 var doc =
new HtmlDocument();
305 foreach (var node
in doc.DocumentNode.SelectNodes(
"//script | //style | //noscript")?.ToList() ??
new List<HtmlNode>())
307 try { node.Remove(); }
catch { }
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*/?>",
317 var stripped =
new HtmlDocument();
318 stripped.LoadHtml(markup);
319 string text = HtmlEntity.DeEntitize(stripped.DocumentNode.InnerText ??
string.Empty);
322 text = Regex.Replace(text,
@"[ \t]+",
" ");
323 text = Regex.Replace(text,
@" *\n *",
"\n");
324 text = Regex.Replace(text,
@"\n{3,}",
"\n\n");
339 private static string DedupeConsecutiveImageLines(
string markdown)
341 if (
string.IsNullOrEmpty(markdown))
344 var sb =
new StringBuilder(markdown.Length);
345 string lastImage =
null;
347 foreach (var line
in markdown.Split(
'\n'))
349 string trimmed = line.Trim();
351 if (
string.IsNullOrEmpty(trimmed))
354 sb.Append(line).Append(
'\n');
358 bool isImage = trimmed.StartsWith(
"![");
360 if (isImage && trimmed == lastImage)
363 sb.Append(line).Append(
'\n');
364 lastImage = isImage ? trimmed :
null;
367 return sb.ToString();
377 private static string DedupeSections(
string markdown)
379 if (
string.IsNullOrEmpty(markdown))
382 var lines = markdown.Split(
'\n');
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"))
389 if (headingIdx.Count < 2)
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));
399 string Text((
int start,
int end) r)
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();
406 string Key(
string block)
408 string s = Regex.Replace(block,
@"!\[[^\]]*\]\([^)]*\)",
"");
409 s = Regex.Replace(s,
@"\s+",
" ");
410 return s.Trim().ToLowerInvariant();
413 var texts = ranges.Select(Text).ToList();
414 bool hasPreamble = headingIdx[0] > 0;
417 var bestForKey =
new Dictionary<string, int>();
418 for (
int i = 0; i < ranges.Count; i++)
420 if (i == 0 && hasPreamble)
continue;
421 string key = Key(texts[i]);
422 if (key.Length < 40)
continue;
423 if (
false == bestForKey.TryGetValue(key, out
int cur) || texts[i].Trim().Length > texts[cur].Trim().Length)
427 var dropped =
new HashSet<int>();
428 for (
int i = 0; i < ranges.Count; i++)
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)
437 if (0 == dropped.Count)
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]);
445 return outSb.ToString();
454 private static string DropEmptyHeadings(
string markdown)
456 if (
string.IsNullOrEmpty(markdown))
459 var lines = markdown.Split(
'\n');
460 var drop =
new bool[lines.Length];
462 for (
int i = 0; i < lines.Length; i++)
464 var m = Regex.Match(lines[i],
@"^\s*(#{1,6})\s");
465 if (
false == m.Success)
continue;
467 int level = m.Groups[1].Value.Length;
468 bool hasContent =
false;
470 for (
int j = i + 1; j < lines.Length; j++)
472 var hm = Regex.Match(lines[j],
@"^\s*(#{1,6})\s");
475 if (hm.Groups[1].Value.Length <= level)
break;
478 if (
false ==
string.IsNullOrWhiteSpace(lines[j])) { hasContent =
true;
break; }
481 if (
false == hasContent) drop[i] =
true;
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');
488 return sb.ToString();
491 private static void RemoveJunkNodes(HtmlDocument doc, List<string> junkSelectors)
493 foreach (var selector
in junkSelectors)
495 var nodes = doc.DocumentNode.SelectNodes(selector)?.ToList() ??
new List<HtmlNode>();
496 foreach (var node
in nodes)
498 try { node.Remove(); }
catch { }
503 foreach (var node
in doc.DocumentNode.DescendantsAndSelf().ToList())
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);
512 private static string ExtractSitemapAsMarkdown(
string html,
string baseUrl)
514 var sb =
new StringBuilder();
515 sb.AppendLine(
"# Sitemap Extract");
518 var doc =
new HtmlDocument();
522 var mainContainer = doc.DocumentNode.SelectSingleNode(
"//h1/ancestor::div") ?? doc.DocumentNode;
525 var titleNode = mainContainer.SelectSingleNode(
".//h1");
526 if (titleNode !=
null)
528 sb.AppendLine($
"## {titleNode.InnerText.Trim()}");
533 var introDiv = mainContainer.SelectSingleNode(
".//div[not(@class)]");
534 if (introDiv !=
null)
536 sb.AppendLine(introDiv.InnerText.Trim());
541 var updatedNode = mainContainer.SelectSingleNode(
".//h2[contains(text(), 'Last updated')]");
542 if (updatedNode !=
null)
544 sb.AppendLine($
"*{updatedNode.InnerText.Trim()}*");
549 var entries = mainContainer.SelectNodes(
".//p");
550 if (entries !=
null && entries.Count > 0)
552 foreach (var p
in entries)
554 var link = p.SelectSingleNode(
".//a");
557 string title = link.InnerText.Trim();
558 string url = link.GetAttributeValue(
"href",
"");
560 if (!
string.IsNullOrEmpty(url))
563 if (!url.StartsWith(
"http"))
566 sb.AppendLine($
"- [{title}]({url})");
570 sb.AppendLine($
"- {title}");
576 sb.AppendLine($
" *{p.InnerText.Trim()}*");
585 var links = mainContainer.SelectNodes(
".//a");
588 foreach (var link
in links)
590 string title = link.InnerText.Trim();
591 string url = link.GetAttributeValue(
"href",
"");
592 if (!
string.IsNullOrEmpty(url))
594 if (!url.StartsWith(
"http"))
597 sb.AppendLine($
"- [{title}]({url})");
603 string final = sb.ToString();
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();
613 private static string ExtractMainContent(HtmlDocument doc, List<string> mainContentCandidates)
616 var candidates = mainContentCandidates
617 .Select(selector => doc.DocumentNode.SelectSingleNode(selector))
618 .Where(n => n !=
null)
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);
627 if (candidates.Any())
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)
639 return best.InnerHtml;
644 var allTextNodes = doc.DocumentNode.SelectNodes(
"//text()")
645 ?.Select(n => n.InnerText.Trim())
646 ?.Where(t => !
string.IsNullOrWhiteSpace(t) && t.Length > 5)
647 ?.ToList() ??
new List<string>();
650 var uniqueText =
new HashSet<string>(allTextNodes);
651 var cleanedText =
string.Join(
"\n\n", uniqueText);
654 if (
string.IsNullOrWhiteSpace(cleanedText))
656 cleanedText = doc.ParsedText.Trim();
662 private static int ScoreContentDensity(HtmlNode node)
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;
674 private static string DeriveOrigin(
string url)
676 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
677 return $
"{uri.Scheme}://{uri.Authority}";
682 public static void SaveToFile(LLMDigestResult digest,
string filePath)
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}");
694 var header =
$@"# {digest.Title} [{digest.Url}]
695 **Extracted digest** — Original length: {digest.OriginalLength / 1024} KB -> Cleaned Markdown: ≈{digest.CleanedLength / 1024} KB
698 File.WriteAllText(filePath, header + digest.Markdown);
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.