-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Add What Happens Next, Winners & Losers, FAQ sections with Schema.org FAQPage and content depth validation #1661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
62ab7b8
34596bd
aa7739f
decc48c
bf2575f
e296115
597c489
d656075
537a15b
74aee8b
da1be81
b0d08af
472b4e7
22c249b
77027a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -62,6 +62,9 @@ const DEFAULT_THRESHOLDS: QualityThresholds = { | |||||||||||||||||||||||||||||||||||||||||
| recommendInternationalComparison: false, | ||||||||||||||||||||||||||||||||||||||||||
| recommendEconomicContext: true, | ||||||||||||||||||||||||||||||||||||||||||
| recommendSCBContext: true, | ||||||||||||||||||||||||||||||||||||||||||
| recommendWhatHappensNext: true, | ||||||||||||||||||||||||||||||||||||||||||
| recommendWinnersLosers: true, | ||||||||||||||||||||||||||||||||||||||||||
| minSpecificClaims: 3, | ||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -179,6 +182,174 @@ function hasWhyThisMatters(content: string): boolean { | |||||||||||||||||||||||||||||||||||||||||
| return patterns.some((pattern: RegExp) => pattern.test(content)); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Detect "What Happens Next" timeline section. | ||||||||||||||||||||||||||||||||||||||||||
| * Looks for the rendered section class or heading variants across supported languages. | ||||||||||||||||||||||||||||||||||||||||||
| * Covers: en, sv, da, no, fi, de, fr, es, nl, ar, he, ja, ko, zh. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * @param content - HTML content of article | ||||||||||||||||||||||||||||||||||||||||||
| * @returns True if the section is present | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| function hasWhatHappensNext(content: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||
| const patterns: readonly RegExp[] = [ | ||||||||||||||||||||||||||||||||||||||||||
| /class=["'][^"']*\bwhat-happens-next\b/, | ||||||||||||||||||||||||||||||||||||||||||
| /what\s+happens\s+next/i, | ||||||||||||||||||||||||||||||||||||||||||
| /vad\s+händer\s+härnäst/i, | ||||||||||||||||||||||||||||||||||||||||||
| /hvad\s+sker\s+der\s+nu/i, | ||||||||||||||||||||||||||||||||||||||||||
| /hva\s+skjer\s+videre/i, | ||||||||||||||||||||||||||||||||||||||||||
| /mitä\s+tapahtuu\s+seuraavaksi/i, | ||||||||||||||||||||||||||||||||||||||||||
| /was\s+passiert\s+als\s+nächstes/i, | ||||||||||||||||||||||||||||||||||||||||||
| /la\s+suite\s+des\s+événements/i, | ||||||||||||||||||||||||||||||||||||||||||
| /qué\s+sucede\s+a\s+continuación/i, | ||||||||||||||||||||||||||||||||||||||||||
| /wat\s+gebeurt\s+er\s+nu/i, | ||||||||||||||||||||||||||||||||||||||||||
| /ماذا يحدث بعد ذلك/, | ||||||||||||||||||||||||||||||||||||||||||
| /מה קורה בהמשך/, | ||||||||||||||||||||||||||||||||||||||||||
| /次のステップ/, | ||||||||||||||||||||||||||||||||||||||||||
| /다음\s+단계/, | ||||||||||||||||||||||||||||||||||||||||||
| /下一步/, | ||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||
| return patterns.some((p: RegExp) => p.test(content)); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Detect "Winners & Losers" analysis section. | ||||||||||||||||||||||||||||||||||||||||||
| * Looks for the rendered section class or heading variants across supported languages. | ||||||||||||||||||||||||||||||||||||||||||
| * Covers: en, sv, da, no, fi, de, fr, es, nl, ar, he, ja, ko, zh. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * @param content - HTML content of article | ||||||||||||||||||||||||||||||||||||||||||
| * @returns True if the section is present | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+185
to
+221
|
||||||||||||||||||||||||||||||||||||||||||
| function hasWinnersLosers(content: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||
| const patterns: readonly RegExp[] = [ | ||||||||||||||||||||||||||||||||||||||||||
| /class=["'][^"']*\bwinners-losers\b/, | ||||||||||||||||||||||||||||||||||||||||||
| /winners\s*(?:&|and)\s*losers/i, | ||||||||||||||||||||||||||||||||||||||||||
| /vinnare\s+och\s+förlorare/i, | ||||||||||||||||||||||||||||||||||||||||||
| /vindere\s+og\s+tabere/i, | ||||||||||||||||||||||||||||||||||||||||||
| /vinnere\s+og\s+tapere/i, | ||||||||||||||||||||||||||||||||||||||||||
| /voittajat\s+ja\s+häviäjät/i, | ||||||||||||||||||||||||||||||||||||||||||
| /gewinner\s+und\s+verlierer/i, | ||||||||||||||||||||||||||||||||||||||||||
| /gagnants\s+et\s+perdants/i, | ||||||||||||||||||||||||||||||||||||||||||
| /ganadores\s+y\s+perdedores/i, | ||||||||||||||||||||||||||||||||||||||||||
| /winnaars\s+en\s+verliezers/i, | ||||||||||||||||||||||||||||||||||||||||||
| /الرابحون والخاسرون/, | ||||||||||||||||||||||||||||||||||||||||||
| /מנצחים ומפסידים/, | ||||||||||||||||||||||||||||||||||||||||||
| /勝者と敗者/, | ||||||||||||||||||||||||||||||||||||||||||
| /승자와\s+패자/, | ||||||||||||||||||||||||||||||||||||||||||
| /赢家与输家/, | ||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||
| return patterns.some((p: RegExp) => p.test(content)); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Count approximate words in a specific HTML section identified by its CSS class. | ||||||||||||||||||||||||||||||||||||||||||
| * Returns 0 if the section is not found. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * @param content - Full HTML content of article | ||||||||||||||||||||||||||||||||||||||||||
| * @param sectionClass - CSS class of the target section element | ||||||||||||||||||||||||||||||||||||||||||
| * @returns Estimated word count within the section | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| function countSectionWords(content: string, sectionClass: string): number { | ||||||||||||||||||||||||||||||||||||||||||
| // Find the opening tag with the requested class, then scan forward to the | ||||||||||||||||||||||||||||||||||||||||||
| // matching closing tag while tracking nested <section>/<div> elements. | ||||||||||||||||||||||||||||||||||||||||||
| const escapedClass = sectionClass.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||||||||||||||||||||||||||||||||||||||||||
| const openingTagPattern = new RegExp( | ||||||||||||||||||||||||||||||||||||||||||
| `<(section|div)\\b[^>]*class=(?:"|')[^"']*\\b${escapedClass}\\b[^"']*(?:"|')[^>]*>`, | ||||||||||||||||||||||||||||||||||||||||||
| 'i', | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| const openingMatch = openingTagPattern.exec(content); | ||||||||||||||||||||||||||||||||||||||||||
| if (!openingMatch || openingMatch.index < 0) return 0; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const rootTag = openingMatch[1].toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||
| const contentStart = openingMatch.index + openingMatch[0].length; | ||||||||||||||||||||||||||||||||||||||||||
| const tagPattern = /<\/?(section|div)\b[^>]*>/gi; | ||||||||||||||||||||||||||||||||||||||||||
| tagPattern.lastIndex = contentStart; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const stack: string[] = [rootTag]; | ||||||||||||||||||||||||||||||||||||||||||
| let tagMatch: RegExpExecArray | null = tagPattern.exec(content); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| while (tagMatch) { | ||||||||||||||||||||||||||||||||||||||||||
| const matchedTag = tagMatch[0]; | ||||||||||||||||||||||||||||||||||||||||||
| const tagName = tagMatch[1].toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||
| const isClosingTag = matchedTag.startsWith('</'); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| if (!isClosingTag) { | ||||||||||||||||||||||||||||||||||||||||||
| stack.push(tagName); | ||||||||||||||||||||||||||||||||||||||||||
| } else if (stack.length > 0 && stack[stack.length - 1] === tagName) { | ||||||||||||||||||||||||||||||||||||||||||
| stack.pop(); | ||||||||||||||||||||||||||||||||||||||||||
| if (stack.length === 0) { | ||||||||||||||||||||||||||||||||||||||||||
| const innerHtml = content.slice(contentStart, tagMatch.index); | ||||||||||||||||||||||||||||||||||||||||||
| const text = innerHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); | ||||||||||||||||||||||||||||||||||||||||||
| return text.split(' ').filter((w: string) => w.length > 0).length; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| tagMatch = tagPattern.exec(content); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| return 0; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Validate that the lede paragraph has sufficient depth (minimum 30 words). | ||||||||||||||||||||||||||||||||||||||||||
| * A lede shorter than this is likely a stub or template placeholder. | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * @param content - HTML content of article | ||||||||||||||||||||||||||||||||||||||||||
| * @returns True if lede meets the minimum word count | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| function hasSubstantialLede(content: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||
| const ledeMatch = content.match(/<p[^>]*class=["'][^"']*\blede\b[^"']*["'][^>]*>([\s\S]*?)<\/p>/i); | ||||||||||||||||||||||||||||||||||||||||||
| if (!ledeMatch?.[1]) return false; | ||||||||||||||||||||||||||||||||||||||||||
| const text = ledeMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); | ||||||||||||||||||||||||||||||||||||||||||
| return text.split(' ').filter((w: string) => w.length > 0).length >= 30; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||
| * Count specific claim indicators in article text. | ||||||||||||||||||||||||||||||||||||||||||
| * Looks for patterns that indicate potentially verifiable, specific content: | ||||||||||||||||||||||||||||||||||||||||||
| * - Explicit document references (Prop., Bet., Mot., IP) | ||||||||||||||||||||||||||||||||||||||||||
| * - Percentage figures | ||||||||||||||||||||||||||||||||||||||||||
| * - Named MPs or ministers matching the pattern "Firstname Lastname (Party)" | ||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||
| * @param content - HTML content of article | ||||||||||||||||||||||||||||||||||||||||||
| * @returns Number of detected specific claim indicators | ||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||
| function countSpecificClaims(content: string): number { | ||||||||||||||||||||||||||||||||||||||||||
| const text = stripHtml(content); | ||||||||||||||||||||||||||||||||||||||||||
| let count = 0; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Count unique normalized document IDs rather than every occurrence to | ||||||||||||||||||||||||||||||||||||||||||
| // prevent repeated mentions of the same citation from inflating the score. | ||||||||||||||||||||||||||||||||||||||||||
| // Cap at 5, consistent with other claim signals below. | ||||||||||||||||||||||||||||||||||||||||||
| const uniqueDocumentReferences = new Set<string>(); | ||||||||||||||||||||||||||||||||||||||||||
| DOCUMENT_ID_PATTERNS.forEach((pattern: RegExp) => { | ||||||||||||||||||||||||||||||||||||||||||
| const flags = pattern.global ? pattern.flags : `${pattern.flags}g`; | ||||||||||||||||||||||||||||||||||||||||||
| const globalPattern = new RegExp(pattern.source, flags); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| for (const match of text.matchAll(globalPattern)) { | ||||||||||||||||||||||||||||||||||||||||||
| const documentReference = match[0]?.trim(); | ||||||||||||||||||||||||||||||||||||||||||
| if (documentReference) { | ||||||||||||||||||||||||||||||||||||||||||
| uniqueDocumentReferences.add(documentReference.replace(/\s+/g, ' ').toLowerCase()); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+324
to
+334
|
||||||||||||||||||||||||||||||||||||||||||
| DOCUMENT_ID_PATTERNS.forEach((pattern: RegExp) => { | |
| const matches = text.match(pattern); | |
| count += matches ? matches.length : 0; | |
| }); | |
| // Count unique normalized document IDs rather than every occurrence to | |
| // prevent repeated mentions of the same citation from inflating the score. | |
| // Cap at 5, consistent with other claim signals below. | |
| const uniqueDocumentReferences = new Set<string>(); | |
| DOCUMENT_ID_PATTERNS.forEach((pattern: RegExp) => { | |
| const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`; | |
| const globalPattern = new RegExp(pattern.source, flags); | |
| for (const match of text.matchAll(globalPattern)) { | |
| const documentReference = match[0]?.trim(); | |
| if (documentReference) { | |
| uniqueDocumentReferences.add(documentReference.replace(/\s+/g, ' ').toLowerCase()); | |
| } | |
| } | |
| }); | |
| count += Math.min(uniqueDocumentReferences.size, 5); |
Copilot
AI
Apr 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says “Percentage figures with surrounding context (e.g. "increased by 12%")”, but the implementation counts any percentage token via /\b\d+(?:\.\d+)?%/g (no context check). Either adjust the comment to match the current behavior, or implement the intended context requirement to avoid over-counting unrelated percentages (e.g. in tables).
| const percentMatches = text.match(/\b\d+(?:\.\d+)?%/g); | |
| const percentMatches = text.match( | |
| /\b(?:increased?|decreased?|rose|risen|fall(?:s|en)?|fell|dropped?|declined?|grew|growth|shrank|reduced?|up|down|change(?:d)?|gain(?:ed)?|loss(?:es)?|surge(?:d)?|jump(?:ed)?|improv(?:ed|ement)|worsen(?:ed|ing)?|inflation|unemployment|approval|support)\s+(?:by|of|to|at)?\s*\d+(?:\.\d+)?%/gi, | |
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New validation helpers (
hasWhatHappensNext,hasWinnersLosers,countSpecificClaims,hasSubstantialLede,countSectionWords) are introduced here, but there don’t appear to be corresponding unit tests (existing suites already test other article-quality-enhancer helpers). Adding focused tests for positive/negative matches would prevent regressions in these regex-based detectors.