agentlinkops 0.5.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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { validatePublicUrl, matchesTarget } from './url.js';
|
|
2
|
+
import { parseDocument, pageDirectives } from './parser.js';
|
|
3
|
+
import { publicFetcher, VerificationError } from './fetch.js';
|
|
4
|
+
import { assessReadiness } from './readiness.js';
|
|
5
|
+
import { nativeRenderEvidence } from './render-evidence.js';
|
|
6
|
+
|
|
7
|
+
export { validatePublicUrl } from './url.js';
|
|
8
|
+
export { parseLinks } from './parser.js';
|
|
9
|
+
export { transitionState } from './transition.js';
|
|
10
|
+
export { checkObservationKind, normalizeDeclaredSite, withinDeclaredSite } from './observation-kind.js';
|
|
11
|
+
|
|
12
|
+
export const CHECKER_VERSION = '4';
|
|
13
|
+
const sharedDocument = Symbol('sharedDocument');
|
|
14
|
+
const suppliedDocument = Symbol('suppliedDocument');
|
|
15
|
+
|
|
16
|
+
const RENDER_REASONS = new Set(['possible_render_required', 'navigation_requires_browser']);
|
|
17
|
+
|
|
18
|
+
/** Optional adaptive lane. The default remains one bounded HTTP observation. */
|
|
19
|
+
export async function verifyLink(input, options = {}) {
|
|
20
|
+
const initial = await verifyHttpLink(input, options);
|
|
21
|
+
const eligible = RENDER_REASONS.has(initial.reason)
|
|
22
|
+
|| (options.compareRendered === true && ['absent', 'present'].includes(initial.state));
|
|
23
|
+
if (!eligible) return initial;
|
|
24
|
+
initial.evidence.renderEligibility = RENDER_REASONS.has(initial.reason) ? 'required' : 'comparison';
|
|
25
|
+
if (!options.renderPage) return initial;
|
|
26
|
+
if (options.renderPage.publicFetchSafe !== true || options.renderPage.requiresNavigationAuthorization !== true) {
|
|
27
|
+
initial.evidence.renderAttempt = { attempted: false, reason: 'render_public_fetch_not_configured' };
|
|
28
|
+
return initial;
|
|
29
|
+
}
|
|
30
|
+
// Grouping shares only acquisition. Target scope, expectations and comparison remain independent.
|
|
31
|
+
const shared = options[sharedDocument];
|
|
32
|
+
const render = async () => {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timeout = Math.min(Math.max(options.renderTimeoutMs ?? 20_000, 1), 20_000);
|
|
35
|
+
let timer;
|
|
36
|
+
try {
|
|
37
|
+
const deadline = new Promise((_, reject) => {
|
|
38
|
+
timer = setTimeout(() => { controller.abort(); reject(new VerificationError('render_timeout')); }, timeout);
|
|
39
|
+
});
|
|
40
|
+
return { value: await Promise.race([
|
|
41
|
+
Promise.resolve().then(() => options.renderPage({ sourceUrl: initial.sourceUrl }, { signal: controller.signal })),
|
|
42
|
+
deadline,
|
|
43
|
+
]) };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// Provider exception messages can contain credentials or remote page text.
|
|
46
|
+
return { error: controller.signal.aborted ? 'render_timeout' : 'render_failed',
|
|
47
|
+
...(Number.isFinite(error?.retryAfterSeconds) && error.retryAfterSeconds >= 0 ? {retryAfterSeconds:Math.min(86400,Math.ceil(error.retryAfterSeconds))} : {}) };
|
|
48
|
+
} finally { clearTimeout(timer); controller.abort(); }
|
|
49
|
+
};
|
|
50
|
+
const acquired = shared ? await (shared.render ??= render()) : await render();
|
|
51
|
+
const fail = (reason) => {
|
|
52
|
+
initial.evidence.renderAttempt = { attempted: true, reason };
|
|
53
|
+
// A failed comparison cannot overrule an already observed anchor. Static absence,
|
|
54
|
+
// however, cannot be promoted when the requested browser comparison did not finish.
|
|
55
|
+
if (initial.state === 'absent') {
|
|
56
|
+
initial.state = 'unknown'; initial.reason = reason; initial.evidence.complete = false;
|
|
57
|
+
delete initial.linkSignature;
|
|
58
|
+
}
|
|
59
|
+
return initial;
|
|
60
|
+
};
|
|
61
|
+
if (acquired.error) {
|
|
62
|
+
if (acquired.retryAfterSeconds !== undefined) initial.retryAfterSeconds = acquired.retryAfterSeconds;
|
|
63
|
+
return fail(acquired.error);
|
|
64
|
+
}
|
|
65
|
+
const doc = acquired.value;
|
|
66
|
+
if (!doc || typeof doc.html !== 'string' || !validatePublicUrl(doc.finalUrl).valid
|
|
67
|
+
|| !Number.isInteger(doc.httpStatus) || !Array.isArray(doc.redirects) || doc.redirects.length > 5
|
|
68
|
+
|| doc.bodyTruncated !== false || typeof doc.ready !== 'boolean') return fail('invalid_render_evidence');
|
|
69
|
+
let cursor = initial.sourceUrl;
|
|
70
|
+
const redirects = [];
|
|
71
|
+
for (const hop of doc.redirects) {
|
|
72
|
+
if (!hop || hop.from !== cursor || !validatePublicUrl(hop.to).valid
|
|
73
|
+
|| ![301, 302, 303, 307, 308].includes(hop.status)) return fail('invalid_render_redirects');
|
|
74
|
+
cursor = hop.to;
|
|
75
|
+
redirects.push({ from: hop.from, to: hop.to, status: hop.status });
|
|
76
|
+
}
|
|
77
|
+
if (cursor !== doc.finalUrl) return fail('invalid_render_redirects');
|
|
78
|
+
let nativeEvidence;
|
|
79
|
+
if (options.renderPage.strictEvidenceVersion !== undefined) {
|
|
80
|
+
const checked = nativeRenderEvidence(doc, options.renderPage.strictEvidenceVersion);
|
|
81
|
+
if (checked.reason) return fail(checked.reason);
|
|
82
|
+
nativeEvidence = checked.evidence;
|
|
83
|
+
}
|
|
84
|
+
if (doc.httpStatus !== 200) return fail(`render_http_${doc.httpStatus}`);
|
|
85
|
+
if (doc.xRobotsTag !== undefined && (typeof doc.xRobotsTag !== 'string' || doc.xRobotsTag.length > 4096
|
|
86
|
+
|| /[^\t\x20-\x7e\x80-\xff]/u.test(doc.xRobotsTag))) return fail('invalid_render_directives');
|
|
87
|
+
if (doc.html.length > 2 * 1024 * 1024) return fail('render_body_too_large');
|
|
88
|
+
const data = new TextEncoder().encode(doc.html);
|
|
89
|
+
if (data.length > Math.min(options.maxBytes ?? 2 * 1024 * 1024, 2 * 1024 * 1024)) return fail('render_body_too_large');
|
|
90
|
+
const rendered = await verifyHttpLink(input, {
|
|
91
|
+
...options, [sharedDocument]: undefined,
|
|
92
|
+
[suppliedDocument]: {
|
|
93
|
+
metadata: { finalUrl: doc.finalUrl, httpStatus: doc.httpStatus, redirects },
|
|
94
|
+
document: {
|
|
95
|
+
response: new Response(null, { status: 200, headers: { 'content-type': 'text/html; charset=utf-8', ...(doc.xRobotsTag ? { 'x-robots-tag': doc.xRobotsTag } : {}) } }),
|
|
96
|
+
body: { text: doc.html, bytes: data.length, data },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
rendered.evidence.method = 'browser_html';
|
|
101
|
+
rendered.evidence.rendered = true;
|
|
102
|
+
rendered.evidence.ready = doc.ready;
|
|
103
|
+
if (nativeEvidence) {
|
|
104
|
+
rendered.evidence.browser = nativeEvidence;
|
|
105
|
+
if (nativeEvidence.retryAfterSeconds !== undefined && nativeEvidence.retryAfterSeconds !== null) rendered.retryAfterSeconds = nativeEvidence.retryAfterSeconds;
|
|
106
|
+
}
|
|
107
|
+
rendered.evidence.renderAttempt = { attempted: true, reason: 'render_completed' };
|
|
108
|
+
rendered.evidence.staticObservation = {
|
|
109
|
+
state: initial.state, reason: initial.reason, checkedAt: initial.checkedAt,
|
|
110
|
+
sha256: initial.evidence.sha256 ?? null, method: 'http_html',
|
|
111
|
+
...(initial.robotsHistory ? { robotsHistory: structuredClone(initial.robotsHistory) } : {}),
|
|
112
|
+
...(initial.sourceResponse ? { sourceResponse: structuredClone(initial.sourceResponse) } : {}),
|
|
113
|
+
};
|
|
114
|
+
if (rendered.state === 'absent' && options.renderPage.positiveOnly === true) {
|
|
115
|
+
rendered.state = 'unknown'; rendered.reason = 'render_no_positive_evidence';
|
|
116
|
+
rendered.evidence.complete = false; delete rendered.linkSignature;
|
|
117
|
+
} else if (rendered.state === 'absent' && !doc.ready) {
|
|
118
|
+
rendered.state = 'unknown'; rendered.reason = 'render_readiness_unconfirmed';
|
|
119
|
+
rendered.evidence.complete = false; delete rendered.linkSignature;
|
|
120
|
+
} else if (rendered.state === 'absent') rendered.reason = 'no_matching_link_in_ready_dom';
|
|
121
|
+
for (const occurrence of rendered.occurrences) occurrence.visibility = 'rendered_dom_not_visual';
|
|
122
|
+
rendered.warnings = ['Rendered DOM observation; visual visibility and search-engine indexing were not checked.'];
|
|
123
|
+
if (initial.state === 'present' && rendered.state !== 'present') {
|
|
124
|
+
initial.evidence.renderAttempt = { attempted: true, reason: 'render_disagreement', state: rendered.state };
|
|
125
|
+
return initial;
|
|
126
|
+
}
|
|
127
|
+
return rendered;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The cache lives only for this bounded invocation, never across tenants or jobs.
|
|
131
|
+
// Evaluation stays per target, including redirect-to-target and expectation rules.
|
|
132
|
+
export async function verifyLinks(inputs, options = {}) {
|
|
133
|
+
if (!Array.isArray(inputs) || inputs.length > 20 || new Set(inputs.map(input => input.sourceUrl)).size > 1) {
|
|
134
|
+
throw new Error('A verification group requires at most 20 identical source URLs.');
|
|
135
|
+
}
|
|
136
|
+
const shared = {};
|
|
137
|
+
options = { ...options, now: timestamp(options.now) };
|
|
138
|
+
const results = [];
|
|
139
|
+
for (const input of inputs) results.push(await verifyLink(input, { ...options, [sharedDocument]: shared }));
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function sha256(bytes) {
|
|
144
|
+
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))]
|
|
145
|
+
.map((value) => value.toString(16).padStart(2, '0')).join('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function timestamp(now) {
|
|
149
|
+
return new Date(typeof now === 'function' ? now() : now ?? Date.now()).toISOString();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function expectations(occurrences, expectedAnchor, expectedRel) {
|
|
153
|
+
const anchor = expectedAnchor === undefined || expectedAnchor === null ? null : String(expectedAnchor).replace(/\s+/gu, ' ').trim();
|
|
154
|
+
const rel = expectedRel === undefined || expectedRel === null ? null
|
|
155
|
+
: [...new Set((Array.isArray(expectedRel) ? expectedRel : String(expectedRel).split(/\s+/u)).map((item) => item.toLowerCase()).filter(Boolean))].sort();
|
|
156
|
+
return {
|
|
157
|
+
expectedAnchor: anchor,
|
|
158
|
+
expectedRel: rel,
|
|
159
|
+
anchorMatches: anchor === null ? null : occurrences.some((item) => item.anchor === anchor),
|
|
160
|
+
relMatches: rel === null ? null : occurrences.some((item) => [...item.rel].sort().join(' ') === rel.join(' ')),
|
|
161
|
+
satisfied: occurrences.some((item) => (anchor === null || item.anchor === anchor)
|
|
162
|
+
&& (rel === null || [...item.rel].sort().join(' ') === rel.join(' '))),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Verify a public page. publicFetchSafe asserts a transport-enforced public-only
|
|
168
|
+
* destination boundary; input screening alone cannot protect against DNS rebinding.
|
|
169
|
+
* The source page and robots.txt are the only fetched documents. No link is clicked.
|
|
170
|
+
*/
|
|
171
|
+
async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', expectedAnchor, expectedRel }, options = {}) {
|
|
172
|
+
const checkedAt = timestamp(options.now);
|
|
173
|
+
const result = {
|
|
174
|
+
state: 'unknown', reason: 'not_checked', sourceUrl, finalUrl: sourceUrl, targetUrl,
|
|
175
|
+
targetScope, httpStatus: null, checkedAt, occurrences: [], directives: pageDirectives([], ''),
|
|
176
|
+
redirects: [], evidence: { method: 'http_html', checkerVersion: CHECKER_VERSION, complete: false, fetchedAt: null },
|
|
177
|
+
};
|
|
178
|
+
const source = validatePublicUrl(sourceUrl);
|
|
179
|
+
const target = validatePublicUrl(targetUrl);
|
|
180
|
+
if (!source.valid || !target.valid) {
|
|
181
|
+
result.reason = `invalid_${!source.valid ? 'source' : 'target'}:${!source.valid ? source.reason : target.reason}`;
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
if (!['exact', 'domain', 'subdomain', 'path'].includes(targetScope)) {
|
|
185
|
+
result.reason = 'unsupported_target_scope';
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
result.sourceUrl = source.url;
|
|
189
|
+
result.finalUrl = source.url;
|
|
190
|
+
result.targetUrl = target.url;
|
|
191
|
+
if (options.publicFetchSafe !== true) {
|
|
192
|
+
result.reason = 'public_fetch_not_configured';
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
const timeoutMs = Math.min(Math.max(options.timeoutMs ?? 20_000, 1), 20_000);
|
|
197
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
198
|
+
try {
|
|
199
|
+
const shared = options[sharedDocument];
|
|
200
|
+
const fetchSource = async () => {
|
|
201
|
+
try {
|
|
202
|
+
const supplied = options[suppliedDocument];
|
|
203
|
+
if (supplied) Object.assign(result, structuredClone(supplied.metadata));
|
|
204
|
+
const document = supplied?.document ?? await publicFetcher(options, controller.signal).source(source.url, result);
|
|
205
|
+
return { document, metadata: structuredClone({ finalUrl: result.finalUrl, httpStatus: result.httpStatus, redirects: result.redirects, ...(result.robots ? { robots: result.robots } : {}), ...(result.robotsHistory ? { robotsHistory: result.robotsHistory } : {}), ...(result.sourceResponse ? { sourceResponse: result.sourceResponse } : {}) }) };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { error: controller.signal.aborted ? new VerificationError('timeout') : error, metadata: structuredClone({ finalUrl: result.finalUrl, httpStatus: result.httpStatus, redirects: result.redirects, ...(result.robots ? { robots: result.robots } : {}), ...(result.robotsHistory ? { robotsHistory: result.robotsHistory } : {}), ...(result.sourceResponse ? { sourceResponse: result.sourceResponse } : {}) }) };
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
const fetched = shared ? await (shared.fetch ??= fetchSource()) : await fetchSource();
|
|
211
|
+
Object.assign(result, structuredClone(fetched.metadata));
|
|
212
|
+
if (fetched.error) throw fetched.error;
|
|
213
|
+
const { response, body } = fetched.document;
|
|
214
|
+
result.evidence.fetchedAt = checkedAt;
|
|
215
|
+
if ([404, 410].includes(response.status)) {
|
|
216
|
+
result.state = 'source_unavailable';
|
|
217
|
+
result.reason = `source_http_${response.status}`;
|
|
218
|
+
result.evidence.complete = true;
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
result.evidence.bytes = body.bytes;
|
|
222
|
+
result.evidence.contentType = response.headers.get('content-type');
|
|
223
|
+
result.evidence.etag = response.headers.get('etag');
|
|
224
|
+
result.evidence.lastModified = response.headers.get('last-modified');
|
|
225
|
+
// Preserve a replayable private artifact even when classification remains unknown.
|
|
226
|
+
result.evidence.sha256 = await sha256(body.data);
|
|
227
|
+
if (options.includeHtml !== false) result.evidence.html = body.text;
|
|
228
|
+
if (!body.text.trim()) throw new VerificationError('empty_document');
|
|
229
|
+
let parsed;
|
|
230
|
+
try { parsed = shared ? (shared.parsed ??= parseDocument(body.text, result.finalUrl)) : parseDocument(body.text, result.finalUrl); }
|
|
231
|
+
catch (error) { throw new VerificationError(error.reason ?? 'html_parse_error'); }
|
|
232
|
+
const robotsHeader = response.headers.get('x-robots-tag');
|
|
233
|
+
if (robotsHeader?.length > 4096) throw new VerificationError('page_directive_too_long');
|
|
234
|
+
result.directives = pageDirectives(parsed.meta, robotsHeader);
|
|
235
|
+
if (parsed.incomplete) throw new VerificationError('incomplete_html', { parseErrors: parsed.parseErrors });
|
|
236
|
+
// Strong, recognizable access-wall evidence. Generic references to captchas in
|
|
237
|
+
// an article are deliberately insufficient to classify a document as blocked.
|
|
238
|
+
if (/^(?:just a moment(?:\.\.\.)?|access denied|attention required!?\s*(?:\|\s*cloudflare)?|verify (?:you are|you're) human)$/iu.test(parsed.title)
|
|
239
|
+
|| parsed.hasChallengeScript) {
|
|
240
|
+
throw new VerificationError('access_challenge');
|
|
241
|
+
}
|
|
242
|
+
// A URL that REDIRECTS into the placement's scope is not a page that links to the target:
|
|
243
|
+
// following it lands on the destination, and parsing that document counts the destination's
|
|
244
|
+
// own navigation as placements. Measured on real data: two shortened links each reported
|
|
245
|
+
// `present` with **120 occurrences**, every one of them an internal link on the target's own
|
|
246
|
+
// homepage, anchored "Skip to main content". The one legitimate same-scope redirect is the
|
|
247
|
+
// source page canonicalizing ITSELF — same path (trailing slash, stripped tracking query)
|
|
248
|
+
// on the same host or a parent/child host of it (apex/www, blog to apex) — because then the
|
|
249
|
+
// destination's links ARE the source page's links. A redirect onto a DIFFERENT page inside
|
|
250
|
+
// the scope is not that, even when the whole site is the customer's own: DP-0020's
|
|
251
|
+
// `internal` kind made that case reachable (a blog post whose URL now bounces to the guide
|
|
252
|
+
// must not verify as a placement carried by the guide's own nav), so the carve-out keys on
|
|
253
|
+
// same-page canonicalization rather than on where the source started. One rule set for
|
|
254
|
+
// internal and external; the moved-page case reports `unknown` naming the redirect, and the
|
|
255
|
+
// customer points the ledger at the new URL.
|
|
256
|
+
const canonicalPage = (url) => {
|
|
257
|
+
const { hostname, pathname } = new URL(url);
|
|
258
|
+
return { host: hostname.toLowerCase().replace(/\.$/, ''),
|
|
259
|
+
path: pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname };
|
|
260
|
+
};
|
|
261
|
+
const sameSite = (a, b) => a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
|
|
262
|
+
const from = canonicalPage(source.url);
|
|
263
|
+
const to = canonicalPage(result.finalUrl);
|
|
264
|
+
const samePage = from.path === to.path && sameSite(from.host, to.host);
|
|
265
|
+
if (result.redirects.length && !samePage && matchesTarget(result.finalUrl, target.url, targetScope)) {
|
|
266
|
+
throw new VerificationError('source_redirects_to_target');
|
|
267
|
+
}
|
|
268
|
+
result.occurrences = parsed.links.filter((link) => matchesTarget(link.targetUrl, target.url, targetScope));
|
|
269
|
+
if (new TextEncoder().encode(JSON.stringify(result.occurrences)).byteLength > 96 * 1024) throw new VerificationError('evidence_too_large');
|
|
270
|
+
result.expectations = expectations(result.occurrences, expectedAnchor, expectedRel);
|
|
271
|
+
// Meta refresh and a password form are access/navigation evidence, not proof of
|
|
272
|
+
// absence. A server-rendered occurrence can still be positively observed.
|
|
273
|
+
if (!result.occurrences.length && parsed.meta.some((item) => item.name === 'refresh')) {
|
|
274
|
+
throw new VerificationError('navigation_requires_browser');
|
|
275
|
+
}
|
|
276
|
+
const readiness = assessReadiness({ html: body.text, parsed, targetUrl: target.url });
|
|
277
|
+
result.evidence.readiness = readiness;
|
|
278
|
+
if (!result.occurrences.length && readiness.possibleLoginWall) {
|
|
279
|
+
throw new VerificationError('possible_login_wall');
|
|
280
|
+
}
|
|
281
|
+
if (!result.occurrences.length && (readiness.requiresRender
|
|
282
|
+
|| (parsed.hasScript && !parsed.links.length && parsed.visibleText.length < 80))) {
|
|
283
|
+
throw new VerificationError('possible_render_required');
|
|
284
|
+
}
|
|
285
|
+
result.state = result.occurrences.length ? 'present' : 'absent';
|
|
286
|
+
result.reason = result.state === 'present' ? 'link_found' : 'no_matching_link_in_complete_html';
|
|
287
|
+
result.evidence.complete = true;
|
|
288
|
+
result.evidence.baseUrl = parsed.baseUrl;
|
|
289
|
+
result.evidence.rendered = false;
|
|
290
|
+
result.linkSignature = await sha256(new TextEncoder().encode(JSON.stringify({
|
|
291
|
+
source: result.finalUrl,
|
|
292
|
+
occurrences: result.occurrences.map(({ targetUrl: destination, anchor, rel }) => ({ targetUrl: destination, anchor, rel: [...rel].sort() })),
|
|
293
|
+
noindex: result.directives.noindex,
|
|
294
|
+
nofollow: result.directives.nofollow,
|
|
295
|
+
})));
|
|
296
|
+
result.warnings = ['Static HTML observation; JavaScript execution and visual visibility were not checked.'];
|
|
297
|
+
return result;
|
|
298
|
+
} catch (error) {
|
|
299
|
+
result.state = 'unknown';
|
|
300
|
+
result.occurrences = [];
|
|
301
|
+
result.reason = error instanceof VerificationError ? error.reason : controller.signal.aborted ? 'timeout' : 'verification_error';
|
|
302
|
+
// The same floor a destination check keeps. A publisher rate-limiting the SOURCE page is the
|
|
303
|
+
// more common case of the two, since a source is fetched on a cadence and a destination is not.
|
|
304
|
+
if (Number.isFinite(error?.retryAfterSeconds)) result.retryAfterSeconds = error.retryAfterSeconds;
|
|
305
|
+
if (error.parseErrors) result.evidence.parseErrors = error.parseErrors;
|
|
306
|
+
return result;
|
|
307
|
+
} finally {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
controller.abort();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The write-time check behind DP-0020's `internal` / `external` observation kinds.
|
|
2
|
+
//
|
|
3
|
+
// A receipt names a placement in exactly the ledger's terms, plus one new field: `kind`, the
|
|
4
|
+
// observation type. `internal` means both source and target are pages of the project's own
|
|
5
|
+
// declared site; `external` means the source belongs to someone else. The distinction is
|
|
6
|
+
// mechanical, not editorial — an actor does not get to choose its observation type, because
|
|
7
|
+
// mislabeling an external placement as internal would claim ownership the customer does not
|
|
8
|
+
// have and hide the placement from publisher-delay semantics.
|
|
9
|
+
//
|
|
10
|
+
// This module decides that question and nothing else. It never fetches: it runs at receipt
|
|
11
|
+
// WRITE time, so a receipt that could never be checked honestly (a staging host, a non-public
|
|
12
|
+
// host) is refused once, up front, instead of being checked into an eternal `unknown`.
|
|
13
|
+
import { validatePublicUrl } from './url.js';
|
|
14
|
+
|
|
15
|
+
// Same host grammar the fetch boundary enforces, so a declared site cannot smuggle in a
|
|
16
|
+
// spelling the verifier would later refuse. A bare label ("intranet") is not a site.
|
|
17
|
+
const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/u;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalizes project identity's declared site into a host set. Accepts a single origin/host or
|
|
21
|
+
* a list of them ("https://customer.com", "customer.com"). Both apex and www forms must be
|
|
22
|
+
* declared explicitly: membership is exact-host, because ownership is DECLARED, not inferred —
|
|
23
|
+
* this module no more infers a registrable domain than `matchesTarget` does a public suffix.
|
|
24
|
+
* That is also what refuses a staging subdomain the customer never declared.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeDeclaredSite(site) {
|
|
27
|
+
// `null`/`undefined`/blank is "no declared site" — a distinct answer from junk, because it is
|
|
28
|
+
// the state of every project that has not declared one yet, and the error must say that.
|
|
29
|
+
const entries = site === null || site === undefined || (typeof site === 'string' && !site.trim())
|
|
30
|
+
? [] : Array.isArray(site) ? site : [site];
|
|
31
|
+
const hosts = new Set();
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (typeof entry !== 'string' || !entry.trim()) return { valid: false, reason: 'invalid_declared_site_entry' };
|
|
34
|
+
const text = entry.trim().toLowerCase();
|
|
35
|
+
let host = text;
|
|
36
|
+
if (/^https?:\/\//u.test(text)) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try { parsed = new URL(text); } catch { return { valid: false, reason: 'invalid_declared_site_entry' }; }
|
|
39
|
+
// A credentials-bearing origin in project identity is a config mistake, and silently
|
|
40
|
+
// stripping it would bless a spelling the fetch boundary itself refuses everywhere.
|
|
41
|
+
if (parsed.username || parsed.password) return { valid: false, reason: 'invalid_declared_site_entry' };
|
|
42
|
+
host = parsed.hostname;
|
|
43
|
+
}
|
|
44
|
+
host = host.replace(/\.$/, '');
|
|
45
|
+
if (/^\d+\.\d+\.\d+\.\d+$/u.test(host)) return { valid: false, reason: 'invalid_declared_site_entry' };
|
|
46
|
+
if (!HOST_PATTERN.test(host) || host.length > 253) return { valid: false, reason: 'invalid_declared_site_entry' };
|
|
47
|
+
hosts.add(host);
|
|
48
|
+
}
|
|
49
|
+
if (!hosts.size) return { valid: false, reason: 'no_declared_site' };
|
|
50
|
+
return { valid: true, hosts: [...hosts].sort() };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Exact, case-insensitive host membership in the declared site. No suffix inference. */
|
|
54
|
+
export function withinDeclaredSite(url, hosts) {
|
|
55
|
+
try { return hosts.includes(new URL(url).hostname.toLowerCase().replace(/\.$/, '')); }
|
|
56
|
+
catch { return false; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse-level check of a receipt's observation kind against the declared site, per the
|
|
61
|
+
* DP-0020-T01 contract. Returns `{valid:true, kind, sourceInSite, targetInSite, declaredSite}`
|
|
62
|
+
* or `{valid:false, reason}` where the reason names the mismatch (the offending host, or the
|
|
63
|
+
* boundary's own refusal). Callers refuse the write on `valid:false`; the check is free and
|
|
64
|
+
* meters nothing.
|
|
65
|
+
*
|
|
66
|
+
* Refusals, all fixed by the contract:
|
|
67
|
+
* - `internal` whose source or target falls outside the declared site;
|
|
68
|
+
* - `external` whose source IS the declared site;
|
|
69
|
+
* - either kind naming a host the public fetch boundary would refuse (staging spellings on
|
|
70
|
+
* reserved suffixes, localhost, IPs) — refused here rather than checked into an unknown;
|
|
71
|
+
* - either kind without a declared site: with no declared site there is no fact of ownership,
|
|
72
|
+
* and the actor's label is the only thing left, which is exactly what must not decide it.
|
|
73
|
+
*/
|
|
74
|
+
export function checkObservationKind({ kind, sourceUrl, targetUrl }, site) {
|
|
75
|
+
const declared = normalizeDeclaredSite(site);
|
|
76
|
+
if (!declared.valid) return { valid: false, reason: declared.reason };
|
|
77
|
+
if (kind !== 'internal' && kind !== 'external') return { valid: false, reason: `unknown_kind:${String(kind)}` };
|
|
78
|
+
const source = validatePublicUrl(sourceUrl);
|
|
79
|
+
if (!source.valid) return { valid: false, reason: `invalid_source:${source.reason}` };
|
|
80
|
+
const target = validatePublicUrl(targetUrl);
|
|
81
|
+
if (!target.valid) return { valid: false, reason: `invalid_target:${target.reason}` };
|
|
82
|
+
const sourceInSite = withinDeclaredSite(source.url, declared.hosts);
|
|
83
|
+
const targetInSite = withinDeclaredSite(target.url, declared.hosts);
|
|
84
|
+
if (kind === 'internal') {
|
|
85
|
+
if (!sourceInSite) return { valid: false, reason: `internal_source_outside_declared_site:${source.hostname}` };
|
|
86
|
+
if (!targetInSite) return { valid: false, reason: `internal_target_outside_declared_site:${target.hostname}` };
|
|
87
|
+
} else if (sourceInSite) {
|
|
88
|
+
return { valid: false, reason: `external_source_on_declared_site:${source.hostname}` };
|
|
89
|
+
}
|
|
90
|
+
return { valid: true, kind, sourceInSite, targetInSite, declaredSite: declared.hosts };
|
|
91
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { parse, defaultTreeAdapter } from 'parse5';
|
|
2
|
+
|
|
3
|
+
const INERT = new Set(['script', 'style', 'template', 'noscript']);
|
|
4
|
+
const BLOCKS = new Set(['p', 'li', 'article', 'section', 'div', 'td', 'dd', 'dt', 'blockquote']);
|
|
5
|
+
// Where a browser puts a visual break. Used to decide whether two runs of text are separate words.
|
|
6
|
+
// Everything NOT in here is treated as phrasing content — `span`, `b`, `a`, `em` — because a
|
|
7
|
+
// browser renders `<span>Read</span><span>more</span>` as one word and so must we.
|
|
8
|
+
const TEXT_BLOCKS = new Set([...BLOCKS, 'body', 'main', 'header', 'footer', 'nav', 'aside',
|
|
9
|
+
'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'dl', 'table', 'tr',
|
|
10
|
+
'th', 'thead', 'tbody', 'tfoot', 'caption', 'pre', 'form', 'fieldset', 'details', 'summary',
|
|
11
|
+
'address', 'hr', 'option', 'legend']);
|
|
12
|
+
const EOF_ERRORS = new Set([
|
|
13
|
+
'eof-before-tag-name', 'eof-in-tag', 'eof-in-comment', 'eof-in-doctype',
|
|
14
|
+
'eof-in-element-that-can-contain-only-text', 'eof-in-script-html-comment-like-text',
|
|
15
|
+
]);
|
|
16
|
+
const attr = (node, name) => node.attrs?.find((value) => value.name === name)?.value;
|
|
17
|
+
const normalize = (text) => text.replace(/\s+/gu, ' ').trim();
|
|
18
|
+
|
|
19
|
+
function* walk(root, includeInert = false) {
|
|
20
|
+
const stack = [root];
|
|
21
|
+
while (stack.length) {
|
|
22
|
+
const node = stack.pop();
|
|
23
|
+
if (node.nodeName === '#comment' || (!includeInert && INERT.has(node.tagName))) continue;
|
|
24
|
+
yield node;
|
|
25
|
+
const children = node.childNodes ?? [];
|
|
26
|
+
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The nearest ancestor a browser would break a line at, stopping at the subtree we were given. */
|
|
31
|
+
function blockAncestor(node, root) {
|
|
32
|
+
let cursor = node.parentNode;
|
|
33
|
+
while (cursor && cursor !== root) {
|
|
34
|
+
if (cursor.tagName && TEXT_BLOCKS.has(cursor.tagName)) return cursor;
|
|
35
|
+
cursor = cursor.parentNode;
|
|
36
|
+
}
|
|
37
|
+
return root;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Text as a reader sees it, with a word boundary exactly where a browser draws one.
|
|
42
|
+
*
|
|
43
|
+
* **Two runs of text are separate words when they sit in different BLOCKS, not merely in different
|
|
44
|
+
* elements.** Without any separator, `<p>See settledestate.com</p><a>Guide</a>` read as
|
|
45
|
+
* `settledestate.comGuide`, which put a joined string into every occurrence context a customer
|
|
46
|
+
* sees. With a separator at every element boundary, `<a><span>Read</span><span>more</span></a>`
|
|
47
|
+
* read as `Read more` — and a browser renders that as one word, so anchor text and therefore the
|
|
48
|
+
* link signature would have disagreed with the page and emitted spurious change events.
|
|
49
|
+
*
|
|
50
|
+
* `normalize` collapses the runs this adds, so over-separating within a block is free and
|
|
51
|
+
* under-separating across one is not.
|
|
52
|
+
*/
|
|
53
|
+
function textContent(node, limit = 2048) {
|
|
54
|
+
let text = '';
|
|
55
|
+
let lastBlock = null;
|
|
56
|
+
const emit = (value, item) => {
|
|
57
|
+
const block = blockAncestor(item, node);
|
|
58
|
+
if (lastBlock !== null && block !== lastBlock) text += ' ';
|
|
59
|
+
text += value;
|
|
60
|
+
lastBlock = block;
|
|
61
|
+
};
|
|
62
|
+
for (const item of walk(node)) {
|
|
63
|
+
// A line break is a word boundary wherever it appears, block or not.
|
|
64
|
+
if (item.tagName === 'br') { text += ' '; lastBlock = null; }
|
|
65
|
+
if (item.nodeName === '#text') emit(item.value, item);
|
|
66
|
+
if (item.tagName === 'img') { const alt = attr(item, 'alt'); if (alt) emit(alt, item); }
|
|
67
|
+
if (text.length > limit * 2) break;
|
|
68
|
+
}
|
|
69
|
+
return normalize(text).slice(0, limit);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function contextFor(node) {
|
|
73
|
+
let context = node.parentNode ?? node;
|
|
74
|
+
for (let depth = 0; context.parentNode && depth < 4; depth++) {
|
|
75
|
+
if (BLOCKS.has(context.tagName) || context.tagName === 'body') break;
|
|
76
|
+
context = context.parentNode;
|
|
77
|
+
}
|
|
78
|
+
return textContent(context, 640);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseDocument(html, pageUrl) {
|
|
82
|
+
if (typeof html !== 'string') throw new TypeError('HTML must be a string');
|
|
83
|
+
const errors = [];
|
|
84
|
+
let elements = 0;
|
|
85
|
+
let anchorElements = 0;
|
|
86
|
+
const parserStart = performance.now();
|
|
87
|
+
const treeAdapter = {
|
|
88
|
+
...defaultTreeAdapter,
|
|
89
|
+
createElement(tagName, namespaceURI, attrs) {
|
|
90
|
+
// Bound parser expansion and evidence output independently of body bytes.
|
|
91
|
+
if (++elements > 25_000 || attrs.length > 256 || performance.now() - parserStart > 500) {
|
|
92
|
+
const error = new Error('document_too_complex');
|
|
93
|
+
error.reason = 'document_too_complex';
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if ((tagName === 'a' || tagName === 'area') && ++anchorElements > 1000) {
|
|
97
|
+
const error = new Error('too_many_link_occurrences');
|
|
98
|
+
error.reason = 'too_many_link_occurrences';
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
return defaultTreeAdapter.createElement(tagName, namespaceURI, attrs);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const document = parse(html, {
|
|
105
|
+
treeAdapter,
|
|
106
|
+
scriptingEnabled: true,
|
|
107
|
+
sourceCodeLocationInfo: true,
|
|
108
|
+
onParseError: ({ code }) => { if (EOF_ERRORS.has(code) && errors.length < 8) errors.push(code); },
|
|
109
|
+
});
|
|
110
|
+
let baseUrl = pageUrl;
|
|
111
|
+
let baseFound = false;
|
|
112
|
+
let canonical = null;
|
|
113
|
+
const links = [];
|
|
114
|
+
const meta = [];
|
|
115
|
+
let title = '';
|
|
116
|
+
let hasScript = false;
|
|
117
|
+
let hasPasswordInput = false;
|
|
118
|
+
let hasChallengeScript = false;
|
|
119
|
+
let visibleText = '';
|
|
120
|
+
const nodes = [...walk(document)];
|
|
121
|
+
for (const node of walk(document, true)) {
|
|
122
|
+
if (node.tagName === 'script') {
|
|
123
|
+
hasScript = true;
|
|
124
|
+
if ((attr(node, 'src') ?? '').includes('/cdn-cgi/challenge-platform/')) hasChallengeScript = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const node of nodes) {
|
|
128
|
+
if (node.tagName === 'body') visibleText = textContent(node, 160);
|
|
129
|
+
if (node.tagName === 'input' && (attr(node, 'type') ?? '').toLowerCase() === 'password') hasPasswordInput = true;
|
|
130
|
+
if (node.tagName === 'base' && !baseFound && attr(node, 'href') !== undefined) {
|
|
131
|
+
// Browsers use only the first base element with an href, even if invalid.
|
|
132
|
+
baseFound = true;
|
|
133
|
+
if (attr(node, 'href').length > 8192) {
|
|
134
|
+
const error = new Error('base_url_too_long');
|
|
135
|
+
error.reason = 'base_url_too_long';
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const base = new URL(attr(node, 'href'), pageUrl);
|
|
140
|
+
if (['http:', 'https:'].includes(base.protocol)) baseUrl = base.href;
|
|
141
|
+
} catch { /* Browser-compatible fallback to the document URL. */ }
|
|
142
|
+
}
|
|
143
|
+
if (node.tagName === 'meta') {
|
|
144
|
+
const name = (attr(node, 'name') ?? '').toLowerCase();
|
|
145
|
+
if ((['robots', 'googlebot', 'bingbot', 'linktrailbot'].includes(name)
|
|
146
|
+
|| (attr(node, 'http-equiv') ?? '').toLowerCase() === 'refresh')
|
|
147
|
+
&& (attr(node, 'content') ?? '').length > 2048) {
|
|
148
|
+
const error = new Error('page_directive_too_long');
|
|
149
|
+
error.reason = 'page_directive_too_long';
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
if (['robots', 'googlebot', 'bingbot', 'linktrailbot'].includes(name)) {
|
|
153
|
+
meta.push({ name, content: (attr(node, 'content') ?? '').slice(0, 2048) });
|
|
154
|
+
}
|
|
155
|
+
if ((attr(node, 'http-equiv') ?? '').toLowerCase() === 'refresh') {
|
|
156
|
+
meta.push({ name: 'refresh', content: (attr(node, 'content') ?? '').slice(0, 2048) });
|
|
157
|
+
}
|
|
158
|
+
if (meta.length > 16) {
|
|
159
|
+
const error = new Error('too_many_page_directives');
|
|
160
|
+
error.reason = 'too_many_page_directives';
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (node.tagName === 'title' && !title) title = textContent(node, 256);
|
|
165
|
+
// Browsers honour the first canonical link element only.
|
|
166
|
+
if (node.tagName === 'link' && canonical === null
|
|
167
|
+
&& (attr(node, 'rel') ?? '').toLowerCase().split(/\s+/u).includes('canonical')) {
|
|
168
|
+
const href = attr(node, 'href') ?? '';
|
|
169
|
+
if (href && href.length <= 8192) {
|
|
170
|
+
try {
|
|
171
|
+
const resolved = new URL(href, baseUrl);
|
|
172
|
+
if (['http:', 'https:'].includes(resolved.protocol)) canonical = resolved.href;
|
|
173
|
+
} catch { /* An unparseable canonical is no canonical, exactly as a browser treats it. */ }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const node of nodes) {
|
|
178
|
+
if (!['a', 'area'].includes(node.tagName)) continue;
|
|
179
|
+
const href = attr(node, 'href');
|
|
180
|
+
if (href === undefined) continue;
|
|
181
|
+
if (href.length > 8192) {
|
|
182
|
+
const error = new Error('link_url_too_long');
|
|
183
|
+
error.reason = 'link_url_too_long';
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
let target;
|
|
187
|
+
try { target = new URL(href, baseUrl); } catch { continue; }
|
|
188
|
+
if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password) continue;
|
|
189
|
+
const location = node.sourceCodeLocation;
|
|
190
|
+
links.push({
|
|
191
|
+
href,
|
|
192
|
+
targetUrl: target.href,
|
|
193
|
+
anchor: node.tagName === 'area' ? normalize(attr(node, 'alt') ?? '') : textContent(node, 1024),
|
|
194
|
+
rel: [...new Set((attr(node, 'rel') ?? '').toLowerCase().split(/\s+/u).filter(Boolean))],
|
|
195
|
+
context: contextFor(node),
|
|
196
|
+
locator: location ? { line: location.startLine, column: location.startCol, offset: location.startOffset } : null,
|
|
197
|
+
visibility: 'not_rendered',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { links, meta, title, baseUrl, canonical, hasScript, hasPasswordInput, hasChallengeScript, visibleText, incomplete: errors.length > 0, parseErrors: errors };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The page's text as a READER sees it, reusing the same walk the parser already uses.
|
|
205
|
+
*
|
|
206
|
+
* `walk` already skips `script`, `style`, `template`, `noscript` and comments, which is the
|
|
207
|
+
* property that matters: a brand name inside a JSON payload or an analytics config is not a
|
|
208
|
+
* mention anyone read. `parseDocument` caps its own copy at 160 characters because it only needs
|
|
209
|
+
* to know whether a page is an empty shell; a mention scan needs the whole document, so it asks
|
|
210
|
+
* for it explicitly rather than raising a cap every check would pay for.
|
|
211
|
+
*
|
|
212
|
+
* Takes no base URL, deliberately: text is not resolved against anything, and a parameter that
|
|
213
|
+
* looks like it matters and does not is how a caller comes to believe the output is absolute.
|
|
214
|
+
*/
|
|
215
|
+
export function pageText(html, limit = 512 * 1024) {
|
|
216
|
+
const document = parse(String(html), { sourceCodeLocationInfo: false, treeAdapter: defaultTreeAdapter });
|
|
217
|
+
for (const node of walk(document)) {
|
|
218
|
+
if (node.tagName === 'body') return textContent(node, limit);
|
|
219
|
+
}
|
|
220
|
+
return textContent(document, limit);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** All HTTP(S) anchor/area occurrences in static HTML; never claims rendered visibility. */
|
|
224
|
+
export function parseLinks(html, baseUrl) {
|
|
225
|
+
return parseDocument(html, new URL(baseUrl).href).links;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function pageDirectives(meta, header) {
|
|
229
|
+
const xRobotsTag = header ? [header] : [];
|
|
230
|
+
// Preserve targeted header directives without incorrectly applying them to all bots.
|
|
231
|
+
const genericHeader = header && !/(?:^|,)\s*[\w-]+\s*:/u.test(header) ? header : '';
|
|
232
|
+
const generic = [genericHeader, ...meta.filter((item) => item.name === 'robots').map((item) => item.content)]
|
|
233
|
+
.join(',').toLowerCase().split(/[\s,]+/u);
|
|
234
|
+
return {
|
|
235
|
+
meta,
|
|
236
|
+
xRobotsTag,
|
|
237
|
+
noindex: generic.includes('noindex') || generic.includes('none'),
|
|
238
|
+
nofollow: generic.includes('nofollow') || generic.includes('none'),
|
|
239
|
+
indexingStatus: 'not_checked',
|
|
240
|
+
};
|
|
241
|
+
}
|