agentlinkops 0.6.13 → 0.6.16
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 +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +25 -2
- package/PACK-ARTIFACT.json +16 -0
- package/PACKS.json +591 -0
- package/README.md +34 -48
- package/cli/adapters/suppliers.js +1 -1
- package/cli/agent-setup.js +101 -17
- package/cli/campaign-bridge.js +325 -0
- package/cli/campaign-outcomes.js +107 -0
- package/cli/catalog.js +45702 -18674
- package/cli/check.js +40 -10
- package/cli/client.js +2 -1
- package/cli/commands.js +17 -8
- package/cli/connection.js +92 -18
- package/cli/contact-adoption.js +91 -0
- package/cli/context.js +13 -2
- package/cli/doctor.js +15 -4
- package/cli/import.js +7 -1
- package/cli/key-store.js +115 -0
- package/cli/ledger.js +1 -0
- package/cli/local-result.js +41 -6
- package/cli/main.js +51 -12
- package/cli/mcp-client.js +2 -2
- package/cli/migration.js +308 -0
- package/cli/prompt.js +25 -0
- package/cli/public-http.js +168 -0
- package/cli/state.js +14 -0
- package/cli/sync.js +1 -1
- package/package.json +7 -8
- package/references/citation-panels.md +2 -0
- package/references/cli.md +2 -2
- package/references/disavow.md +1 -1
- package/references/discovery-handoff.md +1 -1
- package/references/local-crm-draft-handoff.md +1 -1
- package/references/portable-ledger-handoff.md +11 -10
- package/references/recipes/catalog.json +263 -7
- package/references/recipes/command-notes.md +5 -3
- package/references/recipes/internal-link-improvement.md +25 -0
- package/references/recipes/placement-reconciliation.md +53 -1
- package/scripts/portable-handoff.py +17 -4
- package/shared/admission-contract.js +3 -2
- package/shared/admission-report-contract.js +3 -2
- package/shared/analytics-history-contract.js +15 -0
- package/shared/campaign-outcome-contract.js +12 -0
- package/shared/campaign-prospect-contract.js +23 -0
- package/shared/classification-contract.js +54 -0
- package/shared/contact-record-contract.js +27 -0
- package/shared/disavow-contract.js +37 -9
- package/shared/index-observation-contract.js +5 -4
- package/shared/internal-links-contract.js +143 -0
- package/shared/link-category-contract.js +35 -0
- package/shared/link-category.js +138 -0
- package/shared/link-labels.js +26 -0
- package/shared/link-summary-contract.js +17 -0
- package/shared/observation-event.js +1 -1
- package/shared/onboarding.js +20 -0
- package/shared/response-contract.js +38 -5
- package/shared/search-connection-contract.js +2 -1
- package/shared/table-page-contract.js +13 -0
- package/skills/agentlinkops-assets/SKILL-DEPS.json +290 -0
- package/skills/agentlinkops-assets/SKILL.md +3 -1
- package/skills/agentlinkops-assets/agents/openai.yaml +1 -1
- package/skills/agentlinkops-assets/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-assets/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-assets/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-assets/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-assets/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-assets/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-assets/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-assets/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-campaigns/SKILL-DEPS.json +336 -0
- package/skills/agentlinkops-campaigns/SKILL.md +7 -5
- package/skills/agentlinkops-campaigns/agents/openai.yaml +1 -1
- package/skills/agentlinkops-campaigns/references/first-campaign.md +31 -0
- package/skills/agentlinkops-campaigns/references/mailbox-campaign.md +46 -0
- package/skills/agentlinkops-campaigns/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-campaigns/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-campaigns/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-campaigns/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-campaigns/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-connect/SKILL-DEPS.json +104 -0
- package/skills/agentlinkops-connect/SKILL.md +2 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +1 -1
- package/skills/agentlinkops-connect/references/agentlinkops.md +164 -62
- package/skills/agentlinkops-connect/references/placement-workflows.md +19 -0
- package/skills/agentlinkops-crm/SKILL-DEPS.json +381 -0
- package/skills/agentlinkops-crm/SKILL.md +26 -9
- package/skills/agentlinkops-crm/agents/openai.yaml +1 -1
- package/skills/agentlinkops-crm/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-crm/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-crm/references/portable/references/local-crm-draft-handoff.md +76 -0
- package/skills/agentlinkops-crm/references/portable/references/portable-ledger-handoff.md +51 -0
- package/skills/agentlinkops-crm/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-crm/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-crm/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-crm/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-crm/references/portable/scripts/outreach-crm-draft.py +121 -0
- package/skills/agentlinkops-crm/references/portable/scripts/portable-handoff.py +197 -0
- package/skills/agentlinkops-crm/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-crm/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/SKILL-DEPS.json +301 -0
- package/skills/agentlinkops-discovery/SKILL.md +4 -2
- package/skills/agentlinkops-discovery/references/portable/references/cli.md +220 -0
- package/skills/agentlinkops-discovery/references/portable/references/disavow.md +73 -0
- package/skills/agentlinkops-discovery/references/portable/references/discovery-handoff.md +32 -0
- package/skills/agentlinkops-discovery/references/portable/references/reports.md +53 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/agentlinkops.py +676 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/disavow.py +207 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/001_crm.sql +45 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/002_cloud_events.sql +13 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/003_disavow.sql +20 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/migrations/004_placement_costs.sql +2 -0
- package/skills/agentlinkops-discovery/references/portable/scripts/report.py +113 -0
- package/skills/agentlinkops-discovery/references/portable/templates/campaigns.v1.json +65 -0
- package/skills/agentlinkops-discovery/references/saved-backlink-workflows.md +23 -0
- package/src/competitors/contract.js +17 -1
- package/src/context/site-profile.js +34 -121
- package/src/corpus/acquire.js +68 -32
- package/src/corpus/extract.js +147 -0
- package/src/corpus/frontier.js +151 -0
- package/src/discovery/contract.js +8 -0
- package/src/discovery/import.js +13 -1
- package/src/mentions.js +193 -0
- package/src/page-acquisition/acquire.js +50 -0
- package/src/page-acquisition/bytes.js +138 -0
- package/src/page-acquisition/capture.js +11 -0
- package/src/page-acquisition/contracts.js +67 -0
- package/src/page-acquisition/documents.js +166 -0
- package/src/page-acquisition/http.js +307 -0
- package/src/page-acquisition/policy.js +20 -0
- package/src/page-acquisition/recovery.js +164 -0
- package/src/page-acquisition/render-failure.js +82 -0
- package/src/page-extraction/html.js +359 -0
- package/src/page-extraction/index.js +30 -0
- package/src/page-extraction/metadata.js +16 -0
- package/src/response-shaping.js +4 -3
- package/src/tool-views.js +22 -12
- package/src/verifier/fetch.js +4 -339
- package/src/verifier/index.js +81 -23
- package/src/verifier/page-quality.js +14 -0
- package/src/verifier/parser.js +2 -241
- package/src/verifier/readiness.js +64 -13
- package/src/verifier/render-evidence.js +25 -2
- package/src/verifier/target.js +201 -0
- package/src/verifier/transition.js +18 -3
- package/src/verifier/url.js +31 -0
- package/src/watch-crawlability.js +144 -0
- package/skills/content-defingerprinting/SKILL.md +0 -29
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -102
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -26
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -52
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -21
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -96
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -432
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -86
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -460
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -651
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -27
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -212
- package/skills/internal-linking-optimizer/SKILL.md +0 -56
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -76
- package/skills/pre-publish-review/SKILL.md +0 -179
- package/skills/source-cited-content-builder/SKILL.md +0 -56
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -19
- package/skills/source-cited-humanizer/SKILL.md +0 -20
package/src/verifier/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { validatePublicUrl, matchesTarget } from './url.js';
|
|
1
|
+
import { validatePublicUrl, matchesTarget, hasRedirectWrapperToTarget } from './url.js';
|
|
2
2
|
import { parseDocument, pageDirectives } from './parser.js';
|
|
3
3
|
import { publicFetcher, VerificationError } from './fetch.js';
|
|
4
|
-
import {
|
|
4
|
+
import {withAbort} from '../page-acquisition/bytes.js';
|
|
5
|
+
import { analyzeReadiness, isAccessChallenge } from './readiness.js';
|
|
5
6
|
import { nativeRenderEvidence } from './render-evidence.js';
|
|
7
|
+
import {capturedDocument} from '../page-acquisition/capture.js';
|
|
8
|
+
import {readRenderFailure} from '../page-acquisition/render-failure.js';
|
|
6
9
|
|
|
7
10
|
export { validatePublicUrl } from './url.js';
|
|
8
11
|
export { parseLinks } from './parser.js';
|
|
@@ -10,19 +13,32 @@ export { transitionState } from './transition.js';
|
|
|
10
13
|
export { checkObservationKind, normalizeDeclaredSite, withinDeclaredSite } from './observation-kind.js';
|
|
11
14
|
|
|
12
15
|
export const CHECKER_VERSION = '4';
|
|
16
|
+
export function unknownLinkObservation({sourceUrl,targetUrl,targetScope='exact'},{now,reason='timeout'}={}){
|
|
17
|
+
return {state:'unknown',reason,sourceUrl,finalUrl:sourceUrl,targetUrl,targetScope,httpStatus:null,checkedAt:timestamp(now),occurrences:[],
|
|
18
|
+
directives:pageDirectives([],''),redirects:[],evidence:{method:'http_html',checkerVersion:CHECKER_VERSION,complete:false,fetchedAt:null}};
|
|
19
|
+
}
|
|
13
20
|
const sharedDocument = Symbol('sharedDocument');
|
|
14
21
|
const suppliedDocument = Symbol('suppliedDocument');
|
|
22
|
+
const suppliedInitial = Symbol('suppliedInitial');
|
|
23
|
+
const forceRender = Symbol('forceRender');
|
|
15
24
|
|
|
16
25
|
const RENDER_REASONS = new Set(['possible_render_required', 'navigation_requires_browser']);
|
|
17
26
|
|
|
18
27
|
/** Optional adaptive lane. The default remains one bounded HTTP observation. */
|
|
19
28
|
export async function verifyLink(input, options = {}) {
|
|
20
|
-
const initial = await verifyHttpLink(input, options);
|
|
21
|
-
const eligible = RENDER_REASONS.has(initial.reason)
|
|
29
|
+
const initial = options[suppliedInitial] ?? await verifyHttpLink(input, options);
|
|
30
|
+
const eligible = options[forceRender] === true || RENDER_REASONS.has(initial.reason)
|
|
22
31
|
|| (options.compareRendered === true && ['absent', 'present'].includes(initial.state));
|
|
23
32
|
if (!eligible) return initial;
|
|
24
33
|
initial.evidence.renderEligibility = RENDER_REASONS.has(initial.reason) ? 'required' : 'comparison';
|
|
25
|
-
if (
|
|
34
|
+
if (initial.robotsHistory?.some(row=>row?.overrideUsed===true)) {
|
|
35
|
+
initial.evidence.renderAttempt = { attempted:false, reason:'monitor_robots_override_fetch_limit' };
|
|
36
|
+
return initial;
|
|
37
|
+
}
|
|
38
|
+
if (!options.renderPage) {
|
|
39
|
+
initial.evidence.renderAttempt={attempted:false,reason:'render_not_configured'};
|
|
40
|
+
return initial;
|
|
41
|
+
}
|
|
26
42
|
if (options.renderPage.publicFetchSafe !== true || options.renderPage.requiresNavigationAuthorization !== true) {
|
|
27
43
|
initial.evidence.renderAttempt = { attempted: false, reason: 'render_public_fetch_not_configured' };
|
|
28
44
|
return initial;
|
|
@@ -30,26 +46,32 @@ export async function verifyLink(input, options = {}) {
|
|
|
30
46
|
// Grouping shares only acquisition. Target scope, expectations and comparison remain independent.
|
|
31
47
|
const shared = options[sharedDocument];
|
|
32
48
|
const render = async () => {
|
|
49
|
+
if(options.signal?.aborted||(Number.isFinite(options.deadlineAt)&&options.deadlineAt<=Date.now()))return {error:'render_timeout'};
|
|
33
50
|
const controller = new AbortController();
|
|
34
|
-
const
|
|
51
|
+
const ownerAbort=()=>controller.abort();
|
|
52
|
+
options.signal?.addEventListener('abort',ownerAbort,{once:true});
|
|
53
|
+
const timeout = Math.min(Math.max(options.renderTimeoutMs ?? 20_000, 1), 20_000,
|
|
54
|
+
options.deadlineAt ? Math.max(1,options.deadlineAt-Date.now()) : 20_000);
|
|
35
55
|
let timer;
|
|
36
56
|
try {
|
|
37
57
|
const deadline = new Promise((_, reject) => {
|
|
38
58
|
timer = setTimeout(() => { controller.abort(); reject(new VerificationError('render_timeout')); }, timeout);
|
|
39
59
|
});
|
|
40
60
|
return { value: await Promise.race([
|
|
41
|
-
Promise.resolve().then(() => options.renderPage({ sourceUrl: initial.sourceUrl }, { signal: controller.signal })),
|
|
61
|
+
withAbort(Promise.resolve().then(() => options.renderPage({ sourceUrl: initial.sourceUrl }, { signal: controller.signal,deadlineAt:options.deadlineAt })),controller.signal),
|
|
42
62
|
deadline,
|
|
43
63
|
]) };
|
|
44
64
|
} catch (error) {
|
|
45
65
|
// Provider exception messages can contain credentials or remote page text.
|
|
66
|
+
const failure = readRenderFailure(error);
|
|
46
67
|
return { error: controller.signal.aborted ? 'render_timeout' : 'render_failed',
|
|
68
|
+
...(failure ? {failure} : {}),
|
|
47
69
|
...(Number.isFinite(error?.retryAfterSeconds) && error.retryAfterSeconds >= 0 ? {retryAfterSeconds:Math.min(86400,Math.ceil(error.retryAfterSeconds))} : {}) };
|
|
48
|
-
} finally { clearTimeout(timer); controller.abort(); }
|
|
70
|
+
} finally { clearTimeout(timer); controller.abort(); options.signal?.removeEventListener('abort', ownerAbort); }
|
|
49
71
|
};
|
|
50
72
|
const acquired = shared ? await (shared.render ??= render()) : await render();
|
|
51
|
-
const fail = (reason) => {
|
|
52
|
-
initial.evidence.renderAttempt = { attempted: true, reason };
|
|
73
|
+
const fail = (reason, failure) => {
|
|
74
|
+
initial.evidence.renderAttempt = { attempted: true, reason, ...(failure ? {failure} : {}) };
|
|
53
75
|
// A failed comparison cannot overrule an already observed anchor. Static absence,
|
|
54
76
|
// however, cannot be promoted when the requested browser comparison did not finish.
|
|
55
77
|
if (initial.state === 'absent') {
|
|
@@ -60,7 +82,7 @@ export async function verifyLink(input, options = {}) {
|
|
|
60
82
|
};
|
|
61
83
|
if (acquired.error) {
|
|
62
84
|
if (acquired.retryAfterSeconds !== undefined) initial.retryAfterSeconds = acquired.retryAfterSeconds;
|
|
63
|
-
return fail(acquired.error);
|
|
85
|
+
return fail(acquired.error,acquired.failure);
|
|
64
86
|
}
|
|
65
87
|
const doc = acquired.value;
|
|
66
88
|
if (!doc || typeof doc.html !== 'string' || !validatePublicUrl(doc.finalUrl).valid
|
|
@@ -88,7 +110,7 @@ export async function verifyLink(input, options = {}) {
|
|
|
88
110
|
const data = new TextEncoder().encode(doc.html);
|
|
89
111
|
if (data.length > Math.min(options.maxBytes ?? 2 * 1024 * 1024, 2 * 1024 * 1024)) return fail('render_body_too_large');
|
|
90
112
|
const rendered = await verifyHttpLink(input, {
|
|
91
|
-
...options, [sharedDocument]: undefined,
|
|
113
|
+
...options, [sharedDocument]: shared ? (shared.renderedDocument ??= {}) : undefined,
|
|
92
114
|
[suppliedDocument]: {
|
|
93
115
|
metadata: { finalUrl: doc.finalUrl, httpStatus: doc.httpStatus, redirects },
|
|
94
116
|
document: {
|
|
@@ -100,6 +122,9 @@ export async function verifyLink(input, options = {}) {
|
|
|
100
122
|
rendered.evidence.method = 'browser_html';
|
|
101
123
|
rendered.evidence.rendered = true;
|
|
102
124
|
rendered.evidence.ready = doc.ready;
|
|
125
|
+
rendered.evidence.capture=capturedDocument(rendered,{requestedUrl:initial.sourceUrl,representation:'rendered_dom',
|
|
126
|
+
transportId:options.renderPage.transportId??'qualified_browser',transportVersion:options.renderPage.transportVersion??'1',
|
|
127
|
+
qualified:options.renderPage.publicFetchSafe===true&&!!nativeEvidence,charset:'utf-8'});
|
|
103
128
|
if (nativeEvidence) {
|
|
104
129
|
rendered.evidence.browser = nativeEvidence;
|
|
105
130
|
if (nativeEvidence.retryAfterSeconds !== undefined && nativeEvidence.retryAfterSeconds !== null) rendered.retryAfterSeconds = nativeEvidence.retryAfterSeconds;
|
|
@@ -140,6 +165,15 @@ export async function verifyLinks(inputs, options = {}) {
|
|
|
140
165
|
return results;
|
|
141
166
|
}
|
|
142
167
|
|
|
168
|
+
/** Evaluate one admitted browser acquisition without fetching the static page again. */
|
|
169
|
+
export async function verifyRenderedLinks(inputs, initial, options = {}) {
|
|
170
|
+
if(!Array.isArray(initial)||initial.length!==inputs.length)throw new TypeError('Initial observations must bind the render inputs.');
|
|
171
|
+
const shared={};const results=[];
|
|
172
|
+
for(const [i,input] of inputs.entries())results.push(await verifyLink(input,{...options,
|
|
173
|
+
[sharedDocument]:shared,[suppliedInitial]:structuredClone(initial[i]),[forceRender]:true}));
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
async function sha256(bytes) {
|
|
144
178
|
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))]
|
|
145
179
|
.map((value) => value.toString(16).padStart(2, '0')).join('');
|
|
@@ -170,11 +204,7 @@ function expectations(occurrences, expectedAnchor, expectedRel) {
|
|
|
170
204
|
*/
|
|
171
205
|
async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', expectedAnchor, expectedRel }, options = {}) {
|
|
172
206
|
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
|
-
};
|
|
207
|
+
const result = unknownLinkObservation({sourceUrl,targetUrl,targetScope},{now:checkedAt,reason:'not_checked'});
|
|
178
208
|
const source = validatePublicUrl(sourceUrl);
|
|
179
209
|
const target = validatePublicUrl(targetUrl);
|
|
180
210
|
if (!source.valid || !target.valid) {
|
|
@@ -192,8 +222,14 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
192
222
|
result.reason = 'public_fetch_not_configured';
|
|
193
223
|
return result;
|
|
194
224
|
}
|
|
225
|
+
if (options.signal?.aborted || (Number.isFinite(options.deadlineAt) && options.deadlineAt <= Date.now())) {
|
|
226
|
+
result.reason = 'timeout'; return result;
|
|
227
|
+
}
|
|
195
228
|
const controller = new AbortController();
|
|
196
|
-
const
|
|
229
|
+
const ownerAbort = () => controller.abort();
|
|
230
|
+
options.signal?.addEventListener('abort', ownerAbort, { once: true });
|
|
231
|
+
const timeoutMs = Math.min(Math.max(Number.isFinite(options.timeoutMs) ? options.timeoutMs : 20_000, 1), 20_000,
|
|
232
|
+
options.deadlineAt ? Math.max(1,options.deadlineAt-Date.now()) : 20_000);
|
|
197
233
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
198
234
|
try {
|
|
199
235
|
const shared = options[sharedDocument];
|
|
@@ -211,6 +247,7 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
211
247
|
Object.assign(result, structuredClone(fetched.metadata));
|
|
212
248
|
if (fetched.error) throw fetched.error;
|
|
213
249
|
const { response, body } = fetched.document;
|
|
250
|
+
if (controller.signal.aborted || (Number.isFinite(options.deadlineAt) && Date.now() >= options.deadlineAt)) throw new VerificationError('timeout');
|
|
214
251
|
result.evidence.fetchedAt = checkedAt;
|
|
215
252
|
if ([404, 410].includes(response.status)) {
|
|
216
253
|
result.state = 'source_unavailable';
|
|
@@ -224,6 +261,10 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
224
261
|
result.evidence.lastModified = response.headers.get('last-modified');
|
|
225
262
|
// Preserve a replayable private artifact even when classification remains unknown.
|
|
226
263
|
result.evidence.sha256 = await sha256(body.data);
|
|
264
|
+
result.evidence.decodedSha256 = await sha256(new TextEncoder().encode(body.text));
|
|
265
|
+
result.evidence.capture=capturedDocument(result,{requestedUrl:source.url,transportId:options.captureTransport?.id??'public_http',
|
|
266
|
+
transportVersion:options.captureTransport?.version??'1',qualified:options.publicFetchSafe===true,
|
|
267
|
+
charset:/charset\s*=\s*["']?([^\s;"']+)/iu.exec(response.headers.get('content-type')??'')?.[1]??'utf-8'});
|
|
227
268
|
if (options.includeHtml !== false) result.evidence.html = body.text;
|
|
228
269
|
if (!body.text.trim()) throw new VerificationError('empty_document');
|
|
229
270
|
let parsed;
|
|
@@ -233,10 +274,11 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
233
274
|
if (robotsHeader?.length > 4096) throw new VerificationError('page_directive_too_long');
|
|
234
275
|
result.directives = pageDirectives(parsed.meta, robotsHeader);
|
|
235
276
|
if (parsed.incomplete) throw new VerificationError('incomplete_html', { parseErrors: parsed.parseErrors });
|
|
277
|
+
const { readiness, managedClientChallenge } = analyzeReadiness({ html: body.text, parsed, targetUrl: target.url });
|
|
278
|
+
result.evidence.readiness = readiness;
|
|
236
279
|
// Strong, recognizable access-wall evidence. Generic references to captchas in
|
|
237
280
|
// an article are deliberately insufficient to classify a document as blocked.
|
|
238
|
-
if (
|
|
239
|
-
|| parsed.hasChallengeScript) {
|
|
281
|
+
if (isAccessChallenge(parsed, { managedClientChallenge })) {
|
|
240
282
|
throw new VerificationError('access_challenge');
|
|
241
283
|
}
|
|
242
284
|
// A URL that REDIRECTS into the placement's scope is not a page that links to the target:
|
|
@@ -262,19 +304,31 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
262
304
|
const from = canonicalPage(source.url);
|
|
263
305
|
const to = canonicalPage(result.finalUrl);
|
|
264
306
|
const samePage = from.path === to.path && sameSite(from.host, to.host);
|
|
265
|
-
|
|
307
|
+
// Landing on the target PAGE is the same failure even when the URL is not an exact scope
|
|
308
|
+
// match: a tracking query, trailing slash, scheme or www hop puts the reader on the
|
|
309
|
+
// destination, whose self-links then read as placements (DP-0004-T24 corpus v2).
|
|
310
|
+
// Host identity here is exact up to a leading `www.`: a parent/child host with the same
|
|
311
|
+
// path (blog.example.com/guide versus example.com/guide) is a different page, not the target.
|
|
312
|
+
const wanted = canonicalPage(target.url);
|
|
313
|
+
const bareHost = (host) => host.replace(/^www\./, '');
|
|
314
|
+
const landsOnTargetPage = to.path === wanted.path && bareHost(to.host) === bareHost(wanted.host);
|
|
315
|
+
if (result.redirects.length && !samePage
|
|
316
|
+
&& (landsOnTargetPage || matchesTarget(result.finalUrl, target.url, targetScope))) {
|
|
266
317
|
throw new VerificationError('source_redirects_to_target');
|
|
267
318
|
}
|
|
268
319
|
result.occurrences = parsed.links.filter((link) => matchesTarget(link.targetUrl, target.url, targetScope));
|
|
269
320
|
if (new TextEncoder().encode(JSON.stringify(result.occurrences)).byteLength > 96 * 1024) throw new VerificationError('evidence_too_large');
|
|
270
321
|
result.expectations = expectations(result.occurrences, expectedAnchor, expectedRel);
|
|
322
|
+
// A physical tracking link can name the target without proving its eventual
|
|
323
|
+
// destination. Preserve uncertainty until redirect identity is resolved.
|
|
324
|
+
if (!result.occurrences.length && hasRedirectWrapperToTarget(parsed.links, target.url, targetScope)) {
|
|
325
|
+
throw new VerificationError('target_in_redirect_parameter');
|
|
326
|
+
}
|
|
271
327
|
// Meta refresh and a password form are access/navigation evidence, not proof of
|
|
272
328
|
// absence. A server-rendered occurrence can still be positively observed.
|
|
273
329
|
if (!result.occurrences.length && parsed.meta.some((item) => item.name === 'refresh')) {
|
|
274
330
|
throw new VerificationError('navigation_requires_browser');
|
|
275
331
|
}
|
|
276
|
-
const readiness = assessReadiness({ html: body.text, parsed, targetUrl: target.url });
|
|
277
|
-
result.evidence.readiness = readiness;
|
|
278
332
|
if (!result.occurrences.length && readiness.possibleLoginWall) {
|
|
279
333
|
throw new VerificationError('possible_login_wall');
|
|
280
334
|
}
|
|
@@ -293,11 +347,14 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
293
347
|
noindex: result.directives.noindex,
|
|
294
348
|
nofollow: result.directives.nofollow,
|
|
295
349
|
})));
|
|
350
|
+
if (controller.signal.aborted || (Number.isFinite(options.deadlineAt) && Date.now() >= options.deadlineAt)) throw new VerificationError('timeout');
|
|
296
351
|
result.warnings = ['Static HTML observation; JavaScript execution and visual visibility were not checked.'];
|
|
297
352
|
return result;
|
|
298
353
|
} catch (error) {
|
|
299
354
|
result.state = 'unknown';
|
|
300
355
|
result.occurrences = [];
|
|
356
|
+
result.evidence.complete = false;
|
|
357
|
+
delete result.linkSignature;
|
|
301
358
|
result.reason = error instanceof VerificationError ? error.reason : controller.signal.aborted ? 'timeout' : 'verification_error';
|
|
302
359
|
// The same floor a destination check keeps. A publisher rate-limiting the SOURCE page is the
|
|
303
360
|
// more common case of the two, since a source is fetched on a cadence and a destination is not.
|
|
@@ -307,5 +364,6 @@ async function verifyHttpLink({ sourceUrl, targetUrl, targetScope = 'exact', exp
|
|
|
307
364
|
} finally {
|
|
308
365
|
clearTimeout(timer);
|
|
309
366
|
controller.abort();
|
|
367
|
+
options.signal?.removeEventListener('abort', ownerAbort);
|
|
310
368
|
}
|
|
311
369
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isAccessChallenge } from './readiness.js';
|
|
2
|
+
|
|
3
|
+
// A caller holding HTML can supply private readiness analysis without expanding
|
|
4
|
+
// any public evidence DTO. Existing parsed-document-only callers keep their contract.
|
|
5
|
+
export function pageQualityReason(parsed, analysis = null) {
|
|
6
|
+
if (parsed.incomplete) return 'incomplete_html';
|
|
7
|
+
if (isAccessChallenge(parsed, analysis ?? {})) return 'access_challenge';
|
|
8
|
+
if (!parsed.links.length && parsed.meta.some(item => item.name === 'refresh')) return 'navigation_requires_browser';
|
|
9
|
+
if (!parsed.links.length && (analysis ? analysis.readiness.possibleLoginWall : parsed.hasPasswordInput)) return 'possible_login_wall';
|
|
10
|
+
if (analysis?.readiness.requiresRender) return 'possible_render_required';
|
|
11
|
+
if (!parsed.links.length && parsed.hasScript && parsed.visibleText.length < 80) return 'possible_render_required';
|
|
12
|
+
|
|
13
|
+
return null;
|
|
14
|
+
}
|
package/src/verifier/parser.js
CHANGED
|
@@ -1,241 +1,2 @@
|
|
|
1
|
-
|
|
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
|
-
}
|
|
1
|
+
// Compatibility facade: acquisition consumers share one bounded static-HTML parser.
|
|
2
|
+
export { parseDocument, parseLinks, pageText, pageDirectives } from '../page-extraction/html.js';
|
|
@@ -7,11 +7,22 @@ const attr = (node, name) => node.attrs?.find((item) => item.name === name)?.val
|
|
|
7
7
|
const hidden = (node) => attr(node, 'hidden') !== undefined
|
|
8
8
|
|| (node.tagName === 'dialog' && attr(node, 'open') === undefined)
|
|
9
9
|
|| /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important\s*)?(?:;|$)/iu.test(attr(node, 'style') ?? '');
|
|
10
|
+
const text = (value) => value.replace(/[\u2018\u2019]/gu, "'").replace(/\s+/gu, ' ').trim();
|
|
11
|
+
const unfinishedNotice = (value) => /^(?:still loading|we (?:had|are having) trouble loading(?: [^.!?]{1,80})?|(?:this|your) browser is (?:not|no longer) supported)(?:\.{1,3}|…|!)?$/iu.test(text(value));
|
|
12
|
+
const editorialArticle = (node) => node.tagName === 'article'
|
|
13
|
+
&& (!attr(node, 'itemtype') || /(?:^|\s)https?:\/\/schema\.org\/(?:[a-z]*Article|BlogPosting)(?:\s|$)/iu.test(attr(node, 'itemtype')));
|
|
10
14
|
|
|
11
|
-
/**
|
|
12
|
-
export function
|
|
15
|
+
/** A managed challenge needs actual challenge markup, not prose about bot checks. */
|
|
16
|
+
export function isAccessChallenge(parsed, { managedClientChallenge = false } = {}) {
|
|
17
|
+
return /^(?:just a moment(?:\.\.\.)?|access denied|attention required!?\s*(?:\|\s*cloudflare)?|verify (?:you are|you're) human)$/iu.test(parsed?.title ?? '')
|
|
18
|
+
|| parsed?.hasChallengeScript === true
|
|
19
|
+
|| (/^client challenge$/iu.test(parsed?.title ?? '') && managedClientChallenge);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Internal analysis keeps challenge evidence outside the portable readiness DTO. */
|
|
23
|
+
export function analyzeReadiness({ html, parsed, targetUrl }) {
|
|
13
24
|
if (typeof html !== 'string') throw new TypeError('HTML must be a string');
|
|
14
|
-
const bounded = () => ({ requiresRender: true, reason: 'readiness_limit_exceeded', possibleLoginWall: false });
|
|
25
|
+
const bounded = () => ({ readiness: { requiresRender: true, reason: 'readiness_limit_exceeded', possibleLoginWall: false }, managedClientChallenge: false });
|
|
15
26
|
if (html.length > 2 * 1024 * 1024) return bounded();
|
|
16
27
|
let elements = 0;
|
|
17
28
|
const started = performance.now();
|
|
@@ -32,6 +43,11 @@ export function assessReadiness({ html, parsed, targetUrl }) {
|
|
|
32
43
|
}
|
|
33
44
|
let hasScript = false;
|
|
34
45
|
let targetInScript = false;
|
|
46
|
+
let targetInHydrationData = false;
|
|
47
|
+
let targetInVisibleText = false;
|
|
48
|
+
let hasUnfinishedNotice = false;
|
|
49
|
+
let hasManagedChallengeScript = false;
|
|
50
|
+
let hasChallengeAlert = false;
|
|
35
51
|
let hasVisiblePassword = false;
|
|
36
52
|
let contentText = '';
|
|
37
53
|
let articleText = '';
|
|
@@ -42,27 +58,50 @@ export function assessReadiness({ html, parsed, targetUrl }) {
|
|
|
42
58
|
while (stack.length) {
|
|
43
59
|
const state = stack.pop();
|
|
44
60
|
const { node } = state;
|
|
45
|
-
if (
|
|
61
|
+
if (!INERT.has(node.tagName) && attr(node, 'id') === 'loading-error' && attr(node, 'role') === 'alert') hasChallengeAlert = true;
|
|
62
|
+
const nodeHidden = state.hidden || hidden(node);
|
|
46
63
|
if (node.tagName === 'script') {
|
|
47
64
|
hasScript = true;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
const value = (node.childNodes ?? []).filter((child) => child.nodeName === '#text').map((child) => child.value).join('');
|
|
66
|
+
const scriptContainsTarget = targetUrl && (value.replace(/\\\//gu, '/').includes(targetUrl)
|
|
67
|
+
|| value.includes(encodeURIComponent(targetUrl)));
|
|
68
|
+
// Framework payloads may contain a missing link, but are never anchor evidence.
|
|
69
|
+
if (scriptContainsTarget) {
|
|
70
|
+
targetInScript = true;
|
|
71
|
+
if (attr(node, 'type') === 'application/json' || attr(node, 'data-relay-completed-request') === 'true'
|
|
72
|
+
|| /\b(?:window|self)\.[\w$]*(?:hydration|state|data)[\w$]*\s*=/iu.test(value)
|
|
73
|
+
|| /\b__sveltekit[\w$]*\b/u.test(value)) targetInHydrationData = true;
|
|
74
|
+
}
|
|
75
|
+
if (/\/_fs-ch-[a-z0-9_-]+\/(?:errors|script)\.js(?:[?'"\s]|$)/iu.test(`${attr(node, 'src') ?? ''}\n${value}`)) hasManagedChallengeScript = true;
|
|
51
76
|
}
|
|
52
77
|
if (INERT.has(node.tagName)) continue;
|
|
78
|
+
// Hidden containers suppress their visible content, but do not make an
|
|
79
|
+
// executable script inert. Hydration chunks can live in such containers.
|
|
80
|
+
if (nodeHidden) {
|
|
81
|
+
for (const child of node.childNodes ?? []) stack.push({ ...state, node: child, hidden: true });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
53
84
|
const chrome = state.chrome || CHROME.has(node.tagName);
|
|
54
85
|
const form = state.form || node.tagName === 'form';
|
|
55
|
-
const article = state.article || node
|
|
86
|
+
const article = state.article || editorialArticle(node);
|
|
56
87
|
let root = state.root;
|
|
57
88
|
const id = (attr(node, 'id') ?? '').toLowerCase();
|
|
58
|
-
|
|
59
|
-
|
|
89
|
+
const appRoot = ROOT_IDS.has(id) || attr(node, 'data-reactcontainer') === 'true'
|
|
90
|
+
|| attr(node, 'data-reactroot') !== undefined || (node.tagName === 'body' && attr(node, 'data-sveltekit-preload-data') !== undefined);
|
|
91
|
+
if (appRoot || ['profile', 'profile-root', 'profile-container'].includes(id)) {
|
|
92
|
+
root = { app: appRoot, content: false, busy: attr(node, 'aria-busy') === 'true', unfinished: false, parent: root };
|
|
60
93
|
roots.push(root);
|
|
61
94
|
}
|
|
62
95
|
if (node.tagName === 'input' && (attr(node, 'type') ?? '').toLowerCase() === 'password') hasVisiblePassword = true;
|
|
63
96
|
if (!chrome && node.nodeName === '#text' && node.value.trim()) {
|
|
64
97
|
for (let ancestor = root; ancestor && !ancestor.content; ancestor = ancestor.parent) ancestor.content = true;
|
|
65
98
|
if (!form) {
|
|
99
|
+
const unfinished = !article && unfinishedNotice(node.value);
|
|
100
|
+
if (unfinished) {
|
|
101
|
+
hasUnfinishedNotice = true;
|
|
102
|
+
for (let ancestor = root; ancestor && !ancestor.unfinished; ancestor = ancestor.parent) ancestor.unfinished = true;
|
|
103
|
+
}
|
|
104
|
+
if (targetUrl && node.value.includes(targetUrl)) targetInVisibleText = true;
|
|
66
105
|
contentText += node.value.slice(0, 256 - contentText.length);
|
|
67
106
|
if (article) articleText += node.value.slice(0, 256 - articleText.length);
|
|
68
107
|
}
|
|
@@ -78,13 +117,25 @@ export function assessReadiness({ html, parsed, targetUrl }) {
|
|
|
78
117
|
}
|
|
79
118
|
const substantiveContent = contentText.replace(/\s+/gu, ' ').trim().length >= 80
|
|
80
119
|
|| articleText.replace(/\s+/gu, ' ').trim().length >= 40;
|
|
120
|
+
const substantiveArticle = text(articleText).length >= 40;
|
|
81
121
|
const possibleLoginWall = hasVisiblePassword && !substantiveContent;
|
|
82
122
|
let reason = null;
|
|
83
|
-
if (hasScript && !
|
|
123
|
+
if (hasScript && !substantiveArticle && (roots.some((root) => root.app && (root.busy || root.unfinished))
|
|
124
|
+
|| (emptyAppRoot && hasUnfinishedNotice))) {
|
|
125
|
+
reason = 'application_content_unfinished';
|
|
126
|
+
} else if (hasScript && targetInHydrationData && roots.length && !substantiveArticle && !targetInVisibleText) {
|
|
127
|
+
reason = 'application_target_in_hydration_only';
|
|
128
|
+
} else if (hasScript && !substantiveContent) {
|
|
84
129
|
if (emptyAppRoot) reason = 'empty_application_root';
|
|
85
130
|
else if (emptyProfileRoot && targetInScript) reason = 'profile_target_in_script_only';
|
|
86
131
|
else if (!(parsed?.links?.length) && !contentText.trim()) reason = 'empty_script_shell';
|
|
87
132
|
}
|
|
88
|
-
return { requiresRender: reason !== null, reason, possibleLoginWall,
|
|
89
|
-
hasVisiblePassword, targetInScript, emptyAppRoot, emptyProfileRoot }
|
|
133
|
+
return { readiness: { requiresRender: reason !== null, reason, possibleLoginWall,
|
|
134
|
+
hasVisiblePassword, targetInScript, emptyAppRoot, emptyProfileRoot },
|
|
135
|
+
managedClientChallenge: hasManagedChallengeScript && hasChallengeAlert };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Static evidence only; preserves the existing portable readiness schema. */
|
|
139
|
+
export function assessReadiness(input) {
|
|
140
|
+
return analyzeReadiness(input).readiness;
|
|
90
141
|
}
|