glippy-mcp 0.2.0 → 0.3.1
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.
- package/package.json +1 -1
- package/src/geo-checker.js +363 -29
package/package.json
CHANGED
package/src/geo-checker.js
CHANGED
|
@@ -757,10 +757,27 @@ function aggregatePageScores(pageResults) {
|
|
|
757
757
|
* @returns {string} - One of: 'faq', 'product', 'article', 'local-business', 'homepage', 'ecommerce', 'saas', 'generic'.
|
|
758
758
|
*/
|
|
759
759
|
function detectPageType($, schemaTypes, pathname) {
|
|
760
|
-
// Check JSON-LD schema types first (most reliable signal)
|
|
761
|
-
|
|
762
|
-
|
|
760
|
+
// Check JSON-LD schema types first (most reliable signal).
|
|
761
|
+
// 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
|
|
763
|
+
// a 6,400-word guide with a FAQ at the bottom gets penalized as exceeding FAQ length.
|
|
764
|
+
const allH2s = $('h2');
|
|
765
|
+
const h2Count = allH2s.length;
|
|
766
|
+
let questionH2Count = 0;
|
|
767
|
+
allH2s.each((_, el) => {
|
|
768
|
+
const t = ($(el).text() || '').trim();
|
|
769
|
+
if (t.includes('?') || /^(how|what|why|when|where|who|which|can|do|does|is|are|should)\b/i.test(t)) {
|
|
770
|
+
questionH2Count++;
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
const isDominantlyFaq = h2Count > 0 && questionH2Count >= h2Count * 0.7;
|
|
774
|
+
|
|
775
|
+
if (schemaTypes.has('FAQPage') && isDominantlyFaq) return 'faq';
|
|
763
776
|
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';
|
|
780
|
+
if (['Product', 'Offer'].some((t) => schemaTypes.has(t))) return 'product';
|
|
764
781
|
if (['LocalBusiness', 'Restaurant', 'Store'].some((t) => schemaTypes.has(t))) return 'local-business';
|
|
765
782
|
|
|
766
783
|
// Heuristic: homepage detection (including language/locale-prefixed homepages like /en/, /de-DE/, /nl/)
|
|
@@ -769,9 +786,10 @@ function detectPageType($, schemaTypes, pathname) {
|
|
|
769
786
|
const normalizedPath = pathname.replace(/^\/[a-z]{2}(?:[-_][a-z]{2,3})?\/?$/i, '/');
|
|
770
787
|
if (normalizedPath === '/' || normalizedPath === '/index.html' || normalizedPath === '/index.php' || normalizedPath === '') return 'homepage';
|
|
771
788
|
|
|
772
|
-
// Heuristic: FAQ page via DOM
|
|
789
|
+
// Heuristic: FAQ page via DOM. Only treat as FAQ when FAQ-like elements dominate the
|
|
790
|
+
// structure - if the page has many topic H2s it's a guide that happens to include a FAQ.
|
|
773
791
|
const faqIndicators = $('[class*="faq"], [id*="faq"], details, [class*="accordion"]');
|
|
774
|
-
if (faqIndicators.length >= 3) return 'faq';
|
|
792
|
+
if (faqIndicators.length >= 3 && (h2Count < 6 || isDominantlyFaq)) return 'faq';
|
|
775
793
|
|
|
776
794
|
// Heuristic: article via DOM
|
|
777
795
|
const hasArticle = $('article').length > 0;
|
|
@@ -2014,7 +2032,29 @@ function checkEntity($, jsonLdData) {
|
|
|
2014
2032
|
});
|
|
2015
2033
|
}
|
|
2016
2034
|
|
|
2017
|
-
// 6. JSON-LD schema author with quality check
|
|
2035
|
+
// 6. JSON-LD schema author with quality check.
|
|
2036
|
+
// Only treat `author` as the page author when it's attached to a content type
|
|
2037
|
+
// (Article, WebPage, Book, etc.) - NOT inside Review/Comment, where `author` is
|
|
2038
|
+
// the reviewer/commenter and shouldn't be credited to the page.
|
|
2039
|
+
const PAGE_AUTHOR_TYPES = new Set([
|
|
2040
|
+
'Article', 'NewsArticle', 'BlogPosting', 'TechArticle', 'ScholarlyArticle', 'Report', 'OpinionNewsArticle',
|
|
2041
|
+
'WebPage', 'AboutPage', 'CollectionPage', 'ItemPage', 'ProfilePage', 'QAPage', 'FAQPage',
|
|
2042
|
+
'Book', 'Chapter', 'CreativeWork', 'CreativeWorkSeries', 'HowTo', 'Recipe', 'Course', 'LearningResource',
|
|
2043
|
+
'VideoObject', 'AudioObject', 'PodcastEpisode', 'Podcast',
|
|
2044
|
+
'DiscussionForumPosting', 'SocialMediaPosting',
|
|
2045
|
+
]);
|
|
2046
|
+
const SKIP_AUTHOR_TYPES = new Set(['Review', 'Comment', 'UserComments', 'Rating']);
|
|
2047
|
+
const isContentType = (t) => {
|
|
2048
|
+
if (!t) return false;
|
|
2049
|
+
const types = Array.isArray(t) ? t : [t];
|
|
2050
|
+
return types.some((x) => PAGE_AUTHOR_TYPES.has(x));
|
|
2051
|
+
};
|
|
2052
|
+
const isSkipType = (t) => {
|
|
2053
|
+
if (!t) return false;
|
|
2054
|
+
const types = Array.isArray(t) ? t : [t];
|
|
2055
|
+
return types.some((x) => SKIP_AUTHOR_TYPES.has(x));
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2018
2058
|
let hasAuthorSchema = false;
|
|
2019
2059
|
let hasAuthorSameAs = false;
|
|
2020
2060
|
let hasPersonSchema = false;
|
|
@@ -2022,12 +2062,14 @@ function checkEntity($, jsonLdData) {
|
|
|
2022
2062
|
try {
|
|
2023
2063
|
const processSchema = (schema) => {
|
|
2024
2064
|
if (!schema) return;
|
|
2025
|
-
|
|
2065
|
+
// Skip Review/Comment subtrees - their author is not the page author.
|
|
2066
|
+
if (isSkipType(schema['@type'])) return;
|
|
2067
|
+
if (schema.author && isContentType(schema['@type'])) {
|
|
2026
2068
|
hasAuthorSchema = true;
|
|
2027
2069
|
const authors = Array.isArray(schema.author) ? schema.author : [schema.author];
|
|
2028
2070
|
authors.forEach((a) => {
|
|
2029
2071
|
if (typeof a === 'string') authorNames.add(a);
|
|
2030
|
-
else if (a.name) {
|
|
2072
|
+
else if (a && a.name) {
|
|
2031
2073
|
authorNames.add(a.name);
|
|
2032
2074
|
if (a.sameAs) hasAuthorSameAs = true;
|
|
2033
2075
|
if (a['@type'] === 'Person') hasPersonSchema = true;
|
|
@@ -2038,6 +2080,13 @@ function checkEntity($, jsonLdData) {
|
|
|
2038
2080
|
hasPersonSchema = true;
|
|
2039
2081
|
if (schema.sameAs) hasAuthorSameAs = true;
|
|
2040
2082
|
}
|
|
2083
|
+
// Recurse into common content-bearing fields, but skip review arrays.
|
|
2084
|
+
['mainEntity', 'mainEntityOfPage', 'about', 'isPartOf', 'hasPart', 'workExample', 'exampleOfWork'].forEach((key) => {
|
|
2085
|
+
const val = schema[key];
|
|
2086
|
+
if (!val) return;
|
|
2087
|
+
if (Array.isArray(val)) val.forEach(processSchema);
|
|
2088
|
+
else if (typeof val === 'object') processSchema(val);
|
|
2089
|
+
});
|
|
2041
2090
|
};
|
|
2042
2091
|
if (Array.isArray(d)) d.forEach(processSchema);
|
|
2043
2092
|
else if (d['@graph']) d['@graph'].forEach(processSchema);
|
|
@@ -2047,14 +2096,17 @@ function checkEntity($, jsonLdData) {
|
|
|
2047
2096
|
if (hasAuthorSchema) authorSources.schema.push('JSON-LD author');
|
|
2048
2097
|
if (hasPersonSchema) authorSources.schema.push('Person schema');
|
|
2049
2098
|
|
|
2050
|
-
// 7. HTML byline elements - extended selectors
|
|
2099
|
+
// 7. HTML byline elements - extended selectors.
|
|
2100
|
+
// Exclude bylines inside review/comment/testimonial containers - they identify the
|
|
2101
|
+
// reviewer, not the page author.
|
|
2051
2102
|
const bylineSelectors = [
|
|
2052
2103
|
'[class*="author"]', '[rel="author"]', '[itemprop="author"]',
|
|
2053
2104
|
'.byline', '.post-author', '.article-author', '.entry-author',
|
|
2054
2105
|
'[data-author]', '[data-byline]',
|
|
2055
2106
|
'address.author', '.writer', '.contributor',
|
|
2056
2107
|
].join(', ');
|
|
2057
|
-
const
|
|
2108
|
+
const reviewContextSel = '[itemtype*="Review"], [itemtype*="Comment"], .review, .reviews, .comment, .comments, .testimonial, .testimonials, [class*="review-"], [class*="reviews-"]';
|
|
2109
|
+
const authorByline = $(bylineSelectors).filter((_, el) => $(el).closest(reviewContextSel).length === 0).first();
|
|
2058
2110
|
if (authorByline.length > 0) {
|
|
2059
2111
|
const bylineText = (authorByline.text() || '').trim();
|
|
2060
2112
|
if (bylineText && bylineText.length < 100) {
|
|
@@ -2070,8 +2122,9 @@ function checkEntity($, jsonLdData) {
|
|
|
2070
2122
|
authorSources.html.push('address element');
|
|
2071
2123
|
}
|
|
2072
2124
|
|
|
2073
|
-
// 9. Author profile links
|
|
2074
|
-
const authorLinks = $('a[href*="/author/"], a[href*="/writers/"], a[href*="/contributors/"], a[href*="/team/"], a[rel="author"]')
|
|
2125
|
+
// 9. Author profile links - skip review-context links (reviewer profile links).
|
|
2126
|
+
const authorLinks = $('a[href*="/author/"], a[href*="/writers/"], a[href*="/contributors/"], a[href*="/team/"], a[rel="author"]')
|
|
2127
|
+
.filter((_, el) => $(el).closest(reviewContextSel).length === 0);
|
|
2075
2128
|
if (authorLinks.length > 0) {
|
|
2076
2129
|
authorSources.links.push(`${authorLinks.length} author link(s)`);
|
|
2077
2130
|
authorLinks.each((_, el) => {
|
|
@@ -3006,6 +3059,10 @@ function checkPerformance($) {
|
|
|
3006
3059
|
// CHECK CATEGORY 10: Agent Interactivity (WebMCP + UCP)
|
|
3007
3060
|
// ---------------------------------------------------------------------------
|
|
3008
3061
|
|
|
3062
|
+
// LATEST_UCP_VERSION: gating threshold for 2026-04-08 spec additions
|
|
3063
|
+
// (signing_keys, order webhook_url, etc. become required at this version).
|
|
3064
|
+
const LATEST_UCP_VERSION = '2026-04-08';
|
|
3065
|
+
|
|
3009
3066
|
function checkWebMCP($, pageType, ucpData) {
|
|
3010
3067
|
const checks = [];
|
|
3011
3068
|
let score = 0;
|
|
@@ -3350,6 +3407,40 @@ function checkWebMCP($, pageType, ucpData) {
|
|
|
3350
3407
|
const capabilities = capsArray; // Already normalized above
|
|
3351
3408
|
const transportKeys = ['rest', 'mcp', 'a2a', 'embedded'];
|
|
3352
3409
|
|
|
3410
|
+
// UCP CHECK 2.5: Cache Headers (only when caller passed response headers)
|
|
3411
|
+
const ucpHeaders = ucpData && ucpData.headers ? ucpData.headers : null;
|
|
3412
|
+
if (ucpHeaders) {
|
|
3413
|
+
const headerLookup = (n) => {
|
|
3414
|
+
const lower = n.toLowerCase();
|
|
3415
|
+
for (const k of Object.keys(ucpHeaders)) {
|
|
3416
|
+
if (k.toLowerCase() === lower) return String(ucpHeaders[k] || '');
|
|
3417
|
+
}
|
|
3418
|
+
return '';
|
|
3419
|
+
};
|
|
3420
|
+
const ct = headerLookup('content-type').toLowerCase();
|
|
3421
|
+
const cc = headerLookup('cache-control').toLowerCase();
|
|
3422
|
+
const ctOk = ct.startsWith('application/json');
|
|
3423
|
+
const ccTokens = cc.split(',').map(s => s.trim());
|
|
3424
|
+
const hasPublic = ccTokens.includes('public');
|
|
3425
|
+
const hasBadDirective = ccTokens.some(t => t === 'private' || t === 'no-store' || t === 'no-cache');
|
|
3426
|
+
const maxAgeMatch = cc.match(/max-age=(\d+)/);
|
|
3427
|
+
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : -1;
|
|
3428
|
+
const ccOk = hasPublic && !hasBadDirective && maxAge >= 60;
|
|
3429
|
+
maxScore += 5;
|
|
3430
|
+
if (ctOk && ccOk) {
|
|
3431
|
+
score += 5;
|
|
3432
|
+
checks.push({ status: 'pass', label: 'UCP profile cache headers OK', detail: `Content-Type application/json with Cache-Control: public, max-age=${maxAge}` });
|
|
3433
|
+
} else {
|
|
3434
|
+
score += 2;
|
|
3435
|
+
const issues = [];
|
|
3436
|
+
if (!ctOk) issues.push(`content-type "${ct || 'missing'}" (expected application/json)`);
|
|
3437
|
+
if (!hasPublic) issues.push('cache-control missing "public"');
|
|
3438
|
+
if (hasBadDirective) issues.push('cache-control contains private/no-store/no-cache');
|
|
3439
|
+
if (maxAge < 60) issues.push(`max-age=${maxAge >= 0 ? maxAge : 'missing'} (expected >=60)`);
|
|
3440
|
+
checks.push({ status: 'warn', label: 'UCP profile cache headers need attention', detail: issues.slice(0, 3).join('; '), found: issues });
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3353
3444
|
// UCP CHECK 2: Profile Completeness
|
|
3354
3445
|
let completenessIssues = [];
|
|
3355
3446
|
if (!versionDatePattern.test(version)) completenessIssues.push('version not date-formatted (expected YYYY-MM-DD)');
|
|
@@ -3379,25 +3470,94 @@ function checkWebMCP($, pageType, ucpData) {
|
|
|
3379
3470
|
checks.push({ status: 'warn', label: `UCP profile has ${completenessIssues.length} issue(s)`, detail: completenessIssues.slice(0, 3).join('; '), found: completenessIssues.slice(0, 5) });
|
|
3380
3471
|
}
|
|
3381
3472
|
|
|
3382
|
-
// UCP CHECK 3: Capability Coverage
|
|
3473
|
+
// UCP CHECK 3: Capability Coverage (synced with extension processUCPProfile)
|
|
3383
3474
|
const capNames = capabilities.map(c => c.name || '');
|
|
3384
3475
|
const coreCapabilities = {
|
|
3385
3476
|
'dev.ucp.shopping.checkout': 'Checkout',
|
|
3386
3477
|
'dev.ucp.shopping.identity_linking': 'Identity Linking',
|
|
3387
3478
|
'dev.ucp.shopping.order': 'Order Management',
|
|
3479
|
+
'dev.ucp.shopping.cart': 'Cart',
|
|
3388
3480
|
};
|
|
3481
|
+
// Catalog has sub-capabilities; credit if any match the catalog prefix.
|
|
3482
|
+
const hasCatalog = capNames.some(n => n.startsWith('dev.ucp.shopping.catalog'));
|
|
3389
3483
|
const presentCore = Object.keys(coreCapabilities).filter(c => capNames.includes(c));
|
|
3390
|
-
|
|
3484
|
+
if (hasCatalog) presentCore.push('dev.ucp.shopping.catalog');
|
|
3485
|
+
const missingEntries = Object.entries(coreCapabilities).filter(([k]) => !capNames.includes(k));
|
|
3486
|
+
if (!hasCatalog) missingEntries.push(['dev.ucp.shopping.catalog', 'Catalog']);
|
|
3487
|
+
const missingCore = missingEntries.map(([, v]) => v);
|
|
3488
|
+
const totalCore = Object.keys(coreCapabilities).length + 1; // +1 for Catalog
|
|
3391
3489
|
|
|
3392
3490
|
maxScore += 10;
|
|
3393
|
-
if (presentCore.length ===
|
|
3491
|
+
if (presentCore.length === totalCore) {
|
|
3394
3492
|
score += 10;
|
|
3395
|
-
checks.push({ status: 'pass', label:
|
|
3493
|
+
checks.push({ status: 'pass', label: `All ${totalCore} core UCP capabilities declared`, detail: 'Checkout, Identity Linking, Order Management, Cart, and Catalog' });
|
|
3396
3494
|
} else if (presentCore.length > 0) {
|
|
3397
3495
|
score += 5;
|
|
3398
|
-
checks.push({ status: 'warn', label: `${presentCore.length}
|
|
3496
|
+
checks.push({ status: 'warn', label: `${presentCore.length}/${totalCore} core UCP capabilities declared`, detail: `Missing: ${missingCore.join(', ')}`, found: presentCore });
|
|
3399
3497
|
} else {
|
|
3400
|
-
checks.push({ status: 'info', label: 'No core UCP capabilities declared', detail: 'Consider adding checkout, identity_linking, and
|
|
3498
|
+
checks.push({ status: 'info', label: 'No core UCP capabilities declared', detail: 'Consider adding checkout, identity_linking, order, cart, and catalog capabilities' });
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
// 2026-04-08 spec gating: declared version >= 2026-04-08?
|
|
3502
|
+
const isV2 = versionDatePattern.test(version) && version >= LATEST_UCP_VERSION;
|
|
3503
|
+
|
|
3504
|
+
// UCP CHECK 3.5: Signing Keys (RFC 9421 ES256, mandatory in 2026-04-08)
|
|
3505
|
+
const signingKeys = Array.isArray(profile.signing_keys) ? profile.signing_keys : null;
|
|
3506
|
+
if (signingKeys && signingKeys.length > 0) {
|
|
3507
|
+
const malformed = signingKeys.filter(k => !k || !k.kid || k.kty !== 'EC' || k.crv !== 'P-256' || !k.x || !k.y);
|
|
3508
|
+
maxScore += 10;
|
|
3509
|
+
if (malformed.length === 0) {
|
|
3510
|
+
score += 10;
|
|
3511
|
+
checks.push({ status: 'pass', label: `${signingKeys.length} UCP signing key(s) declared`, detail: 'Profile advertises EC P-256 JWK(s) for RFC 9421 message signing' });
|
|
3512
|
+
} else {
|
|
3513
|
+
score += 3;
|
|
3514
|
+
checks.push({ status: 'warn', label: `${malformed.length}/${signingKeys.length} UCP signing key(s) malformed`, detail: 'Each key must have kid, kty=EC, crv=P-256, x, y', found: malformed.map(k => k && k.kid ? k.kid : '<missing kid>').slice(0, 5) });
|
|
3515
|
+
}
|
|
3516
|
+
} else if (isV2) {
|
|
3517
|
+
maxScore += 10;
|
|
3518
|
+
score += 3;
|
|
3519
|
+
checks.push({ status: 'warn', label: 'UCP signing keys missing', detail: 'UCP 2026-04-08 mandates RFC 9421 ES256 signatures; profile must publish signing_keys[]' });
|
|
3520
|
+
} else {
|
|
3521
|
+
checks.push({ status: 'info', label: 'UCP signing keys not declared', detail: 'UCP 2026-04-08 will require signing_keys[]; consider adding for forward compatibility' });
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
// UCP CHECK 3.6: Catalog Sub-Capability Coverage
|
|
3525
|
+
const catalogCaps = capabilities.filter(c => (c.name || '').startsWith('dev.ucp.shopping.catalog'));
|
|
3526
|
+
if (catalogCaps.length > 0) {
|
|
3527
|
+
const subs = ['search', 'lookup', 'get_product'];
|
|
3528
|
+
const presentSubs = subs.filter(s => catalogCaps.some(c => c.name === `dev.ucp.shopping.catalog.${s}` || c.name === 'dev.ucp.shopping.catalog'));
|
|
3529
|
+
const missingSubs = subs.filter(s => !presentSubs.includes(s));
|
|
3530
|
+
maxScore += 5;
|
|
3531
|
+
if (missingSubs.length === 0) {
|
|
3532
|
+
score += 5;
|
|
3533
|
+
checks.push({ status: 'pass', label: 'Catalog capability fully declared', detail: 'search, lookup, and get_product sub-capabilities all present' });
|
|
3534
|
+
} else {
|
|
3535
|
+
score += 3;
|
|
3536
|
+
checks.push({ status: 'warn', label: `Catalog declared with ${presentSubs.length}/${subs.length} sub-capabilities`, detail: `Missing: ${missingSubs.join(', ')}`, found: missingSubs });
|
|
3537
|
+
}
|
|
3538
|
+
} else if (capNames.includes('dev.ucp.shopping.cart')) {
|
|
3539
|
+
checks.push({ status: 'info', label: 'Cart declared without Catalog', detail: 'Consider adding catalog.search / catalog.lookup so agents can discover products before adding to cart' });
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
// UCP CHECK 3.7: Order Webhook URL (required in 2026-04-08)
|
|
3543
|
+
const orderCap = capabilities.find(c => c.name === 'dev.ucp.shopping.order');
|
|
3544
|
+
if (orderCap) {
|
|
3545
|
+
const webhookUrl = orderCap.config && orderCap.config.webhook_url;
|
|
3546
|
+
if (webhookUrl && typeof webhookUrl === 'string' && webhookUrl.startsWith('https://')) {
|
|
3547
|
+
maxScore += 5;
|
|
3548
|
+
score += 5;
|
|
3549
|
+
checks.push({ status: 'pass', label: 'Order webhook URL declared', detail: 'config.webhook_url is HTTPS, enabling real-time order updates' });
|
|
3550
|
+
} else if (webhookUrl) {
|
|
3551
|
+
maxScore += 5;
|
|
3552
|
+
score += 2;
|
|
3553
|
+
checks.push({ status: 'warn', label: 'Order webhook URL is not HTTPS', detail: 'config.webhook_url must use https://' });
|
|
3554
|
+
} else if (isV2) {
|
|
3555
|
+
maxScore += 5;
|
|
3556
|
+
score += 2;
|
|
3557
|
+
checks.push({ status: 'warn', label: 'Order webhook URL missing', detail: 'UCP 2026-04-08 requires config.webhook_url on the order capability for real-time updates' });
|
|
3558
|
+
} else {
|
|
3559
|
+
checks.push({ status: 'info', label: 'Order webhook URL not declared', detail: 'UCP 2026-04-08 will require config.webhook_url on order capabilities' });
|
|
3560
|
+
}
|
|
3401
3561
|
}
|
|
3402
3562
|
|
|
3403
3563
|
// UCP CHECK 4: Extension Support
|
|
@@ -3424,6 +3584,20 @@ function checkWebMCP($, pageType, ucpData) {
|
|
|
3424
3584
|
}
|
|
3425
3585
|
}
|
|
3426
3586
|
|
|
3587
|
+
// Cart-specific transport recommendation (2026-04-08 adds embedded binding).
|
|
3588
|
+
if (capNames.includes('dev.ucp.shopping.cart')) {
|
|
3589
|
+
const cartTransports = new Set(allTransports.map(t => t.transport));
|
|
3590
|
+
const cartRecommended = cartTransports.has('embedded') || cartTransports.has('mcp');
|
|
3591
|
+
maxScore += 3;
|
|
3592
|
+
if (cartRecommended) {
|
|
3593
|
+
score += 3;
|
|
3594
|
+
checks.push({ status: 'pass', label: 'Cart capability has embedded or MCP transport', detail: 'UCP 2026-04-08 recommends embedded or MCP transport for cart capability' });
|
|
3595
|
+
} else {
|
|
3596
|
+
score += 1;
|
|
3597
|
+
checks.push({ status: 'warn', label: 'Cart capability missing embedded/MCP transport', detail: 'UCP 2026-04-08 recommends adding an embedded transport binding for cart so agents can hand off to checkout' });
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3427
3601
|
if (allTransports.length > 1) {
|
|
3428
3602
|
maxScore += 10;
|
|
3429
3603
|
const httpsTransports = allTransports.filter(t => (t.endpoint || '').startsWith('https://') && (t.schema || '').startsWith('https://'));
|
|
@@ -3470,8 +3644,8 @@ function checkWebMCP($, pageType, ucpData) {
|
|
|
3470
3644
|
// UCP CHECK 8: Page-Type-Specific Recommendations
|
|
3471
3645
|
const commercePageTypes = ['product', 'ecommerce', 'saas', 'local-business'];
|
|
3472
3646
|
const ucpRecommendations = {
|
|
3473
|
-
'product': 'Should have checkout +
|
|
3474
|
-
'ecommerce': 'Should have checkout +
|
|
3647
|
+
'product': 'Should have checkout + cart + catalog + fulfillment capabilities',
|
|
3648
|
+
'ecommerce': 'Should have checkout + cart + catalog; consider identity linking for personalization',
|
|
3475
3649
|
'saas': 'Should have checkout for subscription/trial flows',
|
|
3476
3650
|
'local-business': 'Consider checkout for booking/purchasing services',
|
|
3477
3651
|
'homepage': 'UCP profile should be accessible at domain root /.well-known/ucp',
|
|
@@ -3480,6 +3654,51 @@ function checkWebMCP($, pageType, ucpData) {
|
|
|
3480
3654
|
if (ucpRecommendations[pageType]) {
|
|
3481
3655
|
checks.push({ status: 'pass', label: `UCP detected on ${pageType} page`, detail: ucpRecommendations[pageType] });
|
|
3482
3656
|
}
|
|
3657
|
+
|
|
3658
|
+
// UCP CHECK 9: Disclosure / Eligibility / Signals / Delegation feature advertisement (info-only).
|
|
3659
|
+
const advertisedFeatures = new Set();
|
|
3660
|
+
for (const cap of capabilities) {
|
|
3661
|
+
const feats = Array.isArray(cap.features) ? cap.features : [];
|
|
3662
|
+
for (const f of feats) {
|
|
3663
|
+
if (typeof f === 'string') advertisedFeatures.add(f);
|
|
3664
|
+
}
|
|
3665
|
+
}
|
|
3666
|
+
const trackedFeatures = ['eligibility_claims', 'signals', 'disclosure_messages', 'link_delegation'];
|
|
3667
|
+
const presentFeats = trackedFeatures.filter(f => advertisedFeatures.has(f));
|
|
3668
|
+
if (presentFeats.length > 0) {
|
|
3669
|
+
checks.push({ status: 'info', label: `${presentFeats.length} UCP feature(s) advertised`, detail: `Capabilities advertise: ${presentFeats.join(', ')}`, found: presentFeats });
|
|
3670
|
+
} else {
|
|
3671
|
+
checks.push({ status: 'info', label: 'No optional UCP features advertised', detail: 'eligibility_claims, signals, disclosure_messages, and link_delegation are optional but improve agent trust negotiation' });
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
// UCP CHECK 10: Spec Version Currency (info-only).
|
|
3675
|
+
if (versionDatePattern.test(version)) {
|
|
3676
|
+
if (version === LATEST_UCP_VERSION) {
|
|
3677
|
+
checks.push({ status: 'info', label: 'UCP version is current', detail: `Profile declares the latest known UCP version (${version})` });
|
|
3678
|
+
} else if (version < LATEST_UCP_VERSION) {
|
|
3679
|
+
checks.push({ status: 'info', label: 'UCP version is older than latest', detail: `Profile declares ${version}; latest known is ${LATEST_UCP_VERSION}` });
|
|
3680
|
+
} else {
|
|
3681
|
+
checks.push({ status: 'info', label: 'UCP version newer than checker knows', detail: `Profile declares ${version}; this checker is calibrated against ${LATEST_UCP_VERSION}` });
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
// UCP CHECK 11: A2A agent-card.json (only when profile advertises an a2a transport)
|
|
3686
|
+
const agentCard = ucpData && ucpData.agentCard;
|
|
3687
|
+
if (agentCard) {
|
|
3688
|
+
if (agentCard.exists && agentCard.valid) {
|
|
3689
|
+
maxScore += 3;
|
|
3690
|
+
score += 3;
|
|
3691
|
+
checks.push({ status: 'pass', label: 'A2A agent card found', detail: '/.well-known/agent-card.json is reachable and parses as JSON' });
|
|
3692
|
+
} else if (agentCard.exists && !agentCard.valid) {
|
|
3693
|
+
maxScore += 3;
|
|
3694
|
+
checks.push({ status: 'warn', label: 'A2A agent card not valid JSON', detail: '/.well-known/agent-card.json was reachable but did not parse as JSON' });
|
|
3695
|
+
} else if (agentCard.missing) {
|
|
3696
|
+
maxScore += 3;
|
|
3697
|
+
checks.push({ status: 'warn', label: 'A2A agent card not found', detail: 'Profile advertises an a2a transport but /.well-known/agent-card.json returns 404' });
|
|
3698
|
+
} else {
|
|
3699
|
+
checks.push({ status: 'info', label: 'A2A agent card unreachable', detail: agentCard.statusCode ? `HTTP ${agentCard.statusCode}` : 'fetch error' });
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3483
3702
|
}
|
|
3484
3703
|
} else {
|
|
3485
3704
|
// No UCP profile found — informational only, no penalty
|
|
@@ -3487,10 +3706,41 @@ function checkWebMCP($, pageType, ucpData) {
|
|
|
3487
3706
|
if (commercePageTypes.includes(pageType)) {
|
|
3488
3707
|
checks.push({ status: 'info', label: 'No UCP discovery file found', detail: 'UCP enables AI agents to discover commerce capabilities via /.well-known/ucp' });
|
|
3489
3708
|
} else {
|
|
3490
|
-
checks.push({ status: 'info', label: 'No UCP discovery file found', detail: 'UCP enables AI agents to discover commerce capabilities
|
|
3709
|
+
checks.push({ status: 'info', label: 'No UCP discovery file found', detail: 'UCP enables AI agents to discover commerce capabilities - most relevant for commerce pages' });
|
|
3491
3710
|
}
|
|
3492
3711
|
}
|
|
3493
3712
|
|
|
3713
|
+
// Shopify dual-surface info shortcut (fires whether or not UCP profile exists).
|
|
3714
|
+
if (ucpData && ucpData.shopifyHosted) {
|
|
3715
|
+
checks.push({ status: 'info', label: 'Shopify-hosted: dual UCP surface expected', detail: 'Per-shop endpoint at /api/ucp/mcp; global catalog at https://discover.shopifyapps.com/global/mcp' });
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
// Baseline credit for purely informational pages.
|
|
3719
|
+
// If the page has no forms, no WebMCP signals, no UCP profile, and no Shopify
|
|
3720
|
+
// surface, there's nothing for it to expose to agents - WebMCP/UCP are N/A here.
|
|
3721
|
+
// Without this, content-only pages are capped well below 100 even when there's
|
|
3722
|
+
// nothing to fix, dragging the overall score unfairly.
|
|
3723
|
+
const totalForms = $('form').length;
|
|
3724
|
+
const hasUcp = !!(ucpData && ucpData.exists && ucpData.content);
|
|
3725
|
+
const hasShopify = !!(ucpData && ucpData.shopifyHosted);
|
|
3726
|
+
const hasNoInteractiveSurface =
|
|
3727
|
+
totalForms === 0 &&
|
|
3728
|
+
toolCount === 0 &&
|
|
3729
|
+
!hasImperativeSignals &&
|
|
3730
|
+
!webmcpSDKFound &&
|
|
3731
|
+
!hasSchemaActions &&
|
|
3732
|
+
!hasUcp &&
|
|
3733
|
+
!hasShopify;
|
|
3734
|
+
|
|
3735
|
+
if (hasNoInteractiveSurface) {
|
|
3736
|
+
checks.push({
|
|
3737
|
+
status: 'info',
|
|
3738
|
+
label: 'Informational page — Agent Interactivity not applicable',
|
|
3739
|
+
detail: 'No forms or WebMCP/UCP signals detected. Pure-content pages can\'t expose tools to agents, so this category is scored as a baseline rather than penalized.',
|
|
3740
|
+
});
|
|
3741
|
+
return { checks, score: 80, category: 'Agent Interactivity', notApplicable: true };
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3494
3744
|
return { checks, score: maxScore > 0 ? Math.round((score / maxScore) * 100) : 0, category: 'Agent Interactivity' };
|
|
3495
3745
|
}
|
|
3496
3746
|
|
|
@@ -3758,8 +4008,18 @@ function checkContentFreshness($, jsonLdData) {
|
|
|
3758
4008
|
new RegExp('\\bin ' + currentYear + '\\b', 'i'),
|
|
3759
4009
|
new RegExp('\\b(as of|updated)\\s+(january|february|march|april|may|june|july|august|september|october|november|december)\\s+' + currentYear + '\\b', 'i'),
|
|
3760
4010
|
];
|
|
4011
|
+
// Historical/founding-context phrases - "records from 1841 to present", "since 1990",
|
|
4012
|
+
// "established 1936" - are accurate facts, not stale temporal references.
|
|
4013
|
+
const HISTORICAL_CONTEXT_PATTERNS = [
|
|
4014
|
+
/\b(since|from|established|founded|operating since|serving since|in business since)\s+(in\s+)?\d{4}\b/i,
|
|
4015
|
+
/\b\d{4}\s*(?:[‐-―−\-–—~]|to)\s*(present|current|today|now|\d{4})\b/i,
|
|
4016
|
+
/\b(records?|archives?|documents?|history|heritage|founded|established|originated|dating back)\b[^.]{0,80}\b(from|since|in)\s+\d{4}\b/i,
|
|
4017
|
+
/\b(historical|historic|vintage|legacy)\b/i,
|
|
4018
|
+
];
|
|
4019
|
+
const hasHistoricalContext = HISTORICAL_CONTEXT_PATTERNS.some(p => p.test(visibleText));
|
|
3761
4020
|
const hasCurrentRefs = CURRENT_YEAR_PATTERNS.some(p => p.test(visibleText));
|
|
3762
|
-
const
|
|
4021
|
+
const rawOutdatedHits = OUTDATED_TEMPORAL_PATTERNS.some(p => p.test(visibleText));
|
|
4022
|
+
const hasOutdatedRefs = rawOutdatedHits && !hasHistoricalContext;
|
|
3763
4023
|
maxScore += 20;
|
|
3764
4024
|
if (hasCurrentRefs && !hasOutdatedRefs) {
|
|
3765
4025
|
score += 20;
|
|
@@ -3776,13 +4036,16 @@ function checkContentFreshness($, jsonLdData) {
|
|
|
3776
4036
|
}
|
|
3777
4037
|
|
|
3778
4038
|
// 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.
|
|
3779
4041
|
const footerEl = $('footer');
|
|
3780
4042
|
maxScore += 10;
|
|
3781
4043
|
if (footerEl.length > 0) {
|
|
3782
4044
|
const footerText = footerEl.text();
|
|
3783
|
-
const
|
|
3784
|
-
|
|
3785
|
-
|
|
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);
|
|
3786
4049
|
if (copyrightYear === currentYear) {
|
|
3787
4050
|
score += 10;
|
|
3788
4051
|
checks.push({ status: 'pass', label: `Copyright year current (${copyrightYear})`, detail: `Footer copyright is ${copyrightYear}` });
|
|
@@ -3870,22 +4133,36 @@ function checkInformationDensity($) {
|
|
|
3870
4133
|
}
|
|
3871
4134
|
|
|
3872
4135
|
// 13b. Self-Contained Section Scoring (25 pts)
|
|
4136
|
+
// Sections with structured content (tables w/ headers, lists, definition lists) are
|
|
4137
|
+
// self-contained even at lower word counts - the structure carries the meaning.
|
|
3873
4138
|
const h2s = $('main h2, article h2, [role="main"] h2');
|
|
3874
4139
|
maxScore += 25;
|
|
3875
4140
|
if (h2s.length > 0) {
|
|
3876
4141
|
let selfContainedCount = 0;
|
|
3877
4142
|
h2s.each((_i, h2El) => {
|
|
3878
4143
|
let sectionText = '';
|
|
4144
|
+
let hasStructuredContent = false;
|
|
4145
|
+
let hasLabeledTable = false;
|
|
3879
4146
|
let sibling = $(h2El).next();
|
|
3880
4147
|
while (sibling.length > 0 && !sibling.is('h2')) {
|
|
3881
4148
|
sectionText += (sibling.text() || '') + ' ';
|
|
4149
|
+
if (sibling.is('table, ul, ol, dl') || sibling.find('table, ul, ol, dl').length > 0) {
|
|
4150
|
+
hasStructuredContent = true;
|
|
4151
|
+
}
|
|
4152
|
+
const tablesHere = sibling.is('table') ? sibling : sibling.find('table');
|
|
4153
|
+
tablesHere.each((__, t) => {
|
|
4154
|
+
if ($(t).find('th').length > 0) hasLabeledTable = true;
|
|
4155
|
+
});
|
|
3882
4156
|
sibling = sibling.next();
|
|
3883
4157
|
}
|
|
3884
4158
|
const wordCount = sectionText.trim().split(/\s+/).length;
|
|
3885
4159
|
const hasData = /\d/.test(sectionText);
|
|
3886
4160
|
const firstSentence = sectionText.split(/[.!?]/)[0] || '';
|
|
3887
4161
|
const hasTopicSentence = firstSentence.trim().length > 30;
|
|
3888
|
-
|
|
4162
|
+
const isStandardComplete = wordCount >= 150 && wordCount <= 500 && hasData && hasTopicSentence;
|
|
4163
|
+
const isStructurallyComplete = hasStructuredContent && wordCount >= 40 && (hasData || hasLabeledTable);
|
|
4164
|
+
const isLabeledTableSection = hasLabeledTable && wordCount >= 10;
|
|
4165
|
+
if (isStandardComplete || isStructurallyComplete || isLabeledTableSection) {
|
|
3889
4166
|
selfContainedCount++;
|
|
3890
4167
|
}
|
|
3891
4168
|
});
|
|
@@ -3905,6 +4182,8 @@ function checkInformationDensity($) {
|
|
|
3905
4182
|
}
|
|
3906
4183
|
|
|
3907
4184
|
// 13c. Claim-Evidence Pairing (20 pts)
|
|
4185
|
+
// Tables with header cells provide column-level context for every numeric value,
|
|
4186
|
+
// so data points inside labeled tables are considered already-paired by design.
|
|
3908
4187
|
const DATA_SENTENCE = /\d+(\.\d+)?(%|x|\$|€|£)/;
|
|
3909
4188
|
let dataSentences = 0;
|
|
3910
4189
|
let pairedData = 0;
|
|
@@ -3919,14 +4198,26 @@ function checkInformationDensity($) {
|
|
|
3919
4198
|
}
|
|
3920
4199
|
}
|
|
3921
4200
|
});
|
|
4201
|
+
// Count data cells inside labeled tables - they're context-paired via column headers.
|
|
4202
|
+
let labeledTableDataCells = 0;
|
|
4203
|
+
const pairingTables = mainEl.length > 0 ? mainEl.find('table') : $('table');
|
|
4204
|
+
pairingTables.each((_i, t) => {
|
|
4205
|
+
const $t = $(t);
|
|
4206
|
+
if ($t.find('th').length === 0) return;
|
|
4207
|
+
$t.find('tbody td, td').each((__, td) => {
|
|
4208
|
+
if (DATA_SENTENCE.test($(td).text() || '')) labeledTableDataCells++;
|
|
4209
|
+
});
|
|
4210
|
+
});
|
|
3922
4211
|
maxScore += 20;
|
|
3923
|
-
|
|
4212
|
+
const totalData = dataSentences + labeledTableDataCells;
|
|
4213
|
+
const totalPaired = pairedData + labeledTableDataCells;
|
|
4214
|
+
if (totalData === 0) {
|
|
3924
4215
|
checks.push({ status: 'info', label: 'No data claims detected', detail: 'Add quantitative data points with context' });
|
|
3925
4216
|
} else {
|
|
3926
|
-
const pairedPct = Math.round((
|
|
4217
|
+
const pairedPct = Math.round((totalPaired / totalData) * 100);
|
|
3927
4218
|
if (pairedPct > 80) {
|
|
3928
4219
|
score += 20;
|
|
3929
|
-
checks.push({ status: 'pass', label: `Claims well-paired (${pairedPct}%)`, detail: `${pairedPct}% of data claims have contextual explanations` });
|
|
4220
|
+
checks.push({ status: 'pass', label: `Claims well-paired (${pairedPct}%)`, detail: `${pairedPct}% of data claims have contextual explanations${labeledTableDataCells > 0 ? ` (incl. ${labeledTableDataCells} table cells)` : ''}` });
|
|
3930
4221
|
} else if (pairedPct >= 50) {
|
|
3931
4222
|
score += 10;
|
|
3932
4223
|
checks.push({ status: 'warn', label: `Claims partially paired (${pairedPct}%)`, detail: `${pairedPct}% of data claims have context — add more explanations` });
|
|
@@ -4766,11 +5057,54 @@ async function checkGEO(domain, options = {}) {
|
|
|
4766
5057
|
const profile = JSON.parse(ucpRes.body);
|
|
4767
5058
|
output.ucpProfile.exists = true;
|
|
4768
5059
|
output.ucpProfile.content = profile;
|
|
5060
|
+
output.ucpProfile.headers = ucpRes.headers || {};
|
|
4769
5061
|
}
|
|
4770
5062
|
} catch (err) {
|
|
4771
5063
|
output.ucpProfile.error = err.message;
|
|
4772
5064
|
}
|
|
4773
5065
|
|
|
5066
|
+
// --- /.well-known/agent-card.json (A2A discovery; only meaningful when profile advertises a2a) ---
|
|
5067
|
+
try {
|
|
5068
|
+
const services = output.ucpProfile.content && output.ucpProfile.content.ucp && output.ucpProfile.content.ucp.services;
|
|
5069
|
+
const advertisesA2a = services && Object.values(services).some(svc => svc && typeof svc === 'object' && svc.a2a);
|
|
5070
|
+
if (advertisesA2a) {
|
|
5071
|
+
const cardUrl = `${baseUrl}/.well-known/agent-card.json`;
|
|
5072
|
+
const cardRes = await throttledFetchUrl(cardUrl, FETCH_TIMEOUT_MS, MAX_TEXT_BODY_SIZE).catch(() => ({ body: null, statusCode: null }));
|
|
5073
|
+
if (cardRes.statusCode === 200 && cardRes.body) {
|
|
5074
|
+
try {
|
|
5075
|
+
JSON.parse(cardRes.body);
|
|
5076
|
+
output.ucpProfile.agentCard = { url: cardUrl, exists: true, valid: true };
|
|
5077
|
+
} catch {
|
|
5078
|
+
output.ucpProfile.agentCard = { url: cardUrl, exists: true, valid: false };
|
|
5079
|
+
}
|
|
5080
|
+
} else if (cardRes.statusCode === 404) {
|
|
5081
|
+
output.ucpProfile.agentCard = { url: cardUrl, exists: false, missing: true };
|
|
5082
|
+
} else {
|
|
5083
|
+
output.ucpProfile.agentCard = { url: cardUrl, exists: false, statusCode: cardRes.statusCode };
|
|
5084
|
+
}
|
|
5085
|
+
}
|
|
5086
|
+
} catch (err) {
|
|
5087
|
+
output.ucpProfile.agentCard = { error: err.message };
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5090
|
+
// --- Shopify host detection (for dual-surface info shortcut in checks) ---
|
|
5091
|
+
try {
|
|
5092
|
+
const homepageHeaders = homepageRes && homepageRes.headers ? homepageRes.headers : {};
|
|
5093
|
+
const headerLookup = (n) => {
|
|
5094
|
+
const lower = n.toLowerCase();
|
|
5095
|
+
for (const k of Object.keys(homepageHeaders)) {
|
|
5096
|
+
if (k.toLowerCase() === lower) return String(homepageHeaders[k] || '');
|
|
5097
|
+
}
|
|
5098
|
+
return '';
|
|
5099
|
+
};
|
|
5100
|
+
const host = (cleanDomain || '').toLowerCase();
|
|
5101
|
+
const isShopifyDomain = host.endsWith('.myshopify.com') || host === 'myshopify.com';
|
|
5102
|
+
const isShopifyByHeader = !!(headerLookup('x-shopid') || headerLookup('x-shardid') || headerLookup('x-shopify-stage') || headerLookup('powered-by').toLowerCase().includes('shopify'));
|
|
5103
|
+
output.ucpProfile.shopifyHosted = isShopifyDomain || isShopifyByHeader;
|
|
5104
|
+
} catch (err) {
|
|
5105
|
+
output.ucpProfile.shopifyHosted = false;
|
|
5106
|
+
}
|
|
5107
|
+
|
|
4774
5108
|
// --- Homepage (full 16-category analysis) ---
|
|
4775
5109
|
try {
|
|
4776
5110
|
output.homepage.statusCode = homepageRes.statusCode;
|