@nebulacomponents/citable 1.15.1 → 1.17.0
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/CHANGELOG.md +50 -0
- package/README.md +18 -5
- package/dist/universal/.agents/skills/citable/SKILL.md +51 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +3 -1
- package/dist/universal/.agents/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.agents/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.agents/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.agents/skills/citable/manifest.json +17 -12
- package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +51 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
- package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.claude/skills/citable/manifest.json +17 -12
- package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +51 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
- package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +17 -12
- package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +51 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
- package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +17 -12
- package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.github/skills/citable/SKILL.md +51 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +3 -1
- package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.github/skills/citable/manifest.json +17 -12
- package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +51 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
- package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +17 -12
- package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +51 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
- package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +17 -12
- package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +51 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
- package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +17 -12
- package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +51 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
- package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +17 -12
- package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +51 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
- package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +17 -12
- package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +51 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae/skills/citable/manifest.json +17 -12
- package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +51 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +17 -12
- package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/manifest.json +208 -148
- package/package.json +1 -1
- package/schemas/cli-output-envelope.schema.json +30 -0
- package/schemas/experiment.schema.json +1 -1
- package/schemas/finding.schema.json +69 -0
- package/schemas/fleet.schema.json +93 -0
- package/schemas/funnel.schema.json +116 -0
- package/schemas/mcp-transport-envelope.schema.json +244 -0
- package/schemas/observation.schema.json +1 -1
- package/schemas/remediation-verification.schema.json +98 -0
- package/skill/SKILL.md +51 -1
- package/skill/commands/README.md +3 -1
- package/skill/commands/governance.md +12 -0
- package/skill/commands/measurement.md +20 -0
- package/skill/commands/reporting.md +16 -0
- package/src/analysis/saliency.js +119 -0
- package/src/cli/index.js +651 -13
- package/src/commands/attributeImpact.js +54 -0
- package/src/commands/audit.js +2 -2
- package/src/commands/ciWorkflow.js +63 -0
- package/src/commands/compatibility.js +205 -0
- package/src/commands/connect.js +3 -0
- package/src/commands/context.js +2 -1
- package/src/commands/edgeRules.js +196 -0
- package/src/commands/edgeSecurity.js +192 -0
- package/src/commands/exceptions.js +241 -0
- package/src/commands/experimentGuardrails.js +213 -0
- package/src/commands/fleet.js +104 -0
- package/src/commands/generateLlmsTxt.js +111 -0
- package/src/commands/goldenCorpus.js +155 -0
- package/src/commands/implementationKit.js +202 -0
- package/src/commands/inspectAeo.js +74 -0
- package/src/commands/inspectCro.js +142 -0
- package/src/commands/inspectGeo.js +52 -0
- package/src/commands/inspectSerp.js +317 -0
- package/src/commands/lintComponents.js +132 -0
- package/src/commands/monitor.js +138 -0
- package/src/commands/observe.js +6 -1
- package/src/commands/planExperiment.js +120 -0
- package/src/commands/previewCro.js +241 -0
- package/src/commands/probe.js +70 -0
- package/src/commands/remediate.js +461 -0
- package/src/commands/reportConsensus.js +48 -0
- package/src/commands/testFunnel.js +107 -0
- package/src/commands/verifyRemediation.js +204 -0
- package/src/commands/visualRegression.js +165 -0
- package/src/components/index.js +556 -0
- package/src/connectors/index.js +6 -1
- package/src/connectors/indexnow.js +431 -0
- package/src/connectors/mcp/allowlist.js +107 -0
- package/src/connectors/mcp/client.js +239 -0
- package/src/connectors/mcp/diagnostics.js +214 -0
- package/src/connectors/mcp/gscServer.js +155 -0
- package/src/connectors/mcp/http.js +130 -0
- package/src/connectors/mcp/index.js +7 -0
- package/src/connectors/mcp/pilot.js +138 -0
- package/src/connectors/mcp/pilotServer.js +80 -0
- package/src/connectors/mcp/sanitizer.js +38 -0
- package/src/connectors/mcp/stdio.js +178 -0
- package/src/crawler/ipRanges.js +161 -0
- package/src/detectors/agent.js +127 -0
- package/src/detectors/ans.js +282 -0
- package/src/detectors/crawl.js +46 -0
- package/src/detectors/cro.js +825 -0
- package/src/detectors/cwv.js +38 -1
- package/src/detectors/evd.js +101 -0
- package/src/detectors/framework.js +32 -2
- package/src/detectors/geoReco.js +408 -0
- package/src/detectors/index.js +4 -2
- package/src/detectors/link.js +112 -1
- package/src/detectors/page.js +101 -0
- package/src/detectors/schemaData.js +529 -0
- package/src/detectors/tech.js +306 -0
- package/src/extractor/page.js +186 -2
- package/src/extractor/site.js +9 -2
- package/src/installer/index.js +45 -2
- package/src/monitoring/alertDelivery.js +101 -7
- package/src/observations/attribution.js +217 -0
- package/src/registries/index.js +14 -0
- package/src/reporting/consensus.js +416 -0
- package/src/reporting/executiveExport.js +244 -0
package/src/detectors/tech.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parse } from 'node-html-parser';
|
|
1
2
|
import { defineDetector, hasHtmlDocumentMarkup, indexTargets, isHtmlDocument, isHtmlDocumentUrl, registryPageFor, pageSubject, safePath } from './framework.js';
|
|
2
3
|
import { isAllowed } from '../crawler/robots.js';
|
|
3
4
|
|
|
@@ -358,6 +359,311 @@ D.push(defineDetector({
|
|
|
358
359
|
},
|
|
359
360
|
}));
|
|
360
361
|
|
|
362
|
+
D.push(defineDetector({
|
|
363
|
+
id: 'TECH-019', name: 'Open Graph URL conflicts with canonical', namespace: 'TECH',
|
|
364
|
+
description: 'Open Graph URL (og:url) disagrees with the declared canonical URL, sending conflicting canonicalization and entity identity signals.',
|
|
365
|
+
discipline: ['seo', 'aeo', 'geo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
366
|
+
impact: { retrieval: 'low', ranking: 'medium', citation: 'medium' },
|
|
367
|
+
applicable_requirement: 'SEO §2 canonical signals should agree across Open Graph and HTML declarations; AEO §2 consistent identity signals',
|
|
368
|
+
remediation: 'Align og:url with the rel=canonical target on the index-target document, or omit og:url if self-canonicalizing.',
|
|
369
|
+
verification: 'Compare og:url and rel=canonical in rendered HTML.',
|
|
370
|
+
check(ctx) {
|
|
371
|
+
const hits = [];
|
|
372
|
+
for (const p of ctx.site.pages) {
|
|
373
|
+
if (p.canonicals.length !== 1 || !p.ogUrl) continue;
|
|
374
|
+
const declared = p.canonicals[0];
|
|
375
|
+
let normCanonical;
|
|
376
|
+
let normOg;
|
|
377
|
+
try {
|
|
378
|
+
normCanonical = ctx.site.normalize(new URL(declared, p.url).href);
|
|
379
|
+
normOg = ctx.site.normalize(new URL(p.ogUrl, p.url).href);
|
|
380
|
+
} catch {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (normCanonical !== normOg) {
|
|
384
|
+
hits.push({
|
|
385
|
+
subject: pageSubject(p),
|
|
386
|
+
summary: `Open Graph URL (${p.ogUrl}) disagrees with rel=canonical (${declared})`,
|
|
387
|
+
evidence: [`rel=canonical: ${declared}`, `og:url: ${p.ogUrl}`],
|
|
388
|
+
captured: { canonical: declared, ogUrl: p.ogUrl },
|
|
389
|
+
expected: 'matching canonical and og:url',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return hits;
|
|
394
|
+
},
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
D.push(defineDetector({
|
|
398
|
+
id: 'TECH-020', name: 'Missing reciprocal hreflang return link', namespace: 'TECH',
|
|
399
|
+
description: 'An alternate hreflang URL targets an internal page, but that target page does not reciprocate with a matching hreflang link back to the source page.',
|
|
400
|
+
discipline: ['seo', 'geo'], severity: 'high', deterministic: true, requires: ['site'],
|
|
401
|
+
impact: { retrieval: 'high', ranking: 'medium', citation: 'medium' },
|
|
402
|
+
applicable_requirement: 'SEO §2 internationalization controls; RFC 8288 and search engine hreflang reciprocity requirement',
|
|
403
|
+
remediation: 'Add a reciprocal <link rel="alternate" hreflang="..." href="..."> tag on the target page pointing back to the referencing page.',
|
|
404
|
+
verification: 'Extract hreflang alternate links on both URLs and confirm bidirectional reciprocity.',
|
|
405
|
+
check(ctx) {
|
|
406
|
+
const hits = [];
|
|
407
|
+
for (const p of ctx.site.pages) {
|
|
408
|
+
if (!p.hreflangs || p.hreflangs.length === 0) continue;
|
|
409
|
+
const normSource = ctx.site.normalize(p.url);
|
|
410
|
+
for (const alt of p.hreflangs) {
|
|
411
|
+
if (!alt.href || alt.lang === 'x-default') continue;
|
|
412
|
+
let normTarget;
|
|
413
|
+
try {
|
|
414
|
+
normTarget = ctx.site.normalize(new URL(alt.href, p.url).href);
|
|
415
|
+
} catch {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (normTarget === normSource) continue;
|
|
419
|
+
const targetPage = ctx.site.byUrl.get(normTarget);
|
|
420
|
+
if (!targetPage) continue;
|
|
421
|
+
const hasReciprocal = (targetPage.hreflangs || []).some((tAlt) => {
|
|
422
|
+
try {
|
|
423
|
+
const normBack = ctx.site.normalize(new URL(tAlt.href, targetPage.url).href);
|
|
424
|
+
return normBack === normSource;
|
|
425
|
+
} catch {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
if (!hasReciprocal) {
|
|
430
|
+
hits.push({
|
|
431
|
+
subject: pageSubject(p),
|
|
432
|
+
summary: `Missing reciprocal hreflang link from ${alt.href} back to ${p.url}`,
|
|
433
|
+
evidence: [
|
|
434
|
+
`${p.url} references alternate ${alt.href} (hreflang="${alt.lang}")`,
|
|
435
|
+
`${normTarget} does not link back to ${p.url} in its hreflang annotations`,
|
|
436
|
+
],
|
|
437
|
+
captured: { target: normTarget, hreflang: alt.lang },
|
|
438
|
+
expected: `reciprocal link to ${p.url}`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return hits;
|
|
444
|
+
},
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
D.push(defineDetector({
|
|
448
|
+
id: 'TECH-021', name: 'Invalid or non-standard BCP 47 hreflang syntax', namespace: 'TECH',
|
|
449
|
+
description: 'An hreflang annotation uses an invalid language code, malformed region subtag, deprecated territory, or relative URL.',
|
|
450
|
+
discipline: ['seo', 'geo'], severity: 'high', deterministic: true, requires: ['site'],
|
|
451
|
+
impact: { retrieval: 'high', ranking: 'medium', citation: 'medium' },
|
|
452
|
+
applicable_requirement: 'SEO §2 BCP 47 language/region standards (ISO 639-1 language, ISO 3166-1 alpha-2 region)',
|
|
453
|
+
remediation: 'Use lowercase two-letter ISO 639-1 language codes, optional uppercase ISO 3166-1 region codes (e.g. en-US, es-ES, de-DE), or x-default with absolute URLs.',
|
|
454
|
+
verification: 'Validate all hreflang attribute values against BCP 47 and confirm absolute URLs.',
|
|
455
|
+
check(ctx) {
|
|
456
|
+
const hits = [];
|
|
457
|
+
const BCP47_REGEX = /^(?:x-default|[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?)$/;
|
|
458
|
+
for (const p of ctx.site.pages) {
|
|
459
|
+
if (!p.hreflangs || p.hreflangs.length === 0) continue;
|
|
460
|
+
for (const alt of p.hreflangs) {
|
|
461
|
+
const code = alt.lang;
|
|
462
|
+
const href = alt.href;
|
|
463
|
+
const issues = [];
|
|
464
|
+
if (!code) {
|
|
465
|
+
issues.push('hreflang attribute is empty');
|
|
466
|
+
} else {
|
|
467
|
+
if (code.toLowerCase() === 'x-default') {
|
|
468
|
+
if (code !== 'x-default') issues.push(`x-default must be lowercase (got "${code}")`);
|
|
469
|
+
} else if (code.includes('_')) {
|
|
470
|
+
issues.push(`hreflang uses underscore instead of hyphen ("${code}")`);
|
|
471
|
+
} else if (/\ben-uk\b/i.test(code)) {
|
|
472
|
+
issues.push(`"UK" is not an ISO 3166-1 alpha-2 country code; use "GB" (got "${code}")`);
|
|
473
|
+
} else if (!BCP47_REGEX.test(code)) {
|
|
474
|
+
issues.push(`malformed BCP 47 code format ("${code}")`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (href && !/^https?:\/\//i.test(href)) {
|
|
478
|
+
issues.push(`hreflang target must be an absolute URL with scheme (got "${href}")`);
|
|
479
|
+
}
|
|
480
|
+
if (issues.length > 0) {
|
|
481
|
+
hits.push({
|
|
482
|
+
subject: pageSubject(p),
|
|
483
|
+
summary: `Invalid hreflang annotation: ${issues.join('; ')}`,
|
|
484
|
+
evidence: issues.map((iss) => `${p.url}: ${iss}`),
|
|
485
|
+
captured: { hreflang: code, href },
|
|
486
|
+
expected: 'valid BCP 47 language/region code and absolute URL',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return hits;
|
|
492
|
+
},
|
|
493
|
+
}));
|
|
494
|
+
|
|
495
|
+
D.push(defineDetector({
|
|
496
|
+
id: 'TECH-022', name: 'Hreflang alternate target conflicts with rel=canonical', namespace: 'TECH',
|
|
497
|
+
description: 'An alternate hreflang URL points to a page that declares a different rel=canonical target, breaking the alternate cluster.',
|
|
498
|
+
discipline: ['seo', 'geo'], severity: 'critical', deterministic: true, requires: ['site'],
|
|
499
|
+
impact: { retrieval: 'high', ranking: 'high', citation: 'high' },
|
|
500
|
+
applicable_requirement: 'SEO §2 canonical and alternate harmony; localized pages in an hreflang cluster must be self-canonical',
|
|
501
|
+
remediation: 'Ensure each localized alternate page declares a self-referencing rel=canonical matching its own URL.',
|
|
502
|
+
verification: 'Check that target rel=canonical matches the alternate hreflang URL.',
|
|
503
|
+
check(ctx) {
|
|
504
|
+
const hits = [];
|
|
505
|
+
for (const p of ctx.site.pages) {
|
|
506
|
+
if (!p.hreflangs || p.hreflangs.length === 0) continue;
|
|
507
|
+
for (const alt of p.hreflangs) {
|
|
508
|
+
if (!alt.href) continue;
|
|
509
|
+
let normTarget;
|
|
510
|
+
try {
|
|
511
|
+
normTarget = ctx.site.normalize(new URL(alt.href, p.url).href);
|
|
512
|
+
} catch {
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const targetPage = ctx.site.byUrl.get(normTarget);
|
|
516
|
+
if (!targetPage || targetPage.canonicals.length !== 1) continue;
|
|
517
|
+
let targetCanonical;
|
|
518
|
+
try {
|
|
519
|
+
targetCanonical = ctx.site.normalize(new URL(targetPage.canonicals[0], targetPage.url).href);
|
|
520
|
+
} catch {
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (targetCanonical !== normTarget) {
|
|
524
|
+
hits.push({
|
|
525
|
+
subject: pageSubject(p),
|
|
526
|
+
summary: `Hreflang alternate ${alt.href} points to non-canonical page (canonical is ${targetCanonical})`,
|
|
527
|
+
evidence: [
|
|
528
|
+
`${p.url} lists ${alt.href} as alternate (${alt.lang})`,
|
|
529
|
+
`${normTarget} declares canonical ${targetCanonical}`,
|
|
530
|
+
],
|
|
531
|
+
captured: { target: normTarget, canonical: targetCanonical },
|
|
532
|
+
expected: `self-canonical target (${normTarget})`,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return hits;
|
|
538
|
+
},
|
|
539
|
+
}));
|
|
540
|
+
|
|
541
|
+
D.push(defineDetector({
|
|
542
|
+
id: 'TECH-023', name: 'Hydration gap: critical metadata or schema missing from initial server HTML response', namespace: 'TECH',
|
|
543
|
+
description: 'A single-page application or client-rendered document returns empty content mounting shells with virtually no static text (<20 words) in initial server HTML, breaking crawler and answer engine extraction without full headless JS rendering.',
|
|
544
|
+
discipline: ['seo', 'aeo'], severity: 'high', deterministic: true, requires: ['site'],
|
|
545
|
+
impact: { retrieval: 'high', ranking: 'medium', citation: 'medium' },
|
|
546
|
+
applicable_requirement: 'SEO §2 crawlable HTML and server-side rendering; AEO §2 immediate extractability without execution delay',
|
|
547
|
+
remediation: 'Implement Server-Side Rendering (SSR) or Static Site Generation (SSG) so critical content and metadata are present in initial server HTML.',
|
|
548
|
+
verification: 'Inspect initial server HTML response to confirm main content and metadata are present before JavaScript execution.',
|
|
549
|
+
check(ctx) {
|
|
550
|
+
const hits = [];
|
|
551
|
+
for (const p of indexTargets(ctx)) {
|
|
552
|
+
if (p.status !== 200 || !p.rawHtml) continue;
|
|
553
|
+
const doc = parse(p.rawHtml);
|
|
554
|
+
const hasMountShell = doc.querySelector('#root, #__next, #app, [data-reactroot]');
|
|
555
|
+
const wordCount = p.rawVisibleWordCount || p.wordCount || 0;
|
|
556
|
+
const hasScripts = (p.scriptBytes || 0) > 0 || doc.querySelectorAll('script').length > 0;
|
|
557
|
+
|
|
558
|
+
if (hasMountShell && wordCount < 20 && hasScripts) {
|
|
559
|
+
hits.push({
|
|
560
|
+
subject: pageSubject(p),
|
|
561
|
+
summary: `Client-rendered mounting shell (${hasMountShell.tagName.toLowerCase()}${hasMountShell.id ? '#' + hasMountShell.id : ''}) contains only ${wordCount} words in initial HTML response`,
|
|
562
|
+
evidence: [
|
|
563
|
+
`shell element: <${hasMountShell.tagName.toLowerCase()} id="${hasMountShell.id || ''}">`,
|
|
564
|
+
`initial static word count: ${wordCount}`,
|
|
565
|
+
'search engines and AI agents requiring raw HTML cannot extract content without full JavaScript rendering',
|
|
566
|
+
],
|
|
567
|
+
captured: wordCount,
|
|
568
|
+
expected: '>= 20 words with server-rendered content',
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return hits;
|
|
573
|
+
},
|
|
574
|
+
}));
|
|
575
|
+
|
|
576
|
+
D.push(defineDetector({
|
|
577
|
+
id: 'TECH-024', name: 'Excessive render-blocking script payload exceeding crawler execution budget', namespace: 'TECH',
|
|
578
|
+
description: 'The page delivers excessive inline/head script payloads (>1.2MB) or an excessive number of render-blocking head scripts (>12 without defer/async), exhausting crawler execution budgets and delaying indexing.',
|
|
579
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
580
|
+
impact: { retrieval: 'high', ranking: 'low' },
|
|
581
|
+
applicable_requirement: 'SEO §2 crawler execution budget; Google Search Central JavaScript rendering guidelines',
|
|
582
|
+
remediation: 'Reduce JavaScript payload size, code-split vendor bundles, and add defer or async to head script tags.',
|
|
583
|
+
verification: 'Verify total initial script payload is under 1.2MB and head script tags are deferred or asynchronous.',
|
|
584
|
+
check(ctx) {
|
|
585
|
+
const hits = [];
|
|
586
|
+
for (const p of indexTargets(ctx)) {
|
|
587
|
+
if (p.status !== 200 || !p.rawHtml) continue;
|
|
588
|
+
const doc = parse(p.rawHtml);
|
|
589
|
+
const headScripts = doc.querySelectorAll('head script');
|
|
590
|
+
let blockingCount = 0;
|
|
591
|
+
for (const s of headScripts) {
|
|
592
|
+
const type = (s.getAttribute('type') || '').toLowerCase();
|
|
593
|
+
if (type === 'application/ld+json') continue;
|
|
594
|
+
const isAsync = s.hasAttribute('async');
|
|
595
|
+
const isDefer = s.hasAttribute('defer');
|
|
596
|
+
const isModule = type === 'module';
|
|
597
|
+
if (!isAsync && !isDefer && !isModule && s.hasAttribute('src')) {
|
|
598
|
+
blockingCount++;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const scriptBytes = p.scriptBytes || 0;
|
|
603
|
+
if (scriptBytes > 1200000 || blockingCount > 12) {
|
|
604
|
+
hits.push({
|
|
605
|
+
subject: pageSubject(p),
|
|
606
|
+
summary: `Page exceeds crawler script execution budget (${scriptBytes > 1200000 ? `${(scriptBytes / 1000000).toFixed(2)}MB script bytes` : ''}${scriptBytes > 1200000 && blockingCount > 12 ? '; ' : ''}${blockingCount > 12 ? `${blockingCount} render-blocking head scripts` : ''})`,
|
|
607
|
+
evidence: [
|
|
608
|
+
`script bytes: ${scriptBytes}`,
|
|
609
|
+
`render-blocking scripts in head: ${blockingCount}`,
|
|
610
|
+
'crawlers and generative search spiders may abort JavaScript execution under heavy script payloads',
|
|
611
|
+
],
|
|
612
|
+
captured: { scriptBytes, blockingCount },
|
|
613
|
+
expected: 'scriptBytes <= 1.2MB and blockingCount <= 12',
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return hits;
|
|
618
|
+
},
|
|
619
|
+
}));
|
|
620
|
+
|
|
621
|
+
D.push(defineDetector({
|
|
622
|
+
id: 'TECH-025', name: 'Oversized uncompressed image asset or heavy inline image payload', namespace: 'TECH',
|
|
623
|
+
description: 'The page embeds an excessively large inline image data URI (>100KB) or references raw uncompressed image payloads exceeding 1MB, delaying Largest Contentful Paint (LCP) and exhausting mobile data budgets.',
|
|
624
|
+
discipline: ['seo', 'aeo'], severity: 'medium', deterministic: true, requires: ['site'],
|
|
625
|
+
impact: { retrieval: 'medium', ranking: 'low' },
|
|
626
|
+
applicable_requirement: 'SEO §2 crawler media payload budget; Google Search Central image optimization guidelines',
|
|
627
|
+
remediation: 'Compress image assets using modern formats (WebP/AVIF), resize to display dimensions, and externalize inline base64 image data.',
|
|
628
|
+
verification: 'Confirm all image references are under 1MB and inline data URIs are under 100KB.',
|
|
629
|
+
check(ctx) {
|
|
630
|
+
const hits = [];
|
|
631
|
+
for (const p of indexTargets(ctx)) {
|
|
632
|
+
if (p.status !== 200 || !p.images) continue;
|
|
633
|
+
for (const img of p.images) {
|
|
634
|
+
const src = img.src || '';
|
|
635
|
+
if (src.startsWith('data:image/') && src.length > 100000) {
|
|
636
|
+
hits.push({
|
|
637
|
+
subject: pageSubject(p),
|
|
638
|
+
summary: `Heavy inline base64 image payload (${Math.round(src.length / 1024)}KB) bloats HTML document size`,
|
|
639
|
+
evidence: [
|
|
640
|
+
`data URI length: ${src.length} characters (~${Math.round(src.length / 1024)}KB)`,
|
|
641
|
+
'inline image payloads delay initial HTML parsing and waste crawler network budgets',
|
|
642
|
+
],
|
|
643
|
+
captured: { dataUriLength: src.length },
|
|
644
|
+
expected: 'inline image data URI <= 100KB',
|
|
645
|
+
});
|
|
646
|
+
break;
|
|
647
|
+
} else if (img.bytes && img.bytes > 1000000) {
|
|
648
|
+
hits.push({
|
|
649
|
+
subject: pageSubject(p),
|
|
650
|
+
summary: `Oversized image asset "${src}" (${(img.bytes / 1000000).toFixed(2)}MB) delays LCP`,
|
|
651
|
+
evidence: [
|
|
652
|
+
`image source: ${src}`,
|
|
653
|
+
`image size: ${img.bytes} bytes`,
|
|
654
|
+
'images exceeding 1MB exhaust mobile rendering budgets and degrade Largest Contentful Paint',
|
|
655
|
+
],
|
|
656
|
+
captured: { src, bytes: img.bytes },
|
|
657
|
+
expected: 'image bytes <= 1MB',
|
|
658
|
+
});
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return hits;
|
|
664
|
+
},
|
|
665
|
+
}));
|
|
666
|
+
|
|
361
667
|
export function canonicalTargetProblem(ctx, predicate, label) {
|
|
362
668
|
const hits = [];
|
|
363
669
|
for (const p of ctx.site.pages) {
|
package/src/extractor/page.js
CHANGED
|
@@ -19,6 +19,35 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
|
|
|
19
19
|
.map((l) => l.getAttribute('href'))
|
|
20
20
|
.filter(Boolean);
|
|
21
21
|
|
|
22
|
+
const hreflangs = root
|
|
23
|
+
.querySelectorAll('link[hreflang]')
|
|
24
|
+
.filter((l) => {
|
|
25
|
+
const rel = (l.getAttribute('rel') || '').toLowerCase();
|
|
26
|
+
return rel.split(/\s+/).includes('alternate');
|
|
27
|
+
})
|
|
28
|
+
.map((l) => ({
|
|
29
|
+
lang: (l.getAttribute('hreflang') || '').trim(),
|
|
30
|
+
href: (l.getAttribute('href') || '').trim(),
|
|
31
|
+
}))
|
|
32
|
+
.filter((item) => item.lang || item.href);
|
|
33
|
+
|
|
34
|
+
const linkHeader = headers['link'] || headers['Link'];
|
|
35
|
+
if (linkHeader) {
|
|
36
|
+
const headerStr = Array.isArray(linkHeader) ? linkHeader.join(', ') : String(linkHeader);
|
|
37
|
+
const parts = headerStr.split(/,(?=\s*<)/);
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
const hrefMatch = part.match(/<([^>]+)>/);
|
|
40
|
+
const relMatch = part.match(/rel=["']?([^"';\s]+)["']?/i);
|
|
41
|
+
const hreflangMatch = part.match(/hreflang=["']?([^"';\s]+)["']?/i);
|
|
42
|
+
if (hrefMatch && relMatch && relMatch[1].toLowerCase() === 'alternate' && hreflangMatch) {
|
|
43
|
+
hreflangs.push({
|
|
44
|
+
lang: hreflangMatch[1].trim(),
|
|
45
|
+
href: hrefMatch[1].trim(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
const robotsMetaRaw = [...(metas['robots'] || []), ...(metas['googlebot'] || [])].join(',').toLowerCase();
|
|
23
52
|
const xRobots = String(headers['x-robots-tag'] || '').toLowerCase();
|
|
24
53
|
const robotsDirectives = new Set(
|
|
@@ -42,6 +71,9 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
|
|
|
42
71
|
const images = root.querySelectorAll('img').map((i) => ({
|
|
43
72
|
src: i.getAttribute('src') || '',
|
|
44
73
|
alt: i.getAttribute('alt'),
|
|
74
|
+
width: i.getAttribute('width') || null,
|
|
75
|
+
height: i.getAttribute('height') || null,
|
|
76
|
+
loading: (i.getAttribute('loading') || '').toLowerCase(),
|
|
45
77
|
}));
|
|
46
78
|
|
|
47
79
|
const jsonLd = [];
|
|
@@ -102,10 +134,154 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
|
|
|
102
134
|
if (t) hiddenTexts.push(t);
|
|
103
135
|
}
|
|
104
136
|
}
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
// Extract interactive CTAs (buttons, links with button role/classes, submit inputs)
|
|
138
|
+
const CTA_TEXT_RX = /\b(sign\s*up|get\s*started|request\s*(a\s*)?demo|book\s*(a\s*)?demo|schedule\s*(a\s*)?demo|try\s*(it\s*)?free|start\s*(free\s*)?trial|contact\s*sales|talk\s*to\s*sales|buy\s*now|subscribe|view\s*pricing|see\s*pricing|get\s*a\s*quote|download\s*whitepaper|download\s*report|join\s*waitlist)\b/i;
|
|
139
|
+
const CTA_ATTR_RX = /\b(btn|cta|button|primary-action|action-btn|submit)\b/i;
|
|
140
|
+
|
|
141
|
+
const ctas = [];
|
|
142
|
+
for (const el of root.querySelectorAll('button, a, input[type=submit], input[type=button]')) {
|
|
143
|
+
const tag = el.tagName.toLowerCase();
|
|
144
|
+
const text = (tag === 'input' ? el.getAttribute('value') : el.text) || '';
|
|
145
|
+
const cleanText = text.replace(/\s+/g, ' ').trim();
|
|
146
|
+
const href = el.getAttribute('href') || null;
|
|
147
|
+
const action = el.getAttribute('formaction') || null;
|
|
148
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
149
|
+
const cls = el.getAttribute('class') || '';
|
|
150
|
+
|
|
151
|
+
const matchesKeyword = CTA_TEXT_RX.test(cleanText);
|
|
152
|
+
const matchesAttr = CTA_ATTR_RX.test(cls) || role === 'button' || tag === 'button' || el.getAttribute('type') === 'submit';
|
|
153
|
+
|
|
154
|
+
if (matchesKeyword || (matchesAttr && cleanText.length > 0 && cleanText.length <= 40)) {
|
|
155
|
+
let inNav = false;
|
|
156
|
+
let inHero = false;
|
|
157
|
+
let cur = el.parentNode;
|
|
158
|
+
while (cur && cur !== root) {
|
|
159
|
+
const tn = cur.tagName ? cur.tagName.toLowerCase() : '';
|
|
160
|
+
const c = cur.getAttribute ? (cur.getAttribute('class') || '') : '';
|
|
161
|
+
const id = cur.getAttribute ? (cur.getAttribute('id') || '') : '';
|
|
162
|
+
const r = cur.getAttribute ? (cur.getAttribute('role') || '') : '';
|
|
163
|
+
if (tn === 'nav' || tn === 'header' || r === 'navigation' || r === 'banner') inNav = true;
|
|
164
|
+
if (tn === 'header' || c.includes('hero') || id.includes('hero') || c.includes('banner') || id.includes('banner')) inHero = true;
|
|
165
|
+
cur = cur.parentNode;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const style = (el.getAttribute('style') || '').toLowerCase();
|
|
169
|
+
const widthMatch = style.match(/width:\s*(\d+)px/);
|
|
170
|
+
const heightMatch = style.match(/height:\s*(\d+)px/);
|
|
171
|
+
const fontSizeMatch = style.match(/font-size:\s*(\d+)px/);
|
|
172
|
+
const inlineWidth = widthMatch ? Number(widthMatch[1]) : (el.getAttribute('width') ? Number(el.getAttribute('width')) : null);
|
|
173
|
+
const inlineHeight = heightMatch ? Number(heightMatch[1]) : (el.getAttribute('height') ? Number(el.getAttribute('height')) : null);
|
|
174
|
+
const inlineFontSize = fontSizeMatch ? Number(fontSizeMatch[1]) : null;
|
|
175
|
+
|
|
176
|
+
ctas.push({
|
|
177
|
+
tag,
|
|
178
|
+
text: cleanText,
|
|
179
|
+
target: href || action || null,
|
|
180
|
+
role: role || null,
|
|
181
|
+
className: cls || null,
|
|
182
|
+
isPrimary: matchesKeyword,
|
|
183
|
+
inNav,
|
|
184
|
+
inHero,
|
|
185
|
+
style: style || null,
|
|
186
|
+
inlineWidth,
|
|
187
|
+
inlineHeight,
|
|
188
|
+
inlineFontSize,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
107
191
|
}
|
|
108
192
|
|
|
193
|
+
// Extract form elements
|
|
194
|
+
const forms = [];
|
|
195
|
+
for (const form of root.querySelectorAll('form')) {
|
|
196
|
+
const action = form.getAttribute('action') || null;
|
|
197
|
+
const method = (form.getAttribute('method') || 'GET').toUpperCase();
|
|
198
|
+
const inputEls = form.querySelectorAll('input, select, textarea');
|
|
199
|
+
const inputs = [];
|
|
200
|
+
let hasSubmit = !!form.querySelector('button[type=submit], input[type=submit]');
|
|
201
|
+
if (!hasSubmit && form.querySelector('button:not([type=button]):not([type=reset])')) {
|
|
202
|
+
hasSubmit = true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let nonHiddenCount = 0;
|
|
206
|
+
for (const inp of inputEls) {
|
|
207
|
+
const type = (inp.getAttribute('type') || (inp.tagName.toLowerCase() === 'textarea' ? 'textarea' : 'text')).toLowerCase();
|
|
208
|
+
const name = inp.getAttribute('name') || null;
|
|
209
|
+
const id = inp.getAttribute('id') || null;
|
|
210
|
+
const required = inp.hasAttribute('required');
|
|
211
|
+
const autocomplete = inp.getAttribute('autocomplete')?.trim() || null;
|
|
212
|
+
const inputmode = inp.getAttribute('inputmode')?.trim() || null;
|
|
213
|
+
const placeholder = inp.getAttribute('placeholder') || null;
|
|
214
|
+
|
|
215
|
+
let hasLabel = inp.hasAttribute('aria-label') || inp.hasAttribute('aria-labelledby');
|
|
216
|
+
if (!hasLabel && id) {
|
|
217
|
+
hasLabel = !!root.querySelector(`label[for="${id}"]`);
|
|
218
|
+
}
|
|
219
|
+
if (!hasLabel) {
|
|
220
|
+
let parent = inp.parentNode;
|
|
221
|
+
while (parent && parent !== form) {
|
|
222
|
+
if (parent.tagName && parent.tagName.toLowerCase() === 'label') {
|
|
223
|
+
hasLabel = true;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
parent = parent.parentNode;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (type !== 'hidden') nonHiddenCount++;
|
|
231
|
+
inputs.push({ name, id, type, required, autocomplete, inputmode, placeholder, hasLabel });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
forms.push({
|
|
235
|
+
action,
|
|
236
|
+
method,
|
|
237
|
+
inputs,
|
|
238
|
+
fieldCount: nonHiddenCount,
|
|
239
|
+
hasSubmit,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Extract trust / proof signals in DOM
|
|
244
|
+
const TRUST_RX = /\b(soc\s*2|iso\s*27001|gdpr|hipaa|pci[- ]dss|money[- ]back\s*guarantee|cancel\s*anytime|no\s*credit\s*card\s*required|trusted\s*by|featured\s*in|customer\s*reviews|satisfaction\s*guarantee|sla\s*guarantee)\b/i;
|
|
245
|
+
const trustBadges = [];
|
|
246
|
+
for (const el of root.querySelectorAll('div, section, p, span, img, li, footer')) {
|
|
247
|
+
const txt = el.text.replace(/\s+/g, ' ').trim();
|
|
248
|
+
const alt = el.getAttribute('alt') || '';
|
|
249
|
+
const m = TRUST_RX.exec(txt) || TRUST_RX.exec(alt);
|
|
250
|
+
if (m && !trustBadges.some((t) => t.signal.toLowerCase() === m[1].toLowerCase())) {
|
|
251
|
+
trustBadges.push({
|
|
252
|
+
signal: m[1],
|
|
253
|
+
context: txt ? txt.slice(0, 100) : alt,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Analytics & Tag Manager detection
|
|
259
|
+
const ANALYTICS_RX = /\b(googletagmanager\.com|google-analytics\.com|gtag\(|posthog|segment\.com\/analytics|mixpanel|rudderstack|plausible|fathom)\b/i;
|
|
260
|
+
const analyticsTags = [];
|
|
261
|
+
for (const s of root.querySelectorAll('script')) {
|
|
262
|
+
const src = s.getAttribute('src') || '';
|
|
263
|
+
const content = s.text || '';
|
|
264
|
+
if (ANALYTICS_RX.test(src) || ANALYTICS_RX.test(content)) {
|
|
265
|
+
analyticsTags.push(src || 'inline-tag');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Navigation link count
|
|
270
|
+
const navLinksCount = root.querySelectorAll('nav a, header a, [role=navigation] a').length;
|
|
271
|
+
|
|
272
|
+
let domNodeCount = 0;
|
|
273
|
+
let maxDomDepth = 0;
|
|
274
|
+
const walkDom = (node, depth) => {
|
|
275
|
+
domNodeCount++;
|
|
276
|
+
if (depth > maxDomDepth) maxDomDepth = depth;
|
|
277
|
+
for (const child of node.childNodes || []) {
|
|
278
|
+
if (child.nodeType === 1) {
|
|
279
|
+
walkDom(child, depth + 1);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
walkDom(root, 1);
|
|
284
|
+
|
|
109
285
|
return {
|
|
110
286
|
url,
|
|
111
287
|
sourceFile,
|
|
@@ -118,6 +294,7 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
|
|
|
118
294
|
metaDescription: metas['description']?.[0] ?? null,
|
|
119
295
|
metas,
|
|
120
296
|
canonicals,
|
|
297
|
+
hreflangs,
|
|
121
298
|
robotsDirectives: [...robotsDirectives],
|
|
122
299
|
noindex: robotsDirectives.has('noindex') || robotsDirectives.has('none'),
|
|
123
300
|
nosnippet: robotsDirectives.has('nosnippet') || [...robotsDirectives].some((d) => /^max-snippet:\s*0$/.test(d)),
|
|
@@ -138,5 +315,12 @@ export function extractPage({ url, html, status = 200, headers = {}, sourceFile
|
|
|
138
315
|
orderedLists,
|
|
139
316
|
tables,
|
|
140
317
|
hiddenTexts,
|
|
318
|
+
ctas,
|
|
319
|
+
forms,
|
|
320
|
+
trustBadges,
|
|
321
|
+
analyticsTags,
|
|
322
|
+
navLinksCount,
|
|
323
|
+
domNodeCount,
|
|
324
|
+
maxDomDepth,
|
|
141
325
|
};
|
|
142
326
|
}
|
package/src/extractor/site.js
CHANGED
|
@@ -45,6 +45,11 @@ export function buildSiteFromDir(dir, { baseUrl = 'https://example.test' } = {})
|
|
|
45
45
|
const robotsPath = path.join(dir, 'robots.txt');
|
|
46
46
|
const robotsText = fs.existsSync(robotsPath) ? fs.readFileSync(robotsPath, 'utf8') : null;
|
|
47
47
|
|
|
48
|
+
const llmsPath = path.join(dir, 'llms.txt');
|
|
49
|
+
const llmsText = fs.existsSync(llmsPath) ? fs.readFileSync(llmsPath, 'utf8') : null;
|
|
50
|
+
const llmsFullPath = path.join(dir, 'llms-full.txt');
|
|
51
|
+
const llmsFullText = fs.existsSync(llmsFullPath) ? fs.readFileSync(llmsFullPath, 'utf8') : null;
|
|
52
|
+
|
|
48
53
|
const sitemaps = [];
|
|
49
54
|
for (const f of fs.readdirSync(dir)) {
|
|
50
55
|
if (/sitemap.*\.xml$/i.test(f)) {
|
|
@@ -52,7 +57,7 @@ export function buildSiteFromDir(dir, { baseUrl = 'https://example.test' } = {})
|
|
|
52
57
|
}
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
return assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode: 'built_output', location: dir });
|
|
60
|
+
return assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode: 'built_output', location: dir, llmsText, llmsFullText });
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/** Build a SiteModel by fetching a deployed URL set (target URL + same-origin discovery, bounded). */
|
|
@@ -122,7 +127,7 @@ export async function buildSiteFromUrl(startUrl, {
|
|
|
122
127
|
return site;
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
function assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode, location, crawl = null }) {
|
|
130
|
+
function assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode, location, crawl = null, llmsText = null, llmsFullText = null }) {
|
|
126
131
|
const byUrl = new Map();
|
|
127
132
|
for (const p of pages) byUrl.set(normalize(p.url), p);
|
|
128
133
|
|
|
@@ -179,6 +184,8 @@ function assembleSite({ baseUrl, pages, robotsText, sitemaps, transport, mode, l
|
|
|
179
184
|
depth,
|
|
180
185
|
normalize,
|
|
181
186
|
crawl,
|
|
187
|
+
llmsTxt: llmsText != null ? { raw: llmsText, found: true, path: '/llms.txt' } : null,
|
|
188
|
+
llmsFullTxt: llmsFullText != null ? { raw: llmsFullText, found: true, path: '/llms-full.txt' } : null,
|
|
182
189
|
};
|
|
183
190
|
}
|
|
184
191
|
|
package/src/installer/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from './providers.js';
|
|
16
16
|
|
|
17
17
|
export { PROVIDERS, PROVIDER_IDS, parseProviderList, normalizeProviderId } from './providers.js';
|
|
18
|
+
import { diagnoseMcpTransports } from '../connectors/mcp/diagnostics.js';
|
|
18
19
|
|
|
19
20
|
export const EXIT_CODES = Object.freeze({
|
|
20
21
|
success: 0,
|
|
@@ -106,6 +107,7 @@ export function parseInstallerArgs(argv) {
|
|
|
106
107
|
help: false,
|
|
107
108
|
version: false,
|
|
108
109
|
failOnUpdate: false,
|
|
110
|
+
mcp: false,
|
|
109
111
|
installMode: 'copy',
|
|
110
112
|
skillSelection: null,
|
|
111
113
|
_: [],
|
|
@@ -121,6 +123,7 @@ export function parseInstallerArgs(argv) {
|
|
|
121
123
|
else if (arg === '--dry-run') args.dryRun = true;
|
|
122
124
|
else if (arg === '--force') args.force = true;
|
|
123
125
|
else if (arg === '--json') args.json = true;
|
|
126
|
+
else if (arg === '--mcp') args.mcp = true;
|
|
124
127
|
else if (arg === '--all') { args.all = true; args.yes = true; }
|
|
125
128
|
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
126
129
|
else if (arg === '--version' || arg === '-v') args.version = true;
|
|
@@ -1039,7 +1042,8 @@ function combineInstalledState(skillState, profileState) {
|
|
|
1039
1042
|
export async function doctorCommand(args, options = {}) {
|
|
1040
1043
|
const context = resolveTargets('doctor', args, options);
|
|
1041
1044
|
const packageInfo = loadPackageInfo(context.roots.packageRoot);
|
|
1042
|
-
const
|
|
1045
|
+
const isMcp = Boolean(args?.mcp || options?.mcp);
|
|
1046
|
+
const capabilities = await runtimeCapabilities({ ...options, mcp: isMcp, packageRoot: context.roots.packageRoot });
|
|
1043
1047
|
const checks = [];
|
|
1044
1048
|
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10);
|
|
1045
1049
|
checks.push({ level: nodeMajor >= 20 ? 'PASS' : 'FAIL', message: `Node.js ${process.versions.node}` });
|
|
@@ -1073,8 +1077,21 @@ export async function doctorCommand(args, options = {}) {
|
|
|
1073
1077
|
}
|
|
1074
1078
|
}
|
|
1075
1079
|
|
|
1080
|
+
let mcpSummary = null;
|
|
1081
|
+
if (isMcp) {
|
|
1082
|
+
const mcpDiag = await diagnoseMcpTransports({
|
|
1083
|
+
packageRoot: context.roots.packageRoot,
|
|
1084
|
+
projectRoot: context.roots.projectRoot,
|
|
1085
|
+
...options,
|
|
1086
|
+
});
|
|
1087
|
+
checks.push(...mcpDiag.checks);
|
|
1088
|
+
mcpSummary = mcpDiag.summary;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1076
1091
|
const hasFail = checks.some((check) => check.level === 'FAIL');
|
|
1077
|
-
|
|
1092
|
+
const result = { ok: !hasFail, command: 'doctor', checks, capabilities, exitCode: hasFail ? EXIT_CODES.integrityFailure : EXIT_CODES.success };
|
|
1093
|
+
if (mcpSummary) result.mcp = mcpSummary;
|
|
1094
|
+
return result;
|
|
1078
1095
|
}
|
|
1079
1096
|
|
|
1080
1097
|
async function optionalDependencyAvailable(name, options = {}) {
|
|
@@ -1148,6 +1165,24 @@ export async function runtimeCapabilities(options = {}) {
|
|
|
1148
1165
|
limitations: [configured ? definition.readyLimitation : `${definition.credential} is not configured`],
|
|
1149
1166
|
});
|
|
1150
1167
|
}
|
|
1168
|
+
|
|
1169
|
+
if (options.mcp) {
|
|
1170
|
+
const pkgRoot = options.packageRoot || packageRoot();
|
|
1171
|
+
const pilotPath = path.resolve(pkgRoot, 'src/connectors/mcp/pilotServer.js');
|
|
1172
|
+
const pilotAvailable = exists(pilotPath);
|
|
1173
|
+
capabilities.push({
|
|
1174
|
+
id: 'mcp_transport',
|
|
1175
|
+
state: pilotAvailable ? 'ready' : 'missing_dependency',
|
|
1176
|
+
requirements: ['node:child_process', 'node:readline', 'pilotServer.js'],
|
|
1177
|
+
limitations: pilotAvailable
|
|
1178
|
+
? [
|
|
1179
|
+
'MCP transport is read-only and restricted to explicit allowlisted servers and tools',
|
|
1180
|
+
'remote endpoints require HTTPS and must not resolve to private or loopback ranges',
|
|
1181
|
+
]
|
|
1182
|
+
: ['MCP stdio pilotServer.js script was not found'],
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1151
1186
|
return capabilities;
|
|
1152
1187
|
}
|
|
1153
1188
|
|
|
@@ -1350,6 +1385,13 @@ function renderList(result) {
|
|
|
1350
1385
|
|
|
1351
1386
|
function renderDoctor(result) {
|
|
1352
1387
|
const lines = ['Citable doctor', '', ...result.checks.map((check) => `${check.level.padEnd(5)} ${check.message}`)];
|
|
1388
|
+
if (result.mcp) {
|
|
1389
|
+
lines.push('', 'MCP transport diagnostic');
|
|
1390
|
+
lines.push(` allowlist: ${result.mcp.servers_count} server(s), ${result.mcp.tools_count} read-only tool(s)`);
|
|
1391
|
+
lines.push(` stdio execution: ${result.mcp.stdio_ready ? 'ready' : 'failed'}`);
|
|
1392
|
+
lines.push(` http guards: ${result.mcp.http_guards_active ? 'active' : 'failed'}`);
|
|
1393
|
+
lines.push(` registry bindings: ${result.mcp.connections_found} configured connection(s)`);
|
|
1394
|
+
}
|
|
1353
1395
|
if (result.capabilities?.length) {
|
|
1354
1396
|
lines.push('', 'Runtime capabilities');
|
|
1355
1397
|
for (const capability of result.capabilities) {
|
|
@@ -1391,6 +1433,7 @@ Options
|
|
|
1391
1433
|
--project, --local, -p Install/check project-local skill locations
|
|
1392
1434
|
--global, --user, -g Install/check user-global skill locations
|
|
1393
1435
|
--scope=<project|global> Scope equivalent
|
|
1436
|
+
--mcp Run MCP transport diagnostics (stdio, HTTP guards, allowlist, connections)
|
|
1394
1437
|
--yes, -y Confirm non-interactively
|
|
1395
1438
|
--dry-run Preview filesystem changes without mutation
|
|
1396
1439
|
--force Replace locally modified or unmanaged citable directory
|