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)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 // The GPAL section is taken from the built-in defaults for this run, so a workflow always runs
141 // the rules this build ships. A rule set the user has adopted into userRuleSets is never
142 // re-added, so their copy is the one used for that name.
143 var userNames = new HashSet<string>(
144 loaded.UserRuleSets.Select(r => r?.Name).Where(n => false == string.IsNullOrWhiteSpace(n)),
145 StringComparer.OrdinalIgnoreCase);
146
147 loaded.RuleSets = Default().RuleSets.Where(r => false == userNames.Contains(r.Name)).ToList();
148
149 // The file is written only when the GPAL version moves: that is what tells a reader the rules
150 // they are looking at changed, and it carries the user section forward untouched
151 if (false == string.Equals(loaded.Version, CurrentSchemaVersion, StringComparison.Ordinal))
152 {
153 loaded.Version = CurrentSchemaVersion;
154 TryWrite(loaded);
155 }
156
157 return loaded;
158 }
159
164 public static void Save(GPALFile file = null)
165 {
166 if (file != null) ConfigFilePath = file.Filename;
167 WriteConfigFile(Default(), ConfigFilePath);
168 }
169
170 private static void TryWrite(DigestRulesConfig cfg)
171 {
172 try
173 {
174 WriteConfigFile(cfg, ConfigFilePath);
175 }
176 catch (Exception ex)
177 {
178 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not write LLM digest rules file", null, GPALObjectType.None, ex);
179 }
180 }
181
187 private static void WriteConfigFile(DigestRulesConfig cfg, string path)
188 {
189 // NOTE: the converter chooses its format from the file extension, so the temp file MUST end in
190 // .yaml or it will serialize as CSV.
191 string tmp = path + ".gpaltmp.yaml";
192 GPAL.Converter.WithInput(cfg).SaveTo((GPALFile)tmp);
193
194 string yaml = File.ReadAllText(tmp);
195 try { File.Delete(tmp); } catch { /* best effort */ }
196
197 // Attach the section banners just above their YAML keys (banners are YAML comments, ignored on read).
198 yaml = Regex.Replace(yaml, @"(?m)^ruleSets:", GpalBanner + Environment.NewLine + "ruleSets:");
199 yaml = Regex.Replace(yaml, @"(?m)^userRuleSets:", Environment.NewLine + UserBanner + Environment.NewLine + "userRuleSets:");
200
201 string full = FileHeader + yaml;
202
203 if (false == File.Exists(path) || File.ReadAllText(path) != full)
204 File.WriteAllText(path, full);
205 }
206
213 public DigestRuleSet SelectFor(string url, string ruleSetName = null)
214 {
215 var user = UserRuleSets ?? new List<DigestRuleSet>();
216 var gpal = RuleSets ?? new List<DigestRuleSet>();
217 var all = user.Concat(gpal).ToList(); // user first, so an adopted rule set wins for its name
218
219 var generic = user.FirstOrDefault(r => "generic" == r.Name)
220 ?? gpal.FirstOrDefault(r => "generic" == r.Name)
221 ?? new DigestRuleSet();
222
223 DigestRuleSet selected = null;
224
225 if (false == string.IsNullOrWhiteSpace(ruleSetName))
226 selected = all.FirstOrDefault(r => string.Equals(r.Name, ruleSetName, StringComparison.OrdinalIgnoreCase));
227
228 if (null == selected && false == string.IsNullOrWhiteSpace(url))
229 selected = all.FirstOrDefault(r =>
230 "generic" != r.Name &&
231 null != r.DomainPatterns &&
232 r.DomainPatterns.Any(p => 0 <= url.IndexOf(p, StringComparison.OrdinalIgnoreCase)));
233
234 if (null == selected || selected == generic)
235 return generic;
236
237 return new DigestRuleSet
238 {
239 Name = selected.Name,
240 DomainPatterns = selected.DomainPatterns,
241 // A site names what is junk on that site, on top of what is junk everywhere: script, style,
242 // noscript, iframe and the cookie banners are generic's and every site gets them. Taking only
243 // the site's own list left those in the page for the twenty one sites that name any junk
244 JunkSelectors = generic.JunkSelectors.Concat(selected.JunkSelectors).Distinct().ToList(),
245
246 // the site's own candidates are tried first and generic's are the fallback behind them
247 MainContentCandidates = selected.MainContentCandidates.Concat(generic.MainContentCandidates).Distinct().ToList(),
248 SitemapBaseUrl = false == string.IsNullOrWhiteSpace(selected.SitemapBaseUrl) ? selected.SitemapBaseUrl : generic.SitemapBaseUrl,
249 EnabledPasses = 0 < selected.EnabledPasses.Count ? selected.EnabledPasses : generic.EnabledPasses
250 };
251 }
252
258 {
259 var generic = new DigestRuleSet
260 {
261 Name = "generic",
262 JunkSelectors = new List<string>
263 {
264 // Universal high-confidence junk - safe on almost all sites
265 "//script",
266 "//style",
267 "//noscript",
268 "//iframe",
269 "//*[contains(@class,'cookie') or contains(@class,'consent') or contains(@class,'gdpr') or contains(@id,'cookie')]",
270
271 // Navigation & layout boilerplate (protected from main content)
272 "//nav",
273 "//header[not(ancestor::main or contains(@class,'hero') or contains(@class,'main') or contains(@class,'content'))]",
274 "//footer",
275 "//aside",
276 "//*[contains(@role,'navigation') or contains(@role,'banner') and not(ancestor::main)]",
277
278 // Ads, popups, skip links (very safe)
279 // 'ad' is matched as a whole class, not as text inside one. As a substring it took out
280 // shadow, gradient, leading, padding, download and every other utility class holding those
281 // two letters, which on a site built from utility classes is the whole page
282 // popup, modal and skip are matched the same way and for the same reason: a consent sdk
283 // puts didomi-popup-open on the BODY while its banner is up, and contains(@class,'popup')
284 // matched that, so the page itself was removed as junk and the digest was the title alone
285 "//*[contains(concat(' ', normalize-space(@class), ' '), ' ad ') or contains(concat(' ', normalize-space(@class), ' '), ' ads ')" +
286 " or contains(concat(' ', normalize-space(@class), ' '), ' ad-') or contains(concat(' ', normalize-space(@class), ' '), ' ads-')" +
287 " or contains(@class,'advert')" +
288 " or contains(concat(' ', normalize-space(@class), ' '), ' popup ') or contains(concat(' ', normalize-space(@class), ' '), ' popup-') or contains(concat(' ', normalize-space(@class), ' '), '-popup ')" +
289 " or contains(concat(' ', normalize-space(@class), ' '), ' modal ') or contains(concat(' ', normalize-space(@class), ' '), ' modal-') or contains(concat(' ', normalize-space(@class), ' '), '-modal ')" +
290 " or contains(concat(' ', normalize-space(@class), ' '), ' skip ') or contains(concat(' ', normalize-space(@class), ' '), ' skip-') or contains(concat(' ', normalize-space(@class), ' '), '-skip ')" +
291 " or contains(concat(' ', normalize-space(@id), ' '), ' skip-') or contains(concat(' ', normalize-space(@id), ' '), '-skip ') or normalize-space(@id) = 'skip'" +
292 " or contains(text(),'Skip to content')]",
293
294 // Social/share widgets (common noise)
295 "//*[contains(@class,'social') or contains(@class,'share') or contains(@class,'follow')]",
296
297 // Media player chrome (video.js and similar): control bars, screen-reader control text,
298 // caption/track displays, loading spinners, modal dialogs - all pure UI noise in a text digest
299 "//*[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')]",
300
301 // Accessibility helpers & legal text (minimal)
302 "//*[contains(@class,'accessibility') or contains(@class,'legal') or contains(text(),'©') and count(.//a) > 10]",
303
304 // Sidebars and widget rails.
305 // widget is matched as a class on its own or at a word boundary, never as a substring:
306 // contains(@class,'widget') also matches Amazon's celwidget, which is the class on every
307 // content block of a product page, so that one selector removed the whole page. The forms
308 // here still match widget, widget-area, sidebar-widget and widget_text, which are what a
309 // widget rail is actually called. Same trap as the ad selector above
310 "//*[contains(@class, 'sidebar')" +
311 " or contains(concat(' ', normalize-space(@class), ' '), ' widget ')" +
312 " or contains(concat(' ', normalize-space(@class), ' '), ' widget-')" +
313 " or contains(concat(' ', normalize-space(@class), ' '), ' widget_')" +
314 " or contains(concat(' ', normalize-space(@class), ' '), '-widget ')" +
315 " or contains(concat(' ', normalize-space(@class), ' '), '_widget ')" +
316 " or contains(@class, 'social')" +
317 " or contains(concat(' ', normalize-space(@class), ' '), ' popup ') or contains(concat(' ', normalize-space(@class), ' '), ' popup-') or contains(concat(' ', normalize-space(@class), ' '), '-popup ')" +
318 " or contains(concat(' ', normalize-space(@role), ' '), ' navigation ')]",
319
320 // Pagination / next-prev links with very short text
321 "//a[string-length(normalize-space(.)) < 15 and (contains(.,'next') or contains(.,'prev') or contains(.,'›') or contains(.,'«'))]"
322 },
323 MainContentCandidates = new List<string>
324 {
325 "//main",
326 "//*[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')]",
327 "//body"
328 },
329 SitemapBaseUrl = "",
330 EnabledPasses = new List<string>
331 {
332 "CollapseExcessiveBlanks",
333 "RemoveCountersAndEmptyIcons",
334 "RemoveEmptyImages",
335 "StripMediaChrome",
336 "ImageGridBreaker",
337 "ImageSpacing",
338 "RejoinLinkedImages",
339 "DedupeConsecutiveImages",
340 "ProductImageBrandPrice",
341 "PriceGluing",
342 "HeadingSpacing",
343 "StripEscapeArtifacts",
344 "DedupeSections",
345 "DropEmptyHeadings",
346 "SplitConsecutiveLinks",
347 "ResolveRelativeLinks"
348 }
349 };
350
351 // Confidence: medium - Indeed uses dynamic class names; layout differs between search and detail pages
352 var indeed = new DigestRuleSet
353 {
354 Name = "indeed",
355 DomainPatterns = new List<string> { "indeed.com" },
356 JunkSelectors = new List<string>
357 {
358 "//div[contains(@class,'gnav')]",
359 "//div[contains(@class,'footer')]",
360 "//div[contains(@id,'popover')]",
361 "//div[contains(@class,'jobsearch-HiringInsights')]",
362 "//div[contains(@class,'jobsearch-CompanyInfoWithReview')]",
363 },
364 MainContentCandidates = new List<string>
365 {
366 "//div[@id='jobDescriptionText']",
367 "//div[contains(@class,'jobsearch-JobComponent')]",
368 "//div[contains(@class,'jobsearch-ResultsList')]"
369 },
370 SitemapBaseUrl = "https://www.indeed.com",
371 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
372 };
373
374 // Confidence: high - MediaWiki IDs and classes have been stable for many years
375 var wikipedia = new DigestRuleSet
376 {
377 Name = "wikipedia",
378 DomainPatterns = new List<string> { "wikipedia.org" },
379 JunkSelectors = new List<string>
380 {
381 "//div[@id='mw-navigation']",
382 "//div[@id='mw-head-base']",
383 "//div[@id='mw-page-base']",
384 "//div[@id='catlinks']",
385 "//div[@id='footer']",
386 "//*[contains(@class,'navbox')]",
387 "//*[contains(@class,'mw-editsection')]",
388 "//*[contains(@class,'reflist')]",
389 "//*[contains(@class,'sistersitebox')]",
390 "//*[contains(@class,'noprint')]"
391 },
392 MainContentCandidates = new List<string>
393 {
394 "//div[@id='mw-content-text']"
395 },
396 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
397 };
398
399 // Confidence: high - markdown-body and readme ID are longstanding; header/footer are semantic elements
400 var github = new DigestRuleSet
401 {
402 Name = "github",
403 DomainPatterns = new List<string> { "github.com" },
404 JunkSelectors = new List<string>
405 {
406 "//header",
407 "//footer",
408 "//*[contains(@class,'file-navigation')]",
409 "//*[contains(@class,'js-flash-container')]",
410 "//*[contains(@class,'signup-prompt')]",
411 "//*[contains(@class,'repository-topics')]",
412 "//*[contains(@class,'BorderGrid-cell') and contains(@class,'hide-sm')]"
413 },
414 MainContentCandidates = new List<string>
415 {
416 "//*[contains(@class,'markdown-body')]",
417 "//div[@id='readme']",
418 "//article"
419 },
420 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
421 };
422
423 // Confidence: high - #question, #content, #sidebar, s-topbar have been stable across SO redesigns
424 var stackoverflow = new DigestRuleSet
425 {
426 Name = "stackoverflow",
427 DomainPatterns = new List<string> { "stackoverflow.com", "stackexchange.com" },
428 JunkSelectors = new List<string>
429 {
430 "//div[@id='left-sidebar']",
431 "//div[@id='sidebar']",
432 "//*[contains(@class,'s-topbar')]",
433 "//*[contains(@class,'post-menu')]",
434 "//*[contains(@class,'s-sidebarwidget')]",
435 "//*[contains(@class,'js-consent-banner')]",
436 "//div[@id='feed-link']",
437 "//div[@id='hot-network-questions')]"
438 },
439 MainContentCandidates = new List<string>
440 {
441 "//div[@id='question']",
442 "//div[@id='content']"
443 },
444 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
445 };
446
447 // Confidence: high - #dp, #centerCol, #nav-belt, #navFooter are longstanding Amazon product page IDs
448 var amazon = new DigestRuleSet
449 {
450 Name = "amazon",
451 DomainPatterns = new List<string> { "amazon.com" },
452 JunkSelectors = new List<string>
453 {
454 "//div[@id='nav-belt']",
455 "//div[@id='navFooter']",
456 "//div[@id='nav-subnav']",
457 "//div[@id='HeroQuickPromo']",
458 "//div[@id='rhf']",
459 "//*[contains(@id,'carousel')]",
460 "//*[contains(@class,'a-carousel-row')]",
461 "//div[@id='sims-consolidated-1']",
462 "//div[@id='sims-consolidated-2']"
463 },
464 MainContentCandidates = new List<string>
465 {
466 "//div[@id='dp']",
467 "//div[@id='centerCol']"
468 }
469 };
470
471 // Confidence: high - HN's table-based HTML has barely changed since launch; bgcolor and class names are hardcoded
472 var hackernews = new DigestRuleSet
473 {
474 Name = "hackernews",
475 DomainPatterns = new List<string> { "news.ycombinator.com" },
476 JunkSelectors = new List<string>
477 {
478 "//td[@bgcolor='#ff6600']",
479 "//span[@class='yclinks']",
480 "//tr[@class='spacer']"
481 },
482 MainContentCandidates = new List<string>
483 {
484 "//table[@class='comment-tree']",
485 "//table[@class='fatitem']",
486 "//table[@class='itemlist']"
487 },
488 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
489 };
490
491 // Confidence: high - old Reddit's table-based layout with stable IDs has not changed; must precede reddit entry so its domain pattern wins
492 var redditOld = new DigestRuleSet
493 {
494 Name = "reddit-old",
495 DomainPatterns = new List<string> { "old.reddit.com" },
496 JunkSelectors = new List<string>
497 {
498 "//div[@id='header']",
499 "//div[@id='footer']",
500 "//div[@class='side']",
501 "//div[@id='sr-header-area']"
502 },
503 MainContentCandidates = new List<string>
504 {
505 "//div[@class='commentarea']",
506 "//div[@id='siteTable']",
507 "//div[@id='content']"
508 },
509 EnabledPasses = new List<string> { "CollapseExcessiveBlanks" }
510 };
511
512 // Confidence: medium - modern Reddit uses React; shreddit-post/shreddit-comment are custom elements Reddit introduced (~2023) and have held; semantic fallbacks cover the rest
513 var reddit = new DigestRuleSet
514 {
515 Name = "reddit",
516 DomainPatterns = new List<string> { "reddit.com" },
517 JunkSelectors = new List<string>
518 {
519 "//header",
520 "//aside",
521 "//footer"
522 },
523 MainContentCandidates = new List<string>
524 {
525 "//shreddit-post",
526 "//div[@id='main-content']",
527 "//main"
528 },
529 EnabledPasses = new List<string> { "CollapseExcessiveBlanks" }
530 };
531
532 // Confidence: high - article tag is the primary content container; pw- prefixed classes are Medium's current design system and have been stable
533 var medium = new DigestRuleSet
534 {
535 Name = "medium",
536 DomainPatterns = new List<string> { "medium.com" },
537 JunkSelectors = new List<string>
538 {
539 "//header",
540 "//footer",
541 "//nav",
542 "//*[contains(@class,'pw-responses')]",
543 "//*[contains(@class,'pw-post-tags')]"
544 },
545 MainContentCandidates = new List<string>
546 {
547 "//article"
548 },
549 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
550 };
551
552 // Confidence: medium-high - available-content and subscription-widget are known Substack classes; paywall div is well-documented
553 var substack = new DigestRuleSet
554 {
555 Name = "substack",
556 DomainPatterns = new List<string> { "substack.com" },
557 JunkSelectors = new List<string>
558 {
559 "//header",
560 "//footer",
561 "//*[contains(@class,'subscription-widget')]",
562 "//*[contains(@class,'paywall')]",
563 "//*[contains(@class,'post-upsell')]"
564 },
565 MainContentCandidates = new List<string>
566 {
567 "//div[contains(@class,'available-content')]",
568 "//article"
569 },
570 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
571 };
572
573 // Confidence: high - #glbfooter, #mainContent, #vi-desc, #gh-oa are longstanding eBay product page IDs
574 var ebay = new DigestRuleSet
575 {
576 Name = "ebay",
577 DomainPatterns = new List<string> { "ebay.com" },
578 JunkSelectors = new List<string>
579 {
580 "//div[@id='gh-oa']",
581 "//div[@id='glbfooter']",
582 "//div[@id='vi-recentlyViewed']",
583 "//*[contains(@id,'vi-recsalso')]"
584 },
585 MainContentCandidates = new List<string>
586 {
587 "//div[@id='mainContent']",
588 "//div[@id='vi-desc']"
589 }
590 };
591
592 // Confidence: medium - LinkedIn generates most class names; jobs-description and description section are known stable patterns for job pages
593 var linkedin = new DigestRuleSet
594 {
595 Name = "linkedin",
596 DomainPatterns = new List<string> { "linkedin.com" },
597 JunkSelectors = new List<string>
598 {
599 "//header",
600 "//footer",
601 "//aside",
602 "//*[contains(@class,'similar-jobs')]",
603 "//*[contains(@class,'jobs-premium-upsell')]"
604 },
605 MainContentCandidates = new List<string>
606 {
607 "//div[contains(@class,'jobs-description')]",
608 "//section[contains(@class,'description')]",
609 "//main"
610 },
611 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
612 };
613
614 // Confidence: medium - #JobDescriptionContainer is known; HardsellOverlay and LoginModal are observed class names but could change
615 var glassdoor = new DigestRuleSet
616 {
617 Name = "glassdoor",
618 DomainPatterns = new List<string> { "glassdoor.com" },
619 JunkSelectors = new List<string>
620 {
621 "//header",
622 "//footer",
623 "//*[contains(@class,'LoginModal') or contains(@id,'LoginModal')]",
624 "//*[contains(@class,'HardsellOverlay')]"
625 },
626 MainContentCandidates = new List<string>
627 {
628 "//div[@id='JobDescriptionContainer']",
629 "//div[@id='job-desc']",
630 "//main"
631 },
632 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
633 };
634
635 // Confidence: medium - job_description and job_desc are common ZipRecruiter patterns; semantic fallbacks cover misses
636 var ziprecruiter = new DigestRuleSet
637 {
638 Name = "ziprecruiter",
639 DomainPatterns = new List<string> { "ziprecruiter.com" },
640 JunkSelectors = new List<string>
641 {
642 "//header",
643 "//footer",
644 "//*[contains(@class,'suggested_jobs')]",
645 "//*[contains(@class,'email_signup')]"
646 },
647 MainContentCandidates = new List<string>
648 {
649 "//div[contains(@class,'job_description')]",
650 "//div[@id='job_desc']",
651 "//main"
652 },
653 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
654 };
655
656 // Confidence: medium - #maincontent is a known Walmart ID; RecommendedItems is an observed class; site has modernized and classes can shift
657 var walmart = new DigestRuleSet
658 {
659 Name = "walmart",
660 DomainPatterns = new List<string> { "walmart.com" },
661 JunkSelectors = new List<string>
662 {
663 "//header",
664 "//footer",
665 "//*[contains(@class,'RecommendedItems')]",
666 "//*[contains(@id,'recommendations')]"
667 },
668 MainContentCandidates = new List<string>
669 {
670 "//div[@id='maincontent']",
671 "//main"
672 }
673 };
674
675 // Confidence: medium - Reuters redesigns frequently; data-testid='article-body' is a known test attribute; article tag is the semantic fallback
676 var reuters = new DigestRuleSet
677 {
678 Name = "reuters",
679 DomainPatterns = new List<string> { "reuters.com" },
680 JunkSelectors = new List<string>
681 {
682 "//header",
683 "//footer",
684 "//*[contains(@class,'related-articles')]",
685 "//*[contains(@class,'newsletter-signup')]"
686 },
687 MainContentCandidates = new List<string>
688 {
689 "//*[@data-testid='article-body']",
690 "//article"
691 },
692 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
693 };
694
695 // Confidence: medium - ArticleBody and RelatedStories are known AP News class names; site is relatively stable
696 var apnews = new DigestRuleSet
697 {
698 Name = "apnews",
699 DomainPatterns = new List<string> { "apnews.com" },
700 JunkSelectors = new List<string>
701 {
702 "//header",
703 "//footer",
704 "//*[contains(@class,'RelatedStories')]",
705 "//*[contains(@class,'Advertisement')]"
706 },
707 MainContentCandidates = new List<string>
708 {
709 "//div[contains(@class,'ArticleBody')]",
710 "//article"
711 },
712 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
713 };
714
715 // Confidence: low-medium - Zillow is heavily React with generated classes; hdp prefix and zsg-modal are observed but fragile; main is the reliable fallback
716 var zillow = new DigestRuleSet
717 {
718 Name = "zillow",
719 DomainPatterns = new List<string> { "zillow.com" },
720 JunkSelectors = new List<string>
721 {
722 "//header",
723 "//footer",
724 "//*[contains(@class,'zsg-modal')]",
725 "//*[contains(@id,'contact-form')]"
726 },
727 MainContentCandidates = new List<string>
728 {
729 "//div[contains(@class,'hdp')]",
730 "//main"
731 },
732 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
733 };
734
735 // Confidence: medium - #content and home-main-stats are known Redfin patterns; more stable than Zillow but still React-heavy
736 var redfin = new DigestRuleSet
737 {
738 Name = "redfin",
739 DomainPatterns = new List<string> { "redfin.com" },
740 JunkSelectors = new List<string>
741 {
742 "//header",
743 "//footer",
744 "//*[contains(@class,'ContactAgent')]",
745 "//*[contains(@class,'similar-homes')]"
746 },
747 MainContentCandidates = new List<string>
748 {
749 "//div[@id='content']",
750 "//div[contains(@class,'home-main-stats')]",
751 "//main"
752 },
753 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
754 };
755
756 // Confidence: high - MDN uses semantic HTML throughout; article, nav, aside, footer are all accurate; document-toc-container and on-github are known class names
757 var mdn = new DigestRuleSet
758 {
759 Name = "mdn",
760 DomainPatterns = new List<string> { "developer.mozilla.org" },
761 JunkSelectors = new List<string>
762 {
763 "//header",
764 "//nav",
765 "//aside",
766 "//footer",
767 "//*[contains(@class,'document-toc-container')]",
768 "//*[contains(@class,'on-github')]"
769 },
770 MainContentCandidates = new List<string>
771 {
772 "//article",
773 "//main"
774 },
775 EnabledPasses = new List<string> { "CollapseExcessiveBlanks", "HeadingSpacing", "SplitConsecutiveLinks", "ResolveRelativeLinks" }
776 };
777
778 // Confidence: low-medium - Best Buy is React-heavy with generated class names. The sku-title / priceView
779 // and shop-product-* patterns are observed and reasonably stable; //main is the reliable fallback. Giving
780 // the converter a smaller, cleaner container also helps it avoid choking on the full-page markup.
781 var bestbuy = new DigestRuleSet
782 {
783 Name = "bestbuy",
784 DomainPatterns = new List<string> { "bestbuy.com" },
785 JunkSelectors = new List<string>
786 {
787 "//header",
788 "//footer",
789 "//nav",
790 "//*[contains(@class,'sponsored')]",
791 "//*[contains(@class,'carousel')]",
792 "//*[contains(@class,'recommendation')]",
793 "//*[contains(@class,'similar-products') or contains(@class,'related-products')]",
794 "//*[contains(@class,'customers-ultimately-bought')]"
795 },
796 MainContentCandidates = new List<string>
797 {
798 "//div[contains(@class,'shop-product-details')]",
799 "//div[@id='shop-content']",
800 "//div[contains(@class,'column-left')]",
801 "//main"
802 }
803 // EnabledPasses intentionally left empty so it inherits the full "generic" pass list.
804 };
805
806 return new DigestRulesConfig
807 {
809 UserRuleSets = new List<DigestRuleSet>(),
810 RuleSets = new List<DigestRuleSet>
811 {
812 generic, indeed, wikipedia, github, stackoverflow, amazon,
813 hackernews, redditOld, reddit, medium, substack,
814 ebay, linkedin, glassdoor, ziprecruiter, walmart,
815 reuters, apnews, zillow, redfin, mdn, bestbuy
816 }
817 };
818 }
819 }
820}
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
The GPAL version that last wrote the 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:571
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:2568