glippy-mcp 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/geo-checker.js +350 -56
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glippy-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "MCP server for GEO (Generative Engine Optimization) analysis — check any domain's AI-readiness",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -31,22 +31,105 @@ function looksBotBlocked(res) {
31
31
  const FETCH_TIMEOUT_MS = 15_000;
32
32
 
33
33
  /**
34
- * Known AI crawler user-agent tokens. These are checked against robots.txt
35
- * Disallow / Allow rules.
34
+ * Training-only crawlers. Blocking these is informational: it keeps content
35
+ * out of LLM training corpora but does not affect AI citation surfaces.
36
36
  */
37
- const AI_CRAWLERS = Object.freeze([
37
+ const TRAINING_CRAWLERS = Object.freeze([
38
38
  'GPTBot',
39
- 'Google-Extended',
40
- 'CCBot',
41
- 'anthropic-ai',
42
39
  'ClaudeBot',
40
+ 'anthropic-ai',
41
+ 'CCBot',
42
+ 'Google-Extended',
43
+ 'Applebot-Extended',
43
44
  'Bytespider',
44
- 'PerplexityBot',
45
- 'ChatGPT-User',
46
- 'AmazonBot',
45
+ 'FacebookBot',
46
+ 'Meta-ExternalAgent',
47
47
  'cohere-ai',
48
+ 'Diffbot',
49
+ 'Omgili',
50
+ 'Amazonbot',
51
+ 'Timpibot',
52
+ 'ImageSiftBot',
53
+ // Broadened: SEO/search/training crawlers commonly named in robots.txt.
54
+ 'PetalBot',
55
+ 'MJ12bot',
56
+ 'AwarioBot',
57
+ 'AhrefsBot',
58
+ 'SemrushBot',
59
+ 'DotBot',
60
+ 'SeznamBot',
61
+ 'magpie-crawler',
62
+ 'DataForSeoBot',
63
+ 'iaskbot',
64
+ 'Pangu_Bot',
65
+ 'claude-web',
66
+ 'cohere-training-data-crawler',
67
+ 'meta-externalfetcher',
68
+ ]);
69
+
70
+ /**
71
+ * Citation/retrieval crawlers. Blocking these directly hurts AI visibility
72
+ * because answer engines cannot fetch content for inline citation.
73
+ */
74
+ const CITATION_CRAWLERS = Object.freeze([
75
+ 'OAI-SearchBot',
76
+ 'ChatGPT-User',
77
+ 'PerplexityBot',
78
+ 'Perplexity-User',
79
+ 'Applebot',
80
+ 'Bingbot',
81
+ 'Googlebot',
82
+ 'DuckDuckBot',
83
+ 'YouBot',
84
+ // Broadened: alternative answer engines and search crawlers.
85
+ 'MistralAI-User',
86
+ 'PhindBot',
87
+ 'Komo',
88
+ 'AndiBot',
89
+ 'BraveBot',
90
+ 'KagiBot',
91
+ 'Yep',
92
+ 'NeevaBot',
93
+ 'Exabot',
94
+ 'Qwantify',
95
+ 'Seznam',
96
+ 'GoogleOther',
97
+ 'Google-CloudVertexBot',
98
+ 'BingPreview',
48
99
  ]);
49
100
 
101
+ /**
102
+ * Match a User-Agent token against a list of known crawlers using a
103
+ * case-insensitive longest-prefix match. This prevents short prefixes like
104
+ * "applebot" from incorrectly absorbing "applebot-extended" matches.
105
+ *
106
+ * @param {string} ua - User-Agent token from robots.txt or meta tag.
107
+ * @param {readonly string[]} crawlers - Crawler list to match against.
108
+ * @returns {string|null} - The matched crawler name (original casing) or null.
109
+ */
110
+ function matchCrawler(ua, crawlers) {
111
+ if (!ua) return null;
112
+ const lower = ua.toLowerCase();
113
+ let bestMatch = null;
114
+ let bestLen = 0;
115
+ for (const c of crawlers) {
116
+ const cl = c.toLowerCase();
117
+ if (lower === cl || lower.startsWith(cl) || lower.includes(cl)) {
118
+ if (cl.length > bestLen) {
119
+ bestLen = cl.length;
120
+ bestMatch = c;
121
+ }
122
+ }
123
+ }
124
+ return bestMatch;
125
+ }
126
+
127
+ /**
128
+ * Combined AI crawler list, kept for back-compat with downstream callers
129
+ * that iterate the union (e.g. robots.txt block detection per crawler).
130
+ */
131
+ const AI_CRAWLERS = Object.freeze([...TRAINING_CRAWLERS, ...CITATION_CRAWLERS]);
132
+
50
133
  /** Maximum number of redirects to follow when fetching a resource. */
51
134
  const MAX_REDIRECTS = 5;
52
135
 
@@ -759,7 +842,7 @@ function aggregatePageScores(pageResults) {
759
842
  function detectPageType($, schemaTypes, pathname) {
760
843
  // Check JSON-LD schema types first (most reliable signal).
761
844
  // A page can carry FAQPage schema for a small FAQ section while being a long-form
762
- // guide. Only classify as "faq" when FAQPage is the dominant structure - otherwise
845
+ // guide. Only classify as "faq" when FAQPage is the dominant structure, otherwise
763
846
  // a 6,400-word guide with a FAQ at the bottom gets penalized as exceeding FAQ length.
764
847
  const allH2s = $('h2');
765
848
  const h2Count = allH2s.length;
@@ -772,11 +855,52 @@ function detectPageType($, schemaTypes, pathname) {
772
855
  });
773
856
  const isDominantlyFaq = h2Count > 0 && questionH2Count >= h2Count * 0.7;
774
857
 
775
- if (schemaTypes.has('FAQPage') && isDominantlyFaq) return 'faq';
858
+ // Word count for length-based reclassification of FAQ-tagged guides.
859
+ const mainElForCount = $('main, article, [role="main"]');
860
+ const mainTextForCount = (mainElForCount.length > 0 ? mainElForCount.text() : $('body').text() || '').trim();
861
+ const wordCountForType = mainTextForCount.split(/\s+/).filter(w => w.length > 0).length;
862
+
863
+ // Non-FAQ schema types that, when coexisting with FAQPage, signal a hybrid
864
+ // guide rather than a pure FAQ page.
865
+ const NON_FAQ_GUIDE_TYPES = [
866
+ 'Article', 'NewsArticle', 'BlogPosting', 'TechArticle', 'HowTo', 'Product',
867
+ 'Dataset', 'Report', 'WebPage',
868
+ // Broadened: more schema types that imply guide/long-form rather than pure FAQ.
869
+ 'Recipe', 'ScholarlyArticle', 'Guide', 'Course', 'Service',
870
+ 'MedicalEntity', 'MedicalGuideline', 'Book', 'Chapter',
871
+ 'LearningResource', 'Review', 'CollectionPage', 'ItemPage',
872
+ ];
873
+ const hasNonFaqGuideType = NON_FAQ_GUIDE_TYPES.some((t) => schemaTypes.has(t));
874
+
875
+ // Heuristic guide-title overrides: title or H1 phrasing strongly implies a guide.
876
+ const titleText = ($('title').text() || '').trim();
877
+ const h1Text = ($('h1').first().text() || '').trim();
878
+ const titleAndH1 = `${titleText} ${h1Text}`;
879
+ const GUIDE_TITLE_RE = /\b(?:complete|ultimate|definitive|comprehensive)?\s*guide\b/i;
880
+ const EVERYTHING_RE = /everything you need/i;
881
+ const HOW_TO_TITLE_RE = /how to/i;
882
+ const STEP_BY_STEP_RE = /step[- ]by[- ]step/i;
883
+ const matchesGuideTitle = GUIDE_TITLE_RE.test(titleAndH1)
884
+ || EVERYTHING_RE.test(titleAndH1)
885
+ || HOW_TO_TITLE_RE.test(titleAndH1)
886
+ || STEP_BY_STEP_RE.test(titleAndH1);
887
+
888
+ // Definition-list + multiple H2 sections is a strong guide signal.
889
+ const hasDefinitionListGuide = $('dl').length > 0 && h2Count >= 2;
890
+
891
+ // Long-form / heading-rich pages should never classify as pure FAQ.
892
+ const tooLongForFaq = wordCountForType > 2000;
893
+ const tooManyH2sForFaq = h2Count > 8;
894
+
895
+ if (matchesGuideTitle || hasDefinitionListGuide) return 'article';
896
+ if (schemaTypes.has('FAQPage') && isDominantlyFaq && !hasNonFaqGuideType
897
+ && wordCountForType <= 1500 && !tooManyH2sForFaq && !tooLongForFaq) return 'faq';
776
898
  if (['Article', 'NewsArticle', 'BlogPosting', 'TechArticle'].some((t) => schemaTypes.has(t))) return 'article';
777
- // FAQPage schema present but page also has many topic-style H2s = guide with a FAQ section.
778
- if (schemaTypes.has('FAQPage') && h2Count >= 6) return 'article';
779
- if (schemaTypes.has('FAQPage')) return 'faq';
899
+ // FAQPage schema present but page is also long-form or carries another guide-type schema:
900
+ // treat as article so guide-style word/heading expectations apply.
901
+ if (schemaTypes.has('FAQPage') && (hasNonFaqGuideType || wordCountForType > 1500 || h2Count >= 6 || tooManyH2sForFaq || tooLongForFaq)) return 'article';
902
+ if (schemaTypes.has('FAQPage') && !tooManyH2sForFaq && !tooLongForFaq) return 'faq';
903
+ if (schemaTypes.has('FAQPage')) return 'article';
780
904
  if (['Product', 'Offer'].some((t) => schemaTypes.has(t))) return 'product';
781
905
  if (['LocalBusiness', 'Restaurant', 'Store'].some((t) => schemaTypes.has(t))) return 'local-business';
782
906
 
@@ -908,7 +1032,9 @@ function checkStructuredData($, pageType, jsonLdData, jsonLdValid, jsonLdInvalid
908
1032
  checks.push({ status: 'pass', label: `GEO-critical schema types present (${foundImportant.length})`, detail: foundImportant.join(', ') });
909
1033
  } else if (foundImportant.length > 0) {
910
1034
  score += 5;
911
- checks.push({ status: 'warn', label: `Only ${foundImportant.length} GEO-critical schema type(s)`, detail: `Found: ${foundImportant.join(', ')}. Consider adding: FAQPage, HowTo, Article, BreadcrumbList` });
1035
+ const suggestions = ['FAQPage', 'HowTo', 'Article', 'BreadcrumbList'].filter((t) => !schemaTypes.has(t));
1036
+ const consider = suggestions.length > 0 ? `. Consider adding: ${suggestions.join(', ')}` : '';
1037
+ checks.push({ status: 'warn', label: `Only ${foundImportant.length} GEO-critical schema type(s)`, detail: `Found: ${foundImportant.join(', ')}${consider}` });
912
1038
  } else {
913
1039
  checks.push({ status: 'fail', label: 'No GEO-critical schema types', detail: 'Add FAQPage, Article, Organization, BreadcrumbList, etc.' });
914
1040
  }
@@ -1853,38 +1979,66 @@ function checkMachineReadability($, robotsTxtData, llmsTxtData, responseHeaders)
1853
1979
  checks.push({ status: 'pass', label: 'No restrictive robots meta', detail: 'Page is open for indexing' });
1854
1980
  }
1855
1981
 
1856
- // Check for specific AI bot meta tags
1857
- const aiBotMeta = ['googlebot', 'bingbot', 'gptbot', 'chatgpt-user', 'anthropic-ai', 'claude-web', 'ccbot', 'google-extended', 'perplexitybot', 'claudebot'];
1858
- const blockedBots = [];
1982
+ // Check for specific AI bot meta tags. Split blocked bots into training-only
1983
+ // (informational) vs citation crawlers (real penalty) so a noindex on GPTBot
1984
+ // is not weighted the same as a noindex on Googlebot.
1985
+ const trainingBotMeta = TRAINING_CRAWLERS.map(c => c.toLowerCase());
1986
+ const citationBotMeta = CITATION_CRAWLERS.map(c => c.toLowerCase()).concat(['claude-web']);
1987
+ const aiBotMeta = [...new Set([...trainingBotMeta, ...citationBotMeta])];
1988
+ const blockedTrainingBots = [];
1989
+ const blockedCitationBots = [];
1859
1990
  aiBotMeta.forEach((bot) => {
1860
1991
  const content = $(`meta[name="${bot}"]`).attr('content') || '';
1861
1992
  if (content.includes('noindex')) {
1862
- blockedBots.push(bot);
1993
+ if (citationBotMeta.includes(bot)) {
1994
+ blockedCitationBots.push(bot);
1995
+ } else {
1996
+ blockedTrainingBots.push(bot);
1997
+ }
1863
1998
  }
1864
1999
  });
1865
2000
 
1866
2001
  maxScore += 15;
1867
- if (blockedBots.length === 0) {
2002
+ if (blockedCitationBots.length === 0 && blockedTrainingBots.length === 0) {
1868
2003
  score += 15;
1869
2004
  checks.push({ status: 'pass', label: 'No AI bot restrictions in meta', detail: 'No specific bot blocking detected in page HTML' });
2005
+ } else if (blockedCitationBots.length === 0) {
2006
+ score += 15;
2007
+ checks.push({ status: 'info', label: `Training crawler meta blocks: ${blockedTrainingBots.join(', ')}`, detail: 'Training-only blocks do not affect AI citation visibility', found: blockedTrainingBots });
1870
2008
  } else {
1871
- checks.push({ status: 'warn', label: `AI bot blocking detected: ${blockedBots.join(', ')}`, detail: 'These bots are blocked via meta tags', found: blockedBots });
2009
+ score += Math.max(0, 15 - blockedCitationBots.length * 3);
2010
+ checks.push({ status: 'warn', label: `Citation crawler meta blocks: ${blockedCitationBots.join(', ')}`, detail: 'These citation crawlers are blocked via meta tags', found: blockedCitationBots });
2011
+ if (blockedTrainingBots.length > 0) {
2012
+ checks.push({ status: 'info', label: `Training crawler meta blocks: ${blockedTrainingBots.join(', ')}`, detail: 'Training-only blocks are informational', found: blockedTrainingBots });
2013
+ }
1872
2014
  }
1873
2015
 
1874
2016
  // robots.txt integration (from server-side fetch)
1875
2017
  if (robotsTxtData) {
1876
2018
  maxScore += 10;
1877
2019
  if (robotsTxtData.exists) {
1878
- const crawlerBlocked = Object.entries(robotsTxtData.blocksCrawlers || {}).filter(([, v]) => v);
1879
- if (crawlerBlocked.length === 0) {
2020
+ const blocks = robotsTxtData.blocksCrawlers || {};
2021
+ const trainingLowercase = new Set(TRAINING_CRAWLERS.map(c => c.toLowerCase()));
2022
+ const citationLowercase = new Set(CITATION_CRAWLERS.map(c => c.toLowerCase()));
2023
+ const blockedAll = Object.entries(blocks).filter(([, v]) => v).map(([k]) => k);
2024
+ const blockedTraining = blockedAll.filter(k => trainingLowercase.has(k.toLowerCase()));
2025
+ const blockedCitation = blockedAll.filter(k => citationLowercase.has(k.toLowerCase()));
2026
+
2027
+ if (blockedCitation.length === 0 && blockedTraining.length === 0) {
2028
+ score += 10;
2029
+ checks.push({ status: 'pass', label: 'robots.txt: no AI crawlers blocked', detail: 'All known training and citation crawlers are allowed' });
2030
+ } else if (blockedCitation.length === 0) {
1880
2031
  score += 10;
1881
- checks.push({ status: 'pass', label: 'robots.txt: no AI crawlers blocked', detail: 'All known AI crawlers are allowed' });
2032
+ checks.push({ status: 'info', label: `robots.txt: ${blockedTraining.length} training crawler(s) blocked, citation crawlers allowed`, detail: 'Training-only blocks do not affect AI citation visibility', found: blockedTraining });
1882
2033
  } else {
1883
- score += Math.max(0, 10 - crawlerBlocked.length * 2);
1884
- checks.push({ status: 'warn', label: `robots.txt: ${crawlerBlocked.length} AI crawler(s) blocked`, detail: 'Blocked crawlers cannot index your content', found: crawlerBlocked.map(([k]) => k) });
2034
+ score += Math.max(0, 10 - blockedCitation.length * 2);
2035
+ checks.push({ status: 'warn', label: `robots.txt: ${blockedCitation.length} citation crawler(s) blocked`, detail: 'Blocking citation crawlers prevents inline AI citations', found: blockedCitation });
2036
+ if (blockedTraining.length > 0) {
2037
+ checks.push({ status: 'info', label: `robots.txt: ${blockedTraining.length} training crawler(s) blocked`, detail: 'Training-only blocks are informational and do not affect AI citation visibility', found: blockedTraining });
2038
+ }
1885
2039
  }
1886
2040
  if (robotsTxtData.hasWildcardDisallow) {
1887
- checks.push({ status: 'warn', label: 'robots.txt: wildcard Disallow: /', detail: 'All crawlers are blocked by default - only overridden by specific Allow rules' });
2041
+ checks.push({ status: 'warn', label: 'robots.txt: wildcard Disallow: /', detail: 'All crawlers are blocked by default, only overridden by specific Allow rules' });
1888
2042
  }
1889
2043
  } else {
1890
2044
  checks.push({ status: 'warn', label: 'No robots.txt found', detail: 'robots.txt helps control crawler access' });
@@ -4036,24 +4190,68 @@ function checkContentFreshness($, jsonLdData) {
4036
4190
  }
4037
4191
 
4038
4192
  // 12d. Copyright Year & Footer Freshness (10 pts)
4039
- // Year ranges ("© 1997 - 2026") signal a founding year + current year - take the END
4040
- // year as the freshness signal, not the founding year.
4193
+ // Year ranges ("(c) 1997 - 2026") signal a founding year + current year, take
4194
+ // the END year as the freshness signal, not the founding year.
4195
+ // Also handles enumerated lists like "(c) 2010, 2015, 2026" by taking the max
4196
+ // of all years in the same line as a copyright marker.
4041
4197
  const footerEl = $('footer');
4042
4198
  maxScore += 10;
4043
4199
  if (footerEl.length > 0) {
4044
- const footerText = footerEl.text();
4045
- const rangeMatch = footerText.match(/©\s*(\d{4})\s*(?:[‐-―−\-–—~]|to)\s*(\d{4})/);
4046
- const singleMatch = !rangeMatch ? footerText.match(/©\s*(\d{4})/) : null;
4047
- if (rangeMatch || singleMatch) {
4048
- const copyrightYear = parseInt((rangeMatch ? rangeMatch[2] : singleMatch[1]), 10);
4049
- if (copyrightYear === currentYear) {
4200
+ // Strip "All Rights Reserved" boilerplate (en/fr/de) before parsing.
4201
+ const rawFooterText = footerEl.text();
4202
+ const footerText = rawFooterText
4203
+ .replace(/all\s+rights\s+reserved/gi, '')
4204
+ .replace(/tous\s+droits\s+r[ée]serv[ée]s/gi, '')
4205
+ .replace(/alle\s+rechte\s+vorbehalten/gi, '');
4206
+ // Broader prefix list: includes bracket variants and "Copyright ©" double prefix.
4207
+ const COPYRIGHT_PREFIX = /(?:©|\(c\)|\(C\)|\[c\]|\[C\]|&copy;|copyright(?:\s*©)?)/i;
4208
+ // Exclude founding year markers so "Est. 1998" / "Since 2001" do not get
4209
+ // mistaken for a copyright year when no actual copyright marker is present.
4210
+ const FOUNDING_PREFIX = /\b(?:est(?:ablished|\.)?|since|founded(?:\s+in)?)\s+\d{4}\b/i;
4211
+ let copyrightYear = null;
4212
+ // Sweep each line for a copyright marker; take the max year found on that line.
4213
+ const lines = footerText.split(/\r?\n|<br\s*\/?>/i);
4214
+ for (const rawLine of lines) {
4215
+ const line = rawLine.trim();
4216
+ if (!line) continue;
4217
+ if (!COPYRIGHT_PREFIX.test(line)) continue;
4218
+ // Skip lines that look like founding-year statements without a real © marker.
4219
+ const hasRealMarker = /(?:©|\(c\)|\(C\)|\[c\]|\[C\]|&copy;|copyright)/i.test(line);
4220
+ if (!hasRealMarker && FOUNDING_PREFIX.test(line)) continue;
4221
+ const yearMatches = line.match(/\b(19|20)\d{2}\b/g);
4222
+ if (yearMatches && yearMatches.length > 0) {
4223
+ const maxYear = Math.max(...yearMatches.map(y => parseInt(y, 10)));
4224
+ if (copyrightYear === null || maxYear > copyrightYear) copyrightYear = maxYear;
4225
+ }
4226
+ }
4227
+ // Fallback: if the footer is a single blob without line breaks, sweep the
4228
+ // whole text but only when a copyright marker exists.
4229
+ if (copyrightYear === null && COPYRIGHT_PREFIX.test(footerText)) {
4230
+ const yearMatches = footerText.match(/\b(19|20)\d{2}\b/g);
4231
+ if (yearMatches && yearMatches.length > 0) {
4232
+ copyrightYear = Math.max(...yearMatches.map(y => parseInt(y, 10)));
4233
+ }
4234
+ }
4235
+ // Supplemental freshness signal: <time datetime="YYYY"> inside <footer>.
4236
+ if (copyrightYear === null) {
4237
+ footerEl.find('time[datetime]').each((_i, tEl) => {
4238
+ const dt = ($(tEl).attr('datetime') || '').trim();
4239
+ const ym = dt.match(/^(\d{4})/);
4240
+ if (ym) {
4241
+ const ty = parseInt(ym[1], 10);
4242
+ if (copyrightYear === null || ty > copyrightYear) copyrightYear = ty;
4243
+ }
4244
+ });
4245
+ }
4246
+ if (copyrightYear !== null) {
4247
+ if (copyrightYear >= currentYear - 1) {
4050
4248
  score += 10;
4051
4249
  checks.push({ status: 'pass', label: `Copyright year current (${copyrightYear})`, detail: `Footer copyright is ${copyrightYear}` });
4052
- } else if (copyrightYear === currentYear - 1) {
4250
+ } else if (copyrightYear === currentYear - 2) {
4053
4251
  score += 5;
4054
- checks.push({ status: 'warn', label: `Copyright year slightly old (${copyrightYear})`, detail: `Footer shows ${copyrightYear} update to ${currentYear}` });
4252
+ checks.push({ status: 'warn', label: `Copyright year slightly old (${copyrightYear})`, detail: `Footer shows ${copyrightYear}, update to ${currentYear}` });
4055
4253
  } else {
4056
- checks.push({ status: 'fail', label: `Copyright year outdated (${copyrightYear})`, detail: `Footer shows ${copyrightYear} update to ${currentYear}` });
4254
+ checks.push({ status: 'fail', label: `Copyright year outdated (${copyrightYear})`, detail: `Footer shows ${copyrightYear}, update to ${currentYear}` });
4057
4255
  }
4058
4256
  } else {
4059
4257
  checks.push({ status: 'info', label: 'No copyright year in footer', detail: 'Add a copyright year to signal maintenance' });
@@ -4292,6 +4490,18 @@ function checkVerifiability($, domain) {
4292
4490
  const contentText = (mainEl.length > 0 ? mainEl.text() : $('body').text() || '').trim();
4293
4491
  const sentences = contentText.split(/[.!?]+/).filter(s => s.trim().length > 10);
4294
4492
 
4493
+ // Visible body text (paragraphs, list items, blockquotes) for attribution
4494
+ // patterns that often span sentence boundaries or live in elements that
4495
+ // are tricky to split on punctuation alone.
4496
+ const bodyTextEls = mainEl.length > 0
4497
+ ? mainEl.find('p, li, blockquote, td, dd')
4498
+ : $('p, li, blockquote, td, dd');
4499
+ const bodyTextChunks = [];
4500
+ bodyTextEls.each((_i, el) => {
4501
+ const t = ($(el).text() || '').trim();
4502
+ if (t.length > 0) bodyTextChunks.push(t);
4503
+ });
4504
+
4295
4505
  // 14a. External Citation Links (30 pts)
4296
4506
  const AUTHORITY_DOMAINS = ['.gov', '.edu', '.org', 'scholar.google', 'pubmed', 'arxiv.org', 'doi.org'];
4297
4507
  const externalLinks = mainEl.length > 0 ? mainEl.find('a[href^="http"]') : $('a[href^="http"]');
@@ -4312,7 +4522,7 @@ function checkVerifiability($, domain) {
4312
4522
  checks.push({ status: 'pass', label: `Strong citations (${totalExternalLinks} external, ${authorityLinks} authority)`, detail: `${totalExternalLinks} external links including ${authorityLinks} authority sources` });
4313
4523
  } else if (totalExternalLinks >= 1) {
4314
4524
  score += 15;
4315
- checks.push({ status: 'warn', label: `Some citations (${totalExternalLinks} external)`, detail: `${totalExternalLinks} external links add authority sources (.gov, .edu)` });
4525
+ checks.push({ status: 'warn', label: `Some citations (${totalExternalLinks} external)`, detail: `${totalExternalLinks} external links, add authority sources (.gov, .edu)` });
4316
4526
  } else {
4317
4527
  score += 5;
4318
4528
  checks.push({ status: 'fail', label: 'No external citations', detail: 'Add external links to authoritative sources' });
@@ -4320,25 +4530,58 @@ function checkVerifiability($, domain) {
4320
4530
 
4321
4531
  // 14b. Source Attribution in Text (25 pts)
4322
4532
  const SOURCE_ATTRIBUTION_PATTERNS = [
4323
- /\baccording to\s+[A-Z]/,
4324
- /\ba\s+(study|report|survey|analysis)\s+by\b/i,
4325
- /\b(published in|cited in|reported by)\b/i,
4326
- /\b(source|data from|based on)\s*:/i,
4327
- /\b(research\s+from|findings\s+of)\b/i,
4533
+ /\baccording to\s+(?:the\s+|a\s+|an\s+)?[A-Z][\w'.-]*(?:\s+(?:of|for|on|and|the|de|van)\s+)?[A-Z\w'.-]*/,
4534
+ /\b(?:a|an|the|new|recent|latest|major|landmark)?\s*(?:study|report|survey|analysis|paper|whitepaper|brief)\s+(?:by|from|published by)\b/i,
4535
+ /\b(?:research|data|figures|statistics|findings)\s+(?:by|from|of|published by)\b/i,
4536
+ /\b(?:published in|cited in|reported by|noted by|observed by)\b/i,
4537
+ /\b(?:source|data from|based on)\s*:/i,
4538
+ /\b(?:report|study|analysis)\s+(?:by|from)\b/i,
4539
+ /\b[A-Z][\w'.-]+(?:\s+[A-Z][\w'.-]+){0,4}\s+(?:says|states|reports|found|concluded|notes|observed|estimates)\b/,
4328
4540
  /\[\d+\]/,
4329
- /\b(et al\.?|ibid\.?)\b/,
4541
+ /\b(?:et al\.?|ibid\.?)\b/,
4542
+ // Broadened patterns: "as reported by", "as documented in", etc.
4543
+ /\bas\s+(?:reported|noted|stated|cited|documented|shown|described|outlined)\s+(?:by|in|on)\b/i,
4544
+ // "per the WHO", "per CDC"
4545
+ /\bper\s+(?:the\s+)?[A-Z]/,
4546
+ // Possessive: "WHO's data", "CDC's findings"
4547
+ /\b[A-Z][A-Za-z.&'-]+(?:'s|’s)\s+(?:data|report|study|analysis|findings|guidance|recommendations|guidelines)\b/,
4548
+ // Parenthetical citation: "(source: ...)", "(via: ...)"
4549
+ /\((?:source|src|via|cf|see)\s*:\s*[^)]+\)/i,
4550
+ // DOI references
4551
+ /\bdoi:\s*10\.\d+/i,
4552
+ // Numeric brackets variants: "[1, 2]", "[1-3]"
4553
+ /\[\d+(?:[,-]\s*\d+)*\]/,
4554
+ // Author-year: "(Smith, 2023)", "(Smith et al., 2023)", "(Smith and Jones, 2023)"
4555
+ /\([A-Z][a-zA-Z]+(?:\s+(?:et\s+al\.?|and\s+[A-Z][a-zA-Z]+))?,\s*\d{4}[a-z]?\)/,
4556
+ // "<Org> data shows/reveals/indicates/suggests/confirms"
4557
+ /\b[A-Z][\w'.-]+(?:\s+[A-Z][\w'.-]+){0,3}\s+data\s+(?:shows|reveals|indicates|suggests|confirms)\b/,
4558
+ // "<Org> figures/findings show/reveal/indicate"
4559
+ /\b[A-Z][\w'.-]+(?:\s+[A-Z][\w'.-]+){0,3}\s+(?:figures|findings)\s+(?:show|reveal|indicate)\b/,
4560
+ // "in a recent study", "in a landmark report"
4561
+ /\bin\s+(?:a|an)\s+(?:recent|new|landmark|seminal)\s+(?:study|report|survey|paper|analysis)\b/i,
4562
+ // "verified by", "confirmed by", "documented in/by"
4563
+ /\b(?:verified|confirmed)\s+by\b/i,
4564
+ /\bdocumented\s+(?:in|by)\b/i,
4565
+ // Government/regulatory bodies: "Department of Health", "Centers for Disease Control"
4566
+ /\b(?:U\.?S\.?\s+)?(?:Department\s+of|Ministry\s+of|Office\s+of|Bureau\s+of|Centers\s+for|Federal|National|Royal)\s+[A-Z]/,
4330
4567
  ];
4331
4568
  let attrCount = 0;
4332
4569
  sentences.forEach(s => {
4333
4570
  if (SOURCE_ATTRIBUTION_PATTERNS.some(p => p.test(s))) attrCount++;
4334
4571
  });
4572
+ bodyTextChunks.forEach(t => {
4573
+ if (SOURCE_ATTRIBUTION_PATTERNS.some(p => p.test(t))) attrCount++;
4574
+ });
4335
4575
  maxScore += 25;
4336
4576
  if (attrCount >= 3) {
4337
4577
  score += 25;
4338
4578
  checks.push({ status: 'pass', label: `Strong source attribution (${attrCount})`, detail: `${attrCount} source attribution patterns detected` });
4579
+ } else if (attrCount >= 2) {
4580
+ score += 18;
4581
+ checks.push({ status: 'pass', label: `Source attribution found (${attrCount})`, detail: `${attrCount} attribution patterns detected` });
4339
4582
  } else if (attrCount >= 1) {
4340
- score += 12;
4341
- checks.push({ status: 'warn', label: `Some source attribution (${attrCount})`, detail: `${attrCount} attribution(s) add more source references` });
4583
+ score += 10;
4584
+ checks.push({ status: 'warn', label: `Some source attribution (${attrCount})`, detail: `${attrCount} attribution(s), add more source references` });
4342
4585
  } else {
4343
4586
  score += 5;
4344
4587
  checks.push({ status: 'info', label: 'No source attribution detected', detail: 'Add "according to", "study by", or citation markers' });
@@ -4577,6 +4820,48 @@ function checkMultimodal($, jsonLdData) {
4577
4820
  }
4578
4821
 
4579
4822
  // 16b. Figure/Figcaption Usage (25 pts)
4823
+ // Only evaluate coverage against content images. Decorative images (empty
4824
+ // alt, presentation role, callouts, headshots, seals, logos, icons, small
4825
+ // images, content nested in <aside>) are excluded from the denominator.
4826
+ const DECORATIVE_CLASS_HINTS = /(callout|note|highlight|decorative|icon|headshot|avatar|seal|logo|badge|sidebar|bullet|arrow|divider|separator|spacer|pixel|tracking|analytics|placeholder|flag|star|rating)/i;
4827
+ // Filename-style alt text like "img-23.jpg" / "photo.png" indicates a non-descriptive alt.
4828
+ const FILENAME_ALT_RE = /^(?:img|image|photo|picture)?[-_ ]?\d*\.(?:jpg|jpeg|png|gif|svg|webp)$/i;
4829
+ // Tracking pixel hints in src.
4830
+ const TRACKING_SRC_RE = /(?:pixel|beacon|track|analytics)/i;
4831
+ function isDecorativeImage(imgEl) {
4832
+ const $img = $(imgEl);
4833
+ const role = ($img.attr('role') || '').toLowerCase();
4834
+ if (role === 'presentation' || role === 'none') return true;
4835
+ // Explicit decorative attributes.
4836
+ const ariaHidden = ($img.attr('aria-hidden') || '').toLowerCase();
4837
+ if (ariaHidden === 'true') return true;
4838
+ const dataDecorative = ($img.attr('data-decorative') || '').toLowerCase();
4839
+ if (dataDecorative === 'true') return true;
4840
+ const alt = $img.attr('alt');
4841
+ if (alt !== undefined && alt.trim() === '') return true;
4842
+ // Filename-style alt text is non-descriptive and treated as decorative.
4843
+ if (alt !== undefined && FILENAME_ALT_RE.test(alt.trim())) return true;
4844
+ if ($img.closest('aside').length > 0) return true;
4845
+ // Broader ancestor selectors: chrome regions and ad/banner containers.
4846
+ if ($img.closest('header, nav, footer, button, [role="banner"], [role="navigation"], [role="contentinfo"], .ad, .advertisement, .banner').length > 0) return true;
4847
+ const cls = $img.attr('class') || '';
4848
+ if (DECORATIVE_CLASS_HINTS.test(cls)) return true;
4849
+ if ($img.closest(`[class*="callout"], [class*="note"], [class*="highlight"], [class*="decorative"], [class*="seal"], [class*="logo"], [class*="headshot"], [class*="avatar"], [class*="icon"]`).length > 0) return true;
4850
+ const w = parseInt($img.attr('width'), 10);
4851
+ const h = parseInt($img.attr('height'), 10);
4852
+ // Tracking pixel: 1x1 (or 1xN/Nx1) images.
4853
+ if ((Number.isFinite(w) && w === 1) || (Number.isFinite(h) && h === 1)) return true;
4854
+ if (Number.isFinite(w) && w > 0 && w <= 100) return true;
4855
+ if (Number.isFinite(h) && h > 0 && h <= 100) return true;
4856
+ const src = $img.attr('src') || '';
4857
+ if (src && TRACKING_SRC_RE.test(src)) return true;
4858
+ return false;
4859
+ }
4860
+ let contentImageCount = 0;
4861
+ fallbackImages.each((_i, imgEl) => {
4862
+ if (!isDecorativeImage(imgEl)) contentImageCount++;
4863
+ });
4864
+
4580
4865
  const mainFigures = $('main figure, article figure, [role="main"] figure');
4581
4866
  const fallbackFigures = mainFigures.length > 0 ? mainFigures : $('figure');
4582
4867
  let figuresWithCaption = 0;
@@ -4587,16 +4872,19 @@ function checkMultimodal($, jsonLdData) {
4587
4872
  if (fallbackImages.length === 0) {
4588
4873
  score += 25;
4589
4874
  checks.push({ status: 'info', label: 'No images for figure evaluation', detail: 'No images found on page' });
4875
+ } else if (contentImageCount === 0) {
4876
+ score += 25;
4877
+ checks.push({ status: 'info', label: 'Only decorative images detected', detail: 'No content images require figure/figcaption markup' });
4590
4878
  } else {
4591
- const figPct = fallbackImages.length > 0 ? Math.round((figuresWithCaption / fallbackImages.length) * 100) : 0;
4592
- if (figPct > 50) {
4879
+ const figPct = Math.round((figuresWithCaption / contentImageCount) * 100);
4880
+ if (figPct >= 50) {
4593
4881
  score += 25;
4594
- checks.push({ status: 'pass', label: `Good figure/caption usage (${figPct}%)`, detail: `${figPct}% of images wrapped in <figure> with <figcaption>` });
4882
+ checks.push({ status: 'pass', label: `Good figure/caption usage (${figPct}%)`, detail: `${figuresWithCaption} of ${contentImageCount} content images wrapped in <figure> with <figcaption>` });
4595
4883
  } else if (figuresWithCaption > 0) {
4596
4884
  score += 12;
4597
- checks.push({ status: 'warn', label: 'Some figure/caption usage', detail: 'Some images use <figure>/<figcaption> apply to more images' });
4885
+ checks.push({ status: 'warn', label: 'Some figure/caption usage', detail: `${figuresWithCaption} of ${contentImageCount} content images wrapped, extend to remaining content images` });
4598
4886
  } else {
4599
- checks.push({ status: 'info', label: 'No figure/caption usage', detail: 'Wrap images in <figure> with <figcaption> for better context' });
4887
+ checks.push({ status: 'info', label: 'No figure/caption usage', detail: 'Wrap content images in <figure> with <figcaption> for better context' });
4600
4888
  }
4601
4889
  }
4602
4890
 
@@ -5299,11 +5587,13 @@ function calculateGeoScore(data) {
5299
5587
  total += robotsScore;
5300
5588
  maxPossible += 5;
5301
5589
 
5302
- // 2. AI crawlers NOT blocked (1 pt per crawler, 7 max)
5590
+ // 2. AI crawlers NOT blocked. Only citation crawlers (real impact on AI
5591
+ // visibility) contribute to the score. Training-crawler blocks are reported
5592
+ // in the detail string for transparency but do not deduct points.
5303
5593
  let crawlerScore = 0;
5304
5594
  const blocked = data.robotsTxt.blocksCrawlers || {};
5305
5595
  const crawlerDetails = [];
5306
- for (const crawler of AI_CRAWLERS) {
5596
+ for (const crawler of CITATION_CRAWLERS) {
5307
5597
  if (blocked[crawler] === false || blocked[crawler] === undefined) {
5308
5598
  crawlerScore += 1;
5309
5599
  crawlerDetails.push(`${crawler}: allowed`);
@@ -5311,9 +5601,13 @@ function calculateGeoScore(data) {
5311
5601
  crawlerDetails.push(`${crawler}: BLOCKED`);
5312
5602
  }
5313
5603
  }
5314
- breakdown.aiCrawlerAccess = { score: crawlerScore, max: AI_CRAWLERS.length, detail: crawlerDetails.join('; ') };
5604
+ for (const crawler of TRAINING_CRAWLERS) {
5605
+ const status = (blocked[crawler] === false || blocked[crawler] === undefined) ? 'allowed' : 'blocked (training-only, informational)';
5606
+ crawlerDetails.push(`${crawler}: ${status}`);
5607
+ }
5608
+ breakdown.aiCrawlerAccess = { score: crawlerScore, max: CITATION_CRAWLERS.length, detail: crawlerDetails.join('; ') };
5315
5609
  total += crawlerScore;
5316
- maxPossible += AI_CRAWLERS.length;
5610
+ maxPossible += CITATION_CRAWLERS.length;
5317
5611
 
5318
5612
  // 3. llms.txt exists (10 pts)
5319
5613
  const llmsScore = data.llmsTxt.exists ? 10 : 0;