GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
DigestRules.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.RegularExpressions;
22using static GenerallyPositive.Enums;
23
24namespace GenerallyPositive
25{
32 public class DigestRuleSet
33 {
35 public string Name { get; set; } = "generic";
36
41 public List<string> DomainPatterns { get; set; } = new List<string>();
42
44 public List<string> JunkSelectors { get; set; } = new List<string>();
45
47 public List<string> MainContentCandidates { get; set; } = new List<string>();
48
50 public string SitemapBaseUrl { get; set; } = "";
51
53 public List<string> EnabledPasses { get; set; } = new List<string>();
54 }
55
60 public class DigestRulesConfig
61 {
62 private static string ConfigFilePath = "./LLMDigestRules.yaml";
63
69 public static string CurrentSchemaVersion =>
70 false == string.IsNullOrWhiteSpace(GPAL.Version)
71 ? GPAL.Version
72 : (System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0");
73
75 public string Version { get; set; } = null;
76
78 public List<DigestRuleSet> RuleSets { get; set; } = new List<DigestRuleSet>();
79
81 public List<DigestRuleSet> UserRuleSets { get; set; } = new List<DigestRuleSet>();
82
83 private const string GpalBanner =
84 "##### GPAL (managed by GPAL, refreshed from defaults on every run) #####";
85 private const string UserBanner =
86 "##### NEW (yours, GPAL never touches this section) #####";
87
88 private static readonly string FileHeader =
89 "# ==================================================================" + Environment.NewLine +
90 "# GPAL LLM Digest Rules" + Environment.NewLine +
91 "#" + Environment.NewLine +
92 "# ruleSets : GPAL owns these. They are regenerated from the built-in" + Environment.NewLine +
93 "# defaults whenever the GPAL version changes, so any edits" + Environment.NewLine +
94 "# made directly here are lost on the next version. Within a" + Environment.NewLine +
95 "# version this file is left as-is (it is the source of truth)." + Environment.NewLine +
96 "# userRuleSets : Yours. GPAL never changes anything in this section." + Environment.NewLine +
97 "# To take ownership of a GPAL rule set, move the whole entry" + Environment.NewLine +
98 "# out of ruleSets and into userRuleSets. GPAL will not re-add" + Environment.NewLine +
99 "# a rule set whose name it finds there, so your copy wins." + Environment.NewLine +
100 "# ==================================================================" + Environment.NewLine;
101
108 public static DigestRulesConfig Load(GPALFile file = null)
109 {
110 if (file != null) ConfigFilePath = file.Filename;
111
112 // No file yet: write a fresh, fully-populated versioned config and return it.
113 if (false == File.Exists(ConfigFilePath))
114 {
115 var fresh = Default();
116 TryWrite(fresh);
117 return fresh;
118 }
119
120 DigestRulesConfig loaded;
121 try
122 {
123 loaded = new DigestRulesConfig();
125 .WithInput((GPALFile)GPAL.File.WithFileName(ConfigFilePath))
126 .SaveTo(ref loaded);
127 }
128 catch (Exception ex)
129 {
130 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to load LLM digest rules from file. Using defaults.", null, GPALObjectType.None, ex);
131 return Default();
132 }
133
134 if (null == loaded)
135 return Default();
136
137 loaded.RuleSets = loaded.RuleSets ?? new List<DigestRuleSet>();
138 loaded.UserRuleSets = loaded.UserRuleSets ?? new List<DigestRuleSet>();
139
140 if (true == string.IsNullOrWhiteSpace(loaded.Version) && 0 == loaded.UserRuleSets.Count)
141 {
142 // Pre-versioning file with no user section: we cannot tell old defaults from hand edits, so
143 // back it up once and start clean in the new two-section format. The backup loses nothing.
144 BackupLegacyFile(ConfigFilePath);
145 loaded = Default();
146 TryWrite(loaded);
147 }
148 else if (false == string.Equals(loaded.Version, CurrentSchemaVersion, StringComparison.Ordinal))
149 {
150 // GPAL version changed: regenerate the GPAL section from the new defaults, but never re-add a
151 // rule set the user has adopted into userRuleSets. Preserve the user section, restamp the
152 // version, and persist.
153 var userNames = new HashSet<string>(
154 loaded.UserRuleSets.Select(r => r?.Name).Where(n => false == string.IsNullOrWhiteSpace(n)),
155 StringComparer.OrdinalIgnoreCase);
156
157 loaded.RuleSets = Default().RuleSets.Where(r => false == userNames.Contains(r.Name)).ToList();
158 loaded.Version = CurrentSchemaVersion;
159 TryWrite(loaded);
160 }
161 // else: same version. The file is the source of truth, so use it exactly as read and do not rewrite.
162
163 return loaded;
164 }
165
170 public static void Save(GPALFile file = null)
171 {
172 if (file != null) ConfigFilePath = file.Filename;
173 WriteConfigFile(Default(), ConfigFilePath);
174 }
175
176 private static void TryWrite(DigestRulesConfig cfg)
177 {
178 try
179 {
180 WriteConfigFile(cfg, ConfigFilePath);
181 }
182 catch (Exception ex)
183 {
184 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not write LLM digest rules file", null, GPALObjectType.None, ex);
185 }
186 }
187
193 private static void WriteConfigFile(DigestRulesConfig cfg, string path)
194 {
195 // NOTE: the converter chooses its format from the file extension, so the temp file MUST end in
196 // .yaml or it will serialize as CSV.
197 string tmp = path + ".gpaltmp.yaml";
198 GPAL.Converter.WithInput(cfg).SaveTo((GPALFile)GPAL.File.WithFileName(tmp));
199
200 string yaml = File.ReadAllText(tmp);
201 try { File.Delete(tmp); } catch { /* best effort */ }
202
203 // Attach the section banners just above their YAML keys (banners are YAML comments, ignored on read).
204 yaml = Regex.Replace(yaml, @"(?m)^ruleSets:", GpalBanner + Environment.NewLine + "ruleSets:");
205 yaml = Regex.Replace(yaml, @"(?m)^userRuleSets:", Environment.NewLine + UserBanner + Environment.NewLine + "userRuleSets:");
206
207 string full = FileHeader + yaml;
208
209 if (false == File.Exists(path) || File.ReadAllText(path) != full)
210 File.WriteAllText(path, full);
211 }
212
213 private static void BackupLegacyFile(string path)
214 {
215 try
216 {
217 string backup = path + ".v0.bak";
218 int n = 1;
219 while (File.Exists(backup))
220 backup = $"{path}.v0.{n++}.bak";
221
222 File.Copy(path, backup);
223 GPAL.PublishSimpleEvent(GPALEventType.INFO,
224 $"Migrated legacy LLM digest rules to versioned format. Old file backed up to [{backup}].",
225 null, GPALObjectType.None);
226 }
227 catch (Exception ex)
228 {
229 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
230 $"Could not back up legacy LLM digest rules file", null, GPALObjectType.None, ex);
231 }
232 }
233
240 public DigestRuleSet SelectFor(string url, string ruleSetName = null)
241 {
242 var user = UserRuleSets ?? new List<DigestRuleSet>();
243 var gpal = RuleSets ?? new List<DigestRuleSet>();
244 var all = user.Concat(gpal).ToList(); // user first, so an adopted rule set wins for its name
245
246 var generic = user.FirstOrDefault(r => "generic" == r.Name)
247 ?? gpal.FirstOrDefault(r => "generic" == r.Name)
248 ?? new DigestRuleSet();
249
250 DigestRuleSet selected = null;
251
252 if (false == string.IsNullOrWhiteSpace(ruleSetName))
253 selected = all.FirstOrDefault(r => string.Equals(r.Name, ruleSetName, StringComparison.OrdinalIgnoreCase));
254
255 if (null == selected && false == string.IsNullOrWhiteSpace(url))
256 selected = all.FirstOrDefault(r =>
257 "generic" != r.Name &&
258 null != r.DomainPatterns &&
259 r.DomainPatterns.Any(p => 0 <= url.IndexOf(p, StringComparison.OrdinalIgnoreCase)));
260
261 if (null == selected || selected == generic)
262 return generic;
263
264 return new DigestRuleSet
265 {
266 Name = selected.Name,
267 DomainPatterns = selected.DomainPatterns,
268 JunkSelectors = 0 < selected.JunkSelectors.Count ? selected.JunkSelectors : generic.JunkSelectors,
269 MainContentCandidates = 0 < selected.MainContentCandidates.Count ? selected.MainContentCandidates : generic.MainContentCandidates,
270 SitemapBaseUrl = false == string.IsNullOrWhiteSpace(selected.SitemapBaseUrl) ? selected.SitemapBaseUrl : generic.SitemapBaseUrl,
271 EnabledPasses = 0 < selected.EnabledPasses.Count ? selected.EnabledPasses : generic.EnabledPasses
272 };
273 }
274
280 {
281 var generic = new DigestRuleSet
282 {
283 Name = "generic",
284 JunkSelectors = new List<string>
285 {
286 // Universal high-confidence junk - safe on almost all sites
287 "//script",
288 "//style",
289 "//noscript",
290 "//iframe",
291 "//*[contains(@class,'cookie') or contains(@class,'consent') or contains(@class,'gdpr') or contains(@id,'cookie')]",
292
293 // Navigation & layout boilerplate (protected from main content)
294 "//nav",
295 "//header[not(ancestor::main or contains(@class,'hero') or contains(@class,'main') or contains(@class,'content'))]",
296 "//footer",
297 "//aside",
298 "//*[contains(@role,'navigation') or contains(@role,'banner') and not(ancestor::main)]",
299
300 // Ads, popups, skip links (very safe)
301 "//*[contains(@class,'ad') or contains(@class,'advert') or contains(@class,'popup') or contains(@class,'modal') or contains(@class,'skip') or contains(@id,'skip') or contains(text(),'Skip to content')]",
302
303 // Social/share widgets (common noise)
304 "//*[contains(@class,'social') or contains(@class,'share') or contains(@class,'follow')]",
305
306 // Media player chrome (video.js and similar): control bars, screen-reader control text,
307 // caption/track displays, loading spinners, modal dialogs - all pure UI noise in a text digest
308 "//*[contains(@class,'vjs-control-bar') or contains(@class,'vjs-control-text') or contains(@class,'vjs-text-track-display') or contains(@class,'vjs-title-bar') or contains(@class,'vjs-loading-spinner') or contains(@class,'vjs-modal-dialog') or contains(@class,'vjs-menu') or contains(@class,'vjs-playback-rate')]",
309
310 // Accessibility helpers & legal text (minimal)
311 "//*[contains(@class,'accessibility') or contains(@class,'legal') or contains(text(),'©') and count(.//a) > 10]",
312
313 // Sidebars and widget rails
314 "//*[contains(@class, 'sidebar') or contains(@class, 'widget') or contains(@class, 'social') or contains(@class, 'popup') or contains(@role, 'navigation')]",
315
316 // Pagination / next-prev links with very short text
317 "//a[string-length(normalize-space(.)) < 15 and (contains(.,'next') or contains(.,'prev') or contains(.,'›') or contains(.,'«'))]"
318 },
319 MainContentCandidates = new List<string>
320 {
321 "//main",
322 "//*[contains(@id, 'content') or contains(@class, 'content') or contains(@id, 'main') or contains(@class, 'main') or contains(@id, 'dp') or contains(@class, 'section') and contains(@class, 'spacing-none')]",
323 "//body"
324 },
325 SitemapBaseUrl = "",
326 EnabledPasses = new List<string>
327 {
328 "CollapseExcessiveBlanks",
329 "RemoveCountersAndEmptyIcons",
330 "RemoveEmptyImages",
331 "StripMediaChrome",
332 "ImageGridBreaker",
333 "ImageSpacing",
334 "RejoinLinkedImages",
335 "DedupeConsecutiveImages",
336 "ProductImageBrandPrice",
337 "PriceGluing",
338 "HeadingSpacing",
339 "StripEscapeArtifacts",
340 "DedupeSections",
341 "DropEmptyHeadings"
342 }
343 };
344
345 // Confidence: medium - Indeed uses dynamic class names; layout differs between search and detail pages
346 var indeed = new DigestRuleSet
347 {
348 Name = "indeed",
349 DomainPatterns = new List<string> { "indeed.com" },
350 JunkSelectors = new List<string>
351 {
352 "//div[contains(@class,'gnav')]",
353 "//div[contains(@class,'footer')]",
354 "//div[contains(@id,'popover')]",
355 "//div[contains(@class,'jobsearch-HiringInsights')]",
356 "//div[contains(@class,'jobsearch-CompanyInfoWithReview')]",
357 },
358 MainContentCandidates = new List<string>
359 {
360 "//div[@id='jobDescriptionText']",
361 "//div[contains(@class,'jobsearch-JobComponent')]",
362 "//div[contains(@class,'jobsearch-ResultsList')]"
363 },
364 SitemapBaseUrl = "https://www.indeed.com",
365 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
366 };
367
368 // Confidence: high - MediaWiki IDs and classes have been stable for many years
369 var wikipedia = new DigestRuleSet
370 {
371 Name = "wikipedia",
372 DomainPatterns = new List<string> { "wikipedia.org" },
373 JunkSelectors = new List<string>
374 {
375 "//div[@id='mw-navigation']",
376 "//div[@id='mw-head-base']",
377 "//div[@id='mw-page-base']",
378 "//div[@id='catlinks']",
379 "//div[@id='footer']",
380 "//*[contains(@class,'navbox')]",
381 "//*[contains(@class,'mw-editsection')]",
382 "//*[contains(@class,'reflist')]",
383 "//*[contains(@class,'sistersitebox')]",
384 "//*[contains(@class,'noprint')]"
385 },
386 MainContentCandidates = new List<string>
387 {
388 "//div[@id='mw-content-text']"
389 },
390 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
391 };
392
393 // Confidence: high - markdown-body and readme ID are longstanding; header/footer are semantic elements
394 var github = new DigestRuleSet
395 {
396 Name = "github",
397 DomainPatterns = new List<string> { "github.com" },
398 JunkSelectors = new List<string>
399 {
400 "//header",
401 "//footer",
402 "//*[contains(@class,'file-navigation')]",
403 "//*[contains(@class,'js-flash-container')]",
404 "//*[contains(@class,'signup-prompt')]",
405 "//*[contains(@class,'repository-topics')]",
406 "//*[contains(@class,'BorderGrid-cell') and contains(@class,'hide-sm')]"
407 },
408 MainContentCandidates = new List<string>
409 {
410 "//*[contains(@class,'markdown-body')]",
411 "//div[@id='readme']",
412 "//article"
413 },
414 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
415 };
416
417 // Confidence: high - #question, #content, #sidebar, s-topbar have been stable across SO redesigns
418 var stackoverflow = new DigestRuleSet
419 {
420 Name = "stackoverflow",
421 DomainPatterns = new List<string> { "stackoverflow.com", "stackexchange.com" },
422 JunkSelectors = new List<string>
423 {
424 "//div[@id='left-sidebar']",
425 "//div[@id='sidebar']",
426 "//*[contains(@class,'s-topbar')]",
427 "//*[contains(@class,'post-menu')]",
428 "//*[contains(@class,'s-sidebarwidget')]",
429 "//*[contains(@class,'js-consent-banner')]",
430 "//div[@id='feed-link']",
431 "//div[@id='hot-network-questions')]"
432 },
433 MainContentCandidates = new List<string>
434 {
435 "//div[@id='question']",
436 "//div[@id='content']"
437 },
438 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
439 };
440
441 // Confidence: high - #dp, #centerCol, #nav-belt, #navFooter are longstanding Amazon product page IDs
442 var amazon = new DigestRuleSet
443 {
444 Name = "amazon",
445 DomainPatterns = new List<string> { "amazon.com" },
446 JunkSelectors = new List<string>
447 {
448 "//div[@id='nav-belt']",
449 "//div[@id='navFooter']",
450 "//div[@id='nav-subnav']",
451 "//div[@id='HeroQuickPromo']",
452 "//div[@id='rhf']",
453 "//*[contains(@id,'carousel')]",
454 "//*[contains(@class,'a-carousel-row')]",
455 "//div[@id='sims-consolidated-1']",
456 "//div[@id='sims-consolidated-2']"
457 },
458 MainContentCandidates = new List<string>
459 {
460 "//div[@id='dp']",
461 "//div[@id='centerCol']"
462 }
463 };
464
465 // Confidence: high - HN's table-based HTML has barely changed since launch; bgcolor and class names are hardcoded
466 var hackernews = new DigestRuleSet
467 {
468 Name = "hackernews",
469 DomainPatterns = new List<string> { "news.ycombinator.com" },
470 JunkSelectors = new List<string>
471 {
472 "//td[@bgcolor='#ff6600']",
473 "//span[@class='yclinks']",
474 "//tr[@class='spacer']"
475 },
476 MainContentCandidates = new List<string>
477 {
478 "//table[@class='comment-tree']",
479 "//table[@class='fatitem']",
480 "//table[@class='itemlist']"
481 },
482 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
483 };
484
485 // Confidence: high - old Reddit's table-based layout with stable IDs has not changed; must precede reddit entry so its domain pattern wins
486 var redditOld = new DigestRuleSet
487 {
488 Name = "reddit-old",
489 DomainPatterns = new List<string> { "old.reddit.com" },
490 JunkSelectors = new List<string>
491 {
492 "//div[@id='header']",
493 "//div[@id='footer']",
494 "//div[@class='side']",
495 "//div[@id='sr-header-area']"
496 },
497 MainContentCandidates = new List<string>
498 {
499 "//div[@class='commentarea']",
500 "//div[@id='siteTable']",
501 "//div[@id='content']"
502 },
503 EnabledPasses = new List<string> { "CollapseExcessiveBlanks" }
504 };
505
506 // Confidence: medium - modern Reddit uses React; shreddit-post/shreddit-comment are custom elements Reddit introduced (~2023) and have held; semantic fallbacks cover the rest
507 var reddit = new DigestRuleSet
508 {
509 Name = "reddit",
510 DomainPatterns = new List<string> { "reddit.com" },
511 JunkSelectors = new List<string>
512 {
513 "//header",
514 "//aside",
515 "//footer"
516 },
517 MainContentCandidates = new List<string>
518 {
519 "//shreddit-post",
520 "//div[@id='main-content']",
521 "//main"
522 },
523 EnabledPasses = new List<string> { "CollapseExcessiveBlanks" }
524 };
525
526 // Confidence: high - article tag is the primary content container; pw- prefixed classes are Medium's current design system and have been stable
527 var medium = new DigestRuleSet
528 {
529 Name = "medium",
530 DomainPatterns = new List<string> { "medium.com" },
531 JunkSelectors = new List<string>
532 {
533 "//header",
534 "//footer",
535 "//nav",
536 "//*[contains(@class,'pw-responses')]",
537 "//*[contains(@class,'pw-post-tags')]"
538 },
539 MainContentCandidates = new List<string>
540 {
541 "//article"
542 },
543 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
544 };
545
546 // Confidence: medium-high - available-content and subscription-widget are known Substack classes; paywall div is well-documented
547 var substack = new DigestRuleSet
548 {
549 Name = "substack",
550 DomainPatterns = new List<string> { "substack.com" },
551 JunkSelectors = new List<string>
552 {
553 "//header",
554 "//footer",
555 "//*[contains(@class,'subscription-widget')]",
556 "//*[contains(@class,'paywall')]",
557 "//*[contains(@class,'post-upsell')]"
558 },
559 MainContentCandidates = new List<string>
560 {
561 "//div[contains(@class,'available-content')]",
562 "//article"
563 },
564 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
565 };
566
567 // Confidence: high - #glbfooter, #mainContent, #vi-desc, #gh-oa are longstanding eBay product page IDs
568 var ebay = new DigestRuleSet
569 {
570 Name = "ebay",
571 DomainPatterns = new List<string> { "ebay.com" },
572 JunkSelectors = new List<string>
573 {
574 "//div[@id='gh-oa']",
575 "//div[@id='glbfooter']",
576 "//div[@id='vi-recentlyViewed']",
577 "//*[contains(@id,'vi-recsalso')]"
578 },
579 MainContentCandidates = new List<string>
580 {
581 "//div[@id='mainContent']",
582 "//div[@id='vi-desc']"
583 }
584 };
585
586 // Confidence: medium - LinkedIn generates most class names; jobs-description and description section are known stable patterns for job pages
587 var linkedin = new DigestRuleSet
588 {
589 Name = "linkedin",
590 DomainPatterns = new List<string> { "linkedin.com" },
591 JunkSelectors = new List<string>
592 {
593 "//header",
594 "//footer",
595 "//aside",
596 "//*[contains(@class,'similar-jobs')]",
597 "//*[contains(@class,'jobs-premium-upsell')]"
598 },
599 MainContentCandidates = new List<string>
600 {
601 "//div[contains(@class,'jobs-description')]",
602 "//section[contains(@class,'description')]",
603 "//main"
604 },
605 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
606 };
607
608 // Confidence: medium - #JobDescriptionContainer is known; HardsellOverlay and LoginModal are observed class names but could change
609 var glassdoor = new DigestRuleSet
610 {
611 Name = "glassdoor",
612 DomainPatterns = new List<string> { "glassdoor.com" },
613 JunkSelectors = new List<string>
614 {
615 "//header",
616 "//footer",
617 "//*[contains(@class,'LoginModal') or contains(@id,'LoginModal')]",
618 "//*[contains(@class,'HardsellOverlay')]"
619 },
620 MainContentCandidates = new List<string>
621 {
622 "//div[@id='JobDescriptionContainer']",
623 "//div[@id='job-desc']",
624 "//main"
625 },
626 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
627 };
628
629 // Confidence: medium - job_description and job_desc are common ZipRecruiter patterns; semantic fallbacks cover misses
630 var ziprecruiter = new DigestRuleSet
631 {
632 Name = "ziprecruiter",
633 DomainPatterns = new List<string> { "ziprecruiter.com" },
634 JunkSelectors = new List<string>
635 {
636 "//header",
637 "//footer",
638 "//*[contains(@class,'suggested_jobs')]",
639 "//*[contains(@class,'email_signup')]"
640 },
641 MainContentCandidates = new List<string>
642 {
643 "//div[contains(@class,'job_description')]",
644 "//div[@id='job_desc']",
645 "//main"
646 },
647 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
648 };
649
650 // Confidence: medium - #maincontent is a known Walmart ID; RecommendedItems is an observed class; site has modernized and classes can shift
651 var walmart = new DigestRuleSet
652 {
653 Name = "walmart",
654 DomainPatterns = new List<string> { "walmart.com" },
655 JunkSelectors = new List<string>
656 {
657 "//header",
658 "//footer",
659 "//*[contains(@class,'RecommendedItems')]",
660 "//*[contains(@id,'recommendations')]"
661 },
662 MainContentCandidates = new List<string>
663 {
664 "//div[@id='maincontent']",
665 "//main"
666 }
667 };
668
669 // Confidence: medium - Reuters redesigns frequently; data-testid='article-body' is a known test attribute; article tag is the semantic fallback
670 var reuters = new DigestRuleSet
671 {
672 Name = "reuters",
673 DomainPatterns = new List<string> { "reuters.com" },
674 JunkSelectors = new List<string>
675 {
676 "//header",
677 "//footer",
678 "//*[contains(@class,'related-articles')]",
679 "//*[contains(@class,'newsletter-signup')]"
680 },
681 MainContentCandidates = new List<string>
682 {
683 "//*[@data-testid='article-body']",
684 "//article"
685 },
686 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
687 };
688
689 // Confidence: medium - ArticleBody and RelatedStories are known AP News class names; site is relatively stable
690 var apnews = new DigestRuleSet
691 {
692 Name = "apnews",
693 DomainPatterns = new List<string> { "apnews.com" },
694 JunkSelectors = new List<string>
695 {
696 "//header",
697 "//footer",
698 "//*[contains(@class,'RelatedStories')]",
699 "//*[contains(@class,'Advertisement')]"
700 },
701 MainContentCandidates = new List<string>
702 {
703 "//div[contains(@class,'ArticleBody')]",
704 "//article"
705 },
706 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
707 };
708
709 // Confidence: low-medium - Zillow is heavily React with generated classes; hdp prefix and zsg-modal are observed but fragile; main is the reliable fallback
710 var zillow = new DigestRuleSet
711 {
712 Name = "zillow",
713 DomainPatterns = new List<string> { "zillow.com" },
714 JunkSelectors = new List<string>
715 {
716 "//header",
717 "//footer",
718 "//*[contains(@class,'zsg-modal')]",
719 "//*[contains(@id,'contact-form')]"
720 },
721 MainContentCandidates = new List<string>
722 {
723 "//div[contains(@class,'hdp')]",
724 "//main"
725 },
726 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
727 };
728
729 // Confidence: medium - #content and home-main-stats are known Redfin patterns; more stable than Zillow but still React-heavy
730 var redfin = new DigestRuleSet
731 {
732 Name = "redfin",
733 DomainPatterns = new List<string> { "redfin.com" },
734 JunkSelectors = new List<string>
735 {
736 "//header",
737 "//footer",
738 "//*[contains(@class,'ContactAgent')]",
739 "//*[contains(@class,'similar-homes')]"
740 },
741 MainContentCandidates = new List<string>
742 {
743 "//div[@id='content']",
744 "//div[contains(@class,'home-main-stats')]",
745 "//main"
746 },
747 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
748 };
749
750 // Confidence: high - MDN uses semantic HTML throughout; article, nav, aside, footer are all accurate; document-toc-container and on-github are known class names
751 var mdn = new DigestRuleSet
752 {
753 Name = "mdn",
754 DomainPatterns = new List<string> { "developer.mozilla.org" },
755 JunkSelectors = new List<string>
756 {
757 "//header",
758 "//nav",
759 "//aside",
760 "//footer",
761 "//*[contains(@class,'document-toc-container')]",
762 "//*[contains(@class,'on-github')]"
763 },
764 MainContentCandidates = new List<string>
765 {
766 "//article",
767 "//main"
768 },
769 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing" }
770 };
771
772 // Confidence: low-medium - Best Buy is React-heavy with generated class names. The sku-title / priceView
773 // and shop-product-* patterns are observed and reasonably stable; //main is the reliable fallback. Giving
774 // the converter a smaller, cleaner container also helps it avoid choking on the full-page markup.
775 var bestbuy = new DigestRuleSet
776 {
777 Name = "bestbuy",
778 DomainPatterns = new List<string> { "bestbuy.com" },
779 JunkSelectors = new List<string>
780 {
781 "//header",
782 "//footer",
783 "//nav",
784 "//*[contains(@class,'sponsored')]",
785 "//*[contains(@class,'carousel')]",
786 "//*[contains(@class,'recommendation')]",
787 "//*[contains(@class,'similar-products') or contains(@class,'related-products')]",
788 "//*[contains(@class,'customers-ultimately-bought')]"
789 },
790 MainContentCandidates = new List<string>
791 {
792 "//div[contains(@class,'shop-product-details')]",
793 "//div[@id='shop-content']",
794 "//div[contains(@class,'column-left')]",
795 "//main"
796 }
797 // EnabledPasses intentionally left empty so it inherits the full "generic" pass list.
798 };
799
800 return new DigestRulesConfig
801 {
803 UserRuleSets = new List<DigestRuleSet>(),
804 RuleSets = new List<DigestRuleSet>
805 {
806 generic, indeed, wikipedia, github, stackoverflow, amazon,
807 hackernews, redditOld, reddit, medium, substack,
808 ebay, linkedin, glassdoor, ziprecruiter, walmart,
809 reuters, apnews, zillow, redfin, mdn, bestbuy
810 }
811 };
812 }
813 }
814}
One named set of LLM-digest cleanup rules: which nodes are junk, where the main content usually lives...
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....
List< string > DomainPatterns
Substrings matched (case-insensitive) against the current page URL to decide whether this rule set ap...
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...
Loads and saves the named DigestRuleSets used by HtmlToOptimizedMarkdown, stored as ....
static string CurrentSchemaVersion
Version stamped into the file, taken from the GPAL assembly version so the rules file records which b...
List< DigestRuleSet > RuleSets
GPAL-managed rule sets. Refreshed from Default on every load; edits here are not preserved.
DigestRuleSet SelectFor(string url, string ruleSetName=null)
Picks the rule set to use for url : an explicit ruleSetName wins if it exists, otherwise the first n...
static void Save(GPALFile file=null)
Writes a fresh config (GPAL defaults, empty user section) to the file as a starter for editing....
List< DigestRuleSet > UserRuleSets
User-owned rule sets. GPAL never modifies, removes, or re-adds these. Moving a GPAL rule set here "ad...
static DigestRulesConfig Default()
Built-in rule sets, used when ./LLMDigestRules.yaml does not exist. Call DigestRulesConfig....
static DigestRulesConfig Load(GPALFile file=null)
Loads the digest rules config, refreshing the GPAL-managed section from Default while preserving the ...
string Version
Version of the loaded/saved file. Null or empty means a pre-versioning (legacy) file.
GPAL File object instantied with GPAL.File Used to load tokens into a GPALGrid [rows/columns].
Definition GPALFile.cs:36
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static IAllowConverterInput Converter
New GPAL Convertor.
Definition GPAL.cs:560
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
static IAllowFileName File
Instantiates a new fluent File SETTINGS object. This data object defines settings for use by the ....
Definition GPAL.cs:508