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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {ACQUISITION_LIMITS,PageAcquisitionError,boundedAcquisitionLimit} from './contracts.js';
|
|
2
|
+
import {withAbort} from './bytes.js';
|
|
3
|
+
import {recoveryEligibility,acquisitionState as stateOf,completedAcquisition as complete} from './policy.js';
|
|
4
|
+
export {recoveryEligibility} from './policy.js';
|
|
5
|
+
|
|
6
|
+
export const RECOVERY_METHODS = Object.freeze(['workers_http','node_http','node_browser','proxy_http']);
|
|
7
|
+
export const RECOVERY_REASONS = Object.freeze(['disabled','not_enrolled','unsupported','not_configured','unqualified','deadline','rate_limit','robots_override','custody_hold','busy','budget_exhausted','scope_revoked','complete','not_needed']);
|
|
8
|
+
const METHODS=new Set(RECOVERY_METHODS),REASONS=new Set(RECOVERY_REASONS);
|
|
9
|
+
const safeReason=value=>typeof value==='string'&&/^[a-z0-9_:.-]{1,100}$/u.test(value)?value:'acquisition_failed';
|
|
10
|
+
const STOP_PRIORITY={scope_revoked:10,deadline:9,rate_limit:8,robots_override:8,custody_hold:7,busy:6,budget_exhausted:6,unqualified:5,not_configured:4,not_enrolled:3,disabled:2,unsupported:1,not_needed:0};
|
|
11
|
+
const stop=(trace,reason)=>{if(reason!=='complete'&&(STOP_PRIORITY[reason]??0)>(STOP_PRIORITY[trace.terminationReason]??-1))trace.terminationReason=reason;};
|
|
12
|
+
const bodySummary=value=>value?.version===1&&Number.isSafeInteger(value.bytes)&&value.bytes>=0&&value.bytes<=65536&&typeof value.complete==='boolean'
|
|
13
|
+
&&typeof value.truncated==='boolean'&&/^[a-f0-9]{64}$/u.test(value.sha256??'')&&typeof value.looksLikeHtml==='boolean'
|
|
14
|
+
?{version:1,bytes:value.bytes,complete:value.complete,truncated:value.truncated,sha256:value.sha256,looksLikeHtml:value.looksLikeHtml}:undefined;
|
|
15
|
+
function diagnostics(item){
|
|
16
|
+
const robots=Array.isArray(item.robotsHistory)?item.robotsHistory.slice(0,6).map(row=>({
|
|
17
|
+
httpStatus:Number.isInteger(row?.httpStatus)&&row.httpStatus>=100&&row.httpStatus<=599?row.httpStatus:null,
|
|
18
|
+
allowed:typeof row?.allowed==='boolean'?row.allowed:null,reason:row?.reason?safeReason(row.reason):null,
|
|
19
|
+
...(row?.overrideUsed===true?{overrideUsed:true}:{}),...(bodySummary(row?.bodyDiagnostic)?{bodyDiagnostic:bodySummary(row.bodyDiagnostic)}:{})})):undefined;
|
|
20
|
+
const source=item.sourceResponse;
|
|
21
|
+
return {...(robots?{robotsHistory:robots}:{}),...(source?{sourceResponse:{
|
|
22
|
+
httpStatus:Number.isInteger(source.httpStatus)&&source.httpStatus>=100&&source.httpStatus<=599?source.httpStatus:null,
|
|
23
|
+
contentType:typeof source.contentType==='string'&&/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/iu.test(source.contentType)?source.contentType.slice(0,128):null,
|
|
24
|
+
retryAfterSeconds:Number.isFinite(source.retryAfterSeconds)&&source.retryAfterSeconds>=0?Math.min(86400,Math.ceil(source.retryAfterSeconds)):null,
|
|
25
|
+
challenge:source.challenge===true,...(bodySummary(source.bodyDiagnostic)?{bodyDiagnostic:bodySummary(source.bodyDiagnostic)}:{})}}:{})};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function acquisitionBudget({signal,timeoutMs,deadlineAt,clock=Date.now}={}){
|
|
29
|
+
const start=clock();
|
|
30
|
+
if(!Number.isFinite(start)||(deadlineAt!=null&&!Number.isFinite(deadlineAt)))throw new PageAcquisitionError('acquisition_limits_invalid');
|
|
31
|
+
const deadline=Math.min(start+boundedAcquisitionLimit(timeoutMs,ACQUISITION_LIMITS.timeoutMs,ACQUISITION_LIMITS.timeoutMs),deadlineAt??Infinity);
|
|
32
|
+
const controller=new AbortController(),abort=()=>controller.abort();
|
|
33
|
+
if(signal?.aborted||deadline<=start)abort();else signal?.addEventListener('abort',abort,{once:true});
|
|
34
|
+
const timer=setTimeout(abort,Math.max(0,deadline-start));
|
|
35
|
+
return {signal:controller.signal,deadline,remaining:()=>Math.max(0,deadline-clock()),
|
|
36
|
+
assert(){if(controller.signal.aborted||clock()>=deadline)throw new PageAcquisitionError('timeout');},
|
|
37
|
+
close(){clearTimeout(timer);controller.abort();signal?.removeEventListener('abort',abort);}};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Compact, credential-free diagnostics. Old version-one observations remain readable. */
|
|
41
|
+
export function recoverySummary(value){
|
|
42
|
+
if(![1,2].includes(value?.version)||!Array.isArray(value.attempts)||value.attempts.length<1||value.attempts.length>(value.version===1?2:4)||!METHODS.has(value.selectedMethod))return undefined;
|
|
43
|
+
const states=new Set(['present','changed','absent','unknown','source_unavailable','healthy','unavailable','blocked','deferred']);
|
|
44
|
+
const attempts=[];
|
|
45
|
+
for(const item of value.attempts){
|
|
46
|
+
if(!item||!METHODS.has(item.method)||!states.has(item.state)||typeof item.complete!=='boolean'||!Number.isFinite(item.durationMs)||item.durationMs<0||item.durationMs>120000)return undefined;
|
|
47
|
+
attempts.push({method:item.method,state:item.state,complete:item.complete,durationMs:Math.floor(item.durationMs),
|
|
48
|
+
...(typeof item.reason==='string'&&/^[a-z0-9_:.-]{1,100}$/u.test(item.reason)?{reason:item.reason}:{}),
|
|
49
|
+
...(value.version===2?diagnostics(item):{})});
|
|
50
|
+
}
|
|
51
|
+
if(value.version===1)return {version:1,selectedMethod:value.selectedMethod,attempts};
|
|
52
|
+
if(!['customer_monitor','discovery','target','research','document','free'].includes(value.purpose)||!Array.isArray(value.routes)||value.routes.length>4
|
|
53
|
+
||!value.routes.every(r=>r&&METHODS.has(r.method)&&typeof r.available==='boolean'&&typeof r.admitted==='boolean'&&(r.reason===null||REASONS.has(r.reason)))
|
|
54
|
+
||!(value.terminationReason===null||REASONS.has(value.terminationReason)))return undefined;
|
|
55
|
+
return {version:2,purpose:value.purpose,selectedMethod:value.selectedMethod,attempts,routes:value.routes.map(({method,available,admitted,reason})=>({method,available,admitted,reason})),terminationReason:value.terminationReason};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const attempt=(result,method,durationMs)=>({method,durationMs,state:stateOf(result),...(result?.reason?{reason:safeReason(result.reason)}:{}),complete:complete(result),
|
|
59
|
+
...(result?.robotsHistory?{robotsHistory:structuredClone(result.robotsHistory)}:{}),
|
|
60
|
+
...(result?.sourceResponse?{sourceResponse:structuredClone(result.sourceResponse)}:{})});
|
|
61
|
+
function admission(value,fallback){
|
|
62
|
+
if(value?.available===true)return {available:true,reason:null};
|
|
63
|
+
return {available:false,reason:REASONS.has(value?.reason)?value.reason:fallback};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Owns the whole recovery ladder, never a deadline per route. Executors preserve their
|
|
68
|
+
* producer's leases, quotas and actual capture receipts; this router grants none of them.
|
|
69
|
+
* A shared source can recover unknown targets without overwriting already observed links.
|
|
70
|
+
*/
|
|
71
|
+
export async function runRecovery({executeDirect,directFailure,directMethod='workers_http',routes={},authorize=async()=>true,purpose='customer_monitor',options={},clock=Date.now}={}){
|
|
72
|
+
if(typeof executeDirect!=='function')throw new TypeError('A direct acquisition executor is required.');
|
|
73
|
+
if(!['workers_http','node_http'].includes(directMethod))throw new TypeError('A direct HTTP method is required.');
|
|
74
|
+
const owner=acquisitionBudget({...options,clock}),traces=[];let selected,pending=null,directStarted=false;
|
|
75
|
+
const settings=()=>({...options,signal:owner.signal,deadlineAt:owner.deadline,timeoutMs:Math.max(1,owner.remaining())});
|
|
76
|
+
const finish=()=>{
|
|
77
|
+
for(const [i,result] of selected.entries()){
|
|
78
|
+
result.evidence??={};result.evidence.acquisition=traces[i];
|
|
79
|
+
const receipts=Object.values(routes).flatMap(route=>Array.isArray(route?.receipts)?route.receipts:[]);
|
|
80
|
+
if(receipts.length)result.evidence.acquisition.transportReceipts=structuredClone(receipts.slice(0,36));
|
|
81
|
+
}
|
|
82
|
+
return selected;
|
|
83
|
+
};
|
|
84
|
+
try{
|
|
85
|
+
owner.assert();if(!await withAbort(Promise.resolve().then(()=>authorize({beforeDispatch:true})),owner.signal))throw new PageAcquisitionError('acquisition_scope_revoked');
|
|
86
|
+
const started=clock();directStarted=true;selected=await withAbort(Promise.resolve().then(()=>executeDirect(settings())),owner.signal);
|
|
87
|
+
if(!Array.isArray(selected))throw new PageAcquisitionError('acquisition_result_invalid');
|
|
88
|
+
for(const result of selected)traces.push({version:2,purpose,selectedMethod:directMethod,attempts:[attempt(result,directMethod,clock()-started)],
|
|
89
|
+
routes:[{method:directMethod,available:true,admitted:true,reason:null}],terminationReason:null});
|
|
90
|
+
owner.assert();
|
|
91
|
+
if(!await withAbort(Promise.resolve().then(()=>authorize({beforeDispatch:false})),owner.signal))throw new PageAcquisitionError('acquisition_scope_revoked');
|
|
92
|
+
for(const method of ['node_http','node_browser','proxy_http']){
|
|
93
|
+
if(method===directMethod)continue;
|
|
94
|
+
const route=routes[method],cap=admission(route?.capability,typeof route?.execute==='function'?'not_configured':'disabled');
|
|
95
|
+
let status=cap;
|
|
96
|
+
if(cap.available&&typeof route?.execute!=='function')status={available:false,reason:'not_configured'};
|
|
97
|
+
if(status.available&&typeof route?.admit==='function'&&selected.some(value=>recoveryEligibility(value,method).allowed)){
|
|
98
|
+
if(owner.remaining()<=250)status={available:false,reason:'deadline'};
|
|
99
|
+
else status=admission(await withAbort(Promise.resolve().then(()=>route.admit(settings())),owner.signal),'not_enrolled');
|
|
100
|
+
}
|
|
101
|
+
const indices=[];
|
|
102
|
+
for(const [i,result] of selected.entries()){
|
|
103
|
+
const eligibility=recoveryEligibility(result,method);
|
|
104
|
+
const reason=!eligibility.allowed?eligibility.reason:!status.available?status.reason:owner.remaining()<=250?'deadline':null;
|
|
105
|
+
traces[i].routes.push({method,available:status.available,admitted:reason===null,reason});
|
|
106
|
+
if(reason===null)indices.push(i);
|
|
107
|
+
else stop(traces[i],reason);
|
|
108
|
+
}
|
|
109
|
+
if(!indices.length)continue;
|
|
110
|
+
if(!await withAbort(Promise.resolve().then(()=>authorize({beforeDispatch:true})),owner.signal))throw new PageAcquisitionError('acquisition_scope_revoked');
|
|
111
|
+
owner.assert();
|
|
112
|
+
if(owner.remaining()<=250){for(const i of indices){Object.assign(traces[i].routes.at(-1),{admitted:false,reason:'deadline'});stop(traces[i],'deadline');}continue;}
|
|
113
|
+
const at=clock();pending={method,indices,at};
|
|
114
|
+
let next,routeOwner;
|
|
115
|
+
try{
|
|
116
|
+
let routeSettings=settings();
|
|
117
|
+
if(method==='node_browser'&&routes.proxy_http?.capability?.available===true&&typeof routes.proxy_http.execute==='function'){
|
|
118
|
+
const proxyAdmission=typeof routes.proxy_http.admit==='function'?admission(await withAbort(Promise.resolve().then(()=>routes.proxy_http.admit(settings())),owner.signal),'not_enrolled'):{available:true};
|
|
119
|
+
if(proxyAdmission.available){
|
|
120
|
+
const duration=Math.min(10000,owner.remaining()-3000);
|
|
121
|
+
if(duration<=250){for(const i of indices){Object.assign(traces[i].routes.at(-1),{admitted:false,reason:'deadline'});stop(traces[i],'deadline');}pending=null;continue;}
|
|
122
|
+
routeOwner=acquisitionBudget({signal:owner.signal,deadlineAt:Math.min(owner.deadline,clock()+duration),timeoutMs:duration,clock});
|
|
123
|
+
routeSettings={...routeSettings,signal:routeOwner.signal,deadlineAt:routeOwner.deadline,timeoutMs:duration,renderTimeoutMs:duration};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
next=await withAbort(Promise.resolve().then(()=>route.execute(routeSettings,selected,indices)),routeOwner?.signal??owner.signal);
|
|
127
|
+
}catch(error){
|
|
128
|
+
if(error?.reason!=='timeout'||!routeOwner||clock()<routeOwner.deadline||owner.signal.aborted||options.signal?.aborted)throw error;
|
|
129
|
+
next=selected.map((value,i)=>indices.includes(i)?{...value,...(value.state?{state:'unknown'}:{outcome:'unavailable',html:null}),reason:'route_timeout',occurrences:[],
|
|
130
|
+
evidence:{...value.evidence,complete:false,...(method==='node_browser'?{renderAttempt:{attempted:true,reason:'render_timeout'}}:{})}}:value);
|
|
131
|
+
}finally{routeOwner?.close();}
|
|
132
|
+
owner.assert();
|
|
133
|
+
if(!Array.isArray(next)||next.length!==selected.length)throw new PageAcquisitionError('acquisition_result_invalid');
|
|
134
|
+
if(!await withAbort(Promise.resolve().then(()=>authorize({beforeDispatch:false})),owner.signal))throw new PageAcquisitionError('acquisition_scope_revoked');
|
|
135
|
+
owner.assert();
|
|
136
|
+
for(const i of indices){selected[i]=next[i];traces[i].attempts.push(attempt(next[i],method,clock()-at));traces[i].selectedMethod=method;traces[i].terminationReason=null;}
|
|
137
|
+
pending=null;
|
|
138
|
+
}
|
|
139
|
+
return finish();
|
|
140
|
+
}catch(error){
|
|
141
|
+
// A route deadline is an observed inability to finish, never absence. Owner
|
|
142
|
+
// cancellation and producer revocation still reject without publishing a page.
|
|
143
|
+
if(error?.reason!=='timeout'||options.signal?.aborted||clock()<owner.deadline)throw error;
|
|
144
|
+
if(!selected&&typeof directFailure==='function'){
|
|
145
|
+
selected=directFailure('timeout');
|
|
146
|
+
if(!Array.isArray(selected))throw error;
|
|
147
|
+
for(const result of selected)traces.push({version:2,purpose,selectedMethod:directMethod,attempts:[attempt(result,directMethod,0)],
|
|
148
|
+
routes:[{method:directMethod,available:true,admitted:directStarted,reason:'deadline'}],terminationReason:'deadline'});
|
|
149
|
+
}
|
|
150
|
+
if(!selected||traces.length!==selected.length)throw error;
|
|
151
|
+
if(pending)for(const i of pending.indices){
|
|
152
|
+
selected[i]={...selected[i],...(selected[i].state?{state:'unknown'}:{outcome:'unavailable',html:null}),reason:'timeout',occurrences:[],
|
|
153
|
+
evidence:{...selected[i].evidence,complete:false}};
|
|
154
|
+
traces[i].attempts.push(attempt(selected[i],pending.method,clock()-pending.at));traces[i].selectedMethod=pending.method;
|
|
155
|
+
}
|
|
156
|
+
for(const [i,trace] of traces.entries()){
|
|
157
|
+
const completed=complete(selected[i])||['present','absent','healthy','source_unavailable'].includes(stateOf(selected[i]));
|
|
158
|
+
if(!completed)stop(trace,'deadline');
|
|
159
|
+
for(const method of RECOVERY_METHODS.slice(1))if(!trace.routes.some(row=>row.method===method))
|
|
160
|
+
trace.routes.push({method,available:routes[method]?.capability?.available===true,admitted:false,reason:completed?'complete':'deadline'});
|
|
161
|
+
}
|
|
162
|
+
return finish();
|
|
163
|
+
}finally{owner.close();}
|
|
164
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Error messages, page text and provider diagnostics never enter this contract.
|
|
2
|
+
// Only the signed client records provenance; copying fields onto an Error cannot
|
|
3
|
+
// manufacture an authenticated renderer failure for the verifier.
|
|
4
|
+
const failures = new WeakMap();
|
|
5
|
+
const executorReasons = new Set([
|
|
6
|
+
'executor_failed','executor_busy','mode_not_enabled_on_endpoint','browser_not_enabled',
|
|
7
|
+
'browser_failed','browser_unit_failed','browser_namespace_failed','browser_render_failed',
|
|
8
|
+
'browser_request_failed','browser_controller_failed','browser_controller_not_configured',
|
|
9
|
+
'browser_controller_response_unverified','browser_controller_response_too_large',
|
|
10
|
+
'browser_controller_url_not_qualified','browser_cancelled','browser_cancel_unverified',
|
|
11
|
+
'browser_authority_denied','browser_authority_expired','browser_authority_not_configured',
|
|
12
|
+
'browser_authority_unavailable','browser_authority_unverified','browser_cleanup_unverified',
|
|
13
|
+
'browser_insufficient_deadline','browser_resource_admission_failed','browser_job_setup_failed',
|
|
14
|
+
'browser_native_result_unverified','browser_result_too_large','browser_receipt_too_large',
|
|
15
|
+
'browser_uid_wrapper_unqualified','browser_executable_unqualified','browser_egress_unqualified',
|
|
16
|
+
'browser_namespace_escape_probe_failed','browser_namespace_unqualified','browser_not_configured',
|
|
17
|
+
'browser_access_challenge','browser_html_too_large','browser_navigation_changed',
|
|
18
|
+
'browser_redirect_limit','browser_request_limit','browser_resource_bytes_exceeded',
|
|
19
|
+
'browser_resource_concurrency_exceeded','browser_unsafe_redirect','browser_unsafe_navigation',
|
|
20
|
+
'browser_unsupported_content_type','browser_websocket_interception_unavailable',
|
|
21
|
+
'robots_access_challenge','robots_crawl_delay_requires_scheduler','robots_invalid_encoding',
|
|
22
|
+
'robots_not_plain_text','robots_redirect_limit','robots_redirect_loop','robots_unsafe_redirect',
|
|
23
|
+
'robots_disallowed','robots_fetch_failed','robots_unavailable','robots_too_complex',
|
|
24
|
+
'deadline_exceeded','body_too_large','incomplete_body','content_encoding_unsupported',
|
|
25
|
+
'dns_destination_not_public','dns_resolution_failed','response_headers_too_large',
|
|
26
|
+
'origin_connection_failed','socket_destination_changed','socket_destination_unverified',
|
|
27
|
+
'socket_hostname_changed','byte_meter_failed','ipc_body_too_large','ipc_tunnel_unverified',
|
|
28
|
+
'ipc_authentication_failed','ipc_body_unverified','ipc_destination_unsafe',
|
|
29
|
+
'ipc_invalid_request','ipc_request_too_large','ipc_response_unverified','ipc_route_not_found',
|
|
30
|
+
'expired_or_invalid_request','request_replayed','replay_store_full','replay_journal_unavailable',
|
|
31
|
+
'invalid_request','invalid_request_json','invalid_request_identity','invalid_binding',
|
|
32
|
+
'invalid_browser_binding','invalid_limits','invalid_mode','invalid_request_header',
|
|
33
|
+
'unrecognized_user_agent','unsupported_request_headers','unsupported_accept',
|
|
34
|
+
'native_admission_closed','native_admission_epoch_retired','native_admission_state_invalid',
|
|
35
|
+
'native_custody_journal_full','native_custody_journal_unavailable','native_custody_journal_invalid',
|
|
36
|
+
'native_custody_identity_missing','native_custody_identity_changed','native_custody_dispatch_changed','native_custody_session_invalid',
|
|
37
|
+
]);
|
|
38
|
+
const urlReasons = '(?:invalid_url|unsupported_protocol|url_credentials|unsupported_port|ip_literal_not_supported|non_public_hostname)';
|
|
39
|
+
const dynamicReason = new RegExp(`^(?:unsafe_url|browser_unsafe_resource):${urlReasons}$`, 'u');
|
|
40
|
+
const localReasons = new Set(['timeout','acquisition_scope_revoked','acquisition_not_configured',
|
|
41
|
+
'acquisition_failed','acquisition_request_invalid','acquisition_receipt_invalid',
|
|
42
|
+
'acquisition_receipt_limit','body_too_large','incomplete_body','unsupported_or_invalid_encoding']);
|
|
43
|
+
const token = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/u.test(value);
|
|
44
|
+
const session = value => typeof value === 'string' && /^alo-page-browser-job-[a-f0-9]{24}\.service$/u.test(value);
|
|
45
|
+
export function safeExecutorFailureReason(value) {
|
|
46
|
+
return executorReasons.has(value) || typeof value === 'string' && (dynamicReason.test(value) || /^robots_http_[1-5][0-9]{2}$/u.test(value)) ? value : 'executor_failed';
|
|
47
|
+
}
|
|
48
|
+
function safeReason(value) {
|
|
49
|
+
if(localReasons.has(value))return value;
|
|
50
|
+
if(typeof value === 'string' && value.startsWith('acquisition_')
|
|
51
|
+
&& safeExecutorFailureReason(value.slice(12)) === value.slice(12))return value;
|
|
52
|
+
return 'acquisition_failed';
|
|
53
|
+
}
|
|
54
|
+
function custody(closure) {
|
|
55
|
+
if(closure?.browserClosed !== true)return {state:'unknown'};
|
|
56
|
+
if(['signed_executor','signed_executor_lookup'].includes(closure.cleanupSource) && session(closure.sessionId)
|
|
57
|
+
&& closure.cleanup?.unitStopped === true && closure.cleanup.cgroupEmpty === true
|
|
58
|
+
&& closure.cleanup.namespaceDestroyed === true)return {state:'closed',source:closure.cleanupSource,sessionId:closure.sessionId,
|
|
59
|
+
cleanup:{unitStopped:true,cgroupEmpty:true,namespaceDestroyed:true}};
|
|
60
|
+
if(closure.browserDispatched === false && ['signed_no_launch','signed_no_launch_lookup','local_no_dispatch'].includes(closure.cleanupSource)
|
|
61
|
+
&& closure.sessionId === undefined && closure.cleanup === undefined)return {state:'not_launched',source:closure.cleanupSource};
|
|
62
|
+
return {state:'unknown'};
|
|
63
|
+
}
|
|
64
|
+
export function recordRenderFailure(error, {reason,stage,durationMs,closure,receipt,reservationId} = {}) {
|
|
65
|
+
if(!(error instanceof Error))return;
|
|
66
|
+
const trace = {version:1,method:'node_browser',reason:safeReason(reason),
|
|
67
|
+
stage:['admission','transport','response_validation','executor','capture_validation','postflight'].includes(stage)?stage:'transport',
|
|
68
|
+
durationMs:Number.isFinite(durationMs)?Math.max(0,Math.min(60000,Math.ceil(durationMs))):null,custody:custody(closure)};
|
|
69
|
+
if(token(reservationId))trace.reservationId=reservationId;
|
|
70
|
+
if(receipt && token(receipt.requestId) && /^[a-f0-9]{64}$/u.test(receipt.sha256 ?? '')
|
|
71
|
+
&& Number.isInteger(receipt.httpStatus) && receipt.httpStatus >= 200 && receipt.httpStatus <= 599) {
|
|
72
|
+
trace.receipt={requestId:receipt.requestId,sha256:receipt.sha256,httpStatus:receipt.httpStatus,keyId:'version1',authenticated:true};
|
|
73
|
+
if(receipt.executorReason !== undefined)trace.executorReason=safeExecutorFailureReason(receipt.executorReason);
|
|
74
|
+
if(['admission','execution'].includes(receipt.executorStage))trace.executorStage=receipt.executorStage;
|
|
75
|
+
if(typeof receipt.retryable === 'boolean')trace.retryable=receipt.retryable;
|
|
76
|
+
}
|
|
77
|
+
failures.set(error, trace);
|
|
78
|
+
}
|
|
79
|
+
export function readRenderFailure(error) {
|
|
80
|
+
const value = error instanceof Error ? failures.get(error) : null;
|
|
81
|
+
return value ? structuredClone(value) : null;
|
|
82
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
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 textContentResult(node, limit = 2048) {
|
|
54
|
+
let text = '';
|
|
55
|
+
let lastBlock = null;
|
|
56
|
+
let stopped = false;
|
|
57
|
+
const emit = (value, item) => {
|
|
58
|
+
const block = blockAncestor(item, node);
|
|
59
|
+
if (lastBlock !== null && block !== lastBlock) text += ' ';
|
|
60
|
+
text += value;
|
|
61
|
+
lastBlock = block;
|
|
62
|
+
};
|
|
63
|
+
for (const item of walk(node)) {
|
|
64
|
+
// A line break is a word boundary wherever it appears, block or not.
|
|
65
|
+
if (item.tagName === 'br') { text += ' '; lastBlock = null; }
|
|
66
|
+
if (item.nodeName === '#text') emit(item.value, item);
|
|
67
|
+
if (item.tagName === 'img') { const alt = attr(item, 'alt'); if (alt) emit(alt, item); }
|
|
68
|
+
if (text.length > limit * 2) { stopped = true; break; }
|
|
69
|
+
}
|
|
70
|
+
const normalized = normalize(text);
|
|
71
|
+
return { text: normalized.slice(0, limit), truncated: stopped || normalized.length > limit, limit };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function textContent(node, limit = 2048) {
|
|
75
|
+
return textContentResult(node, limit).text;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function contextFor(node) {
|
|
79
|
+
let context = node.parentNode ?? node;
|
|
80
|
+
for (let depth = 0; context.parentNode && depth < 4; depth++) {
|
|
81
|
+
if (BLOCKS.has(context.tagName) || context.tagName === 'body') break;
|
|
82
|
+
context = context.parentNode;
|
|
83
|
+
}
|
|
84
|
+
return textContent(context, 640);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Landmark region of a link from its own ancestors in the one parse tree: the
|
|
88
|
+
// nearest main/article/nav/footer/header/aside (or ARIA role), where a template
|
|
89
|
+
// ancestor (navigation, footer, header, aside) wins over a nested main/article.
|
|
90
|
+
// Links inside script/style/template/noscript have no region.
|
|
91
|
+
const LANDMARK_TAGS = new Map([['main', 'main'], ['article', 'main'], ['nav', 'navigation'], ['footer', 'footer'], ['header', 'header'], ['aside', 'aside']]);
|
|
92
|
+
const LANDMARK_ROLES = new Map([['main', 'main'], ['navigation', 'navigation'], ['contentinfo', 'footer'], ['banner', 'header'], ['complementary', 'aside']]);
|
|
93
|
+
const TEMPLATE_REGIONS = new Set(['navigation', 'footer', 'header', 'aside']);
|
|
94
|
+
const NO_REGION = new Set(['script', 'style', 'template', 'noscript']);
|
|
95
|
+
function landmarkRegion(node) {
|
|
96
|
+
const chain = [];
|
|
97
|
+
for (let current = node; current; current = current.parentNode) {
|
|
98
|
+
if (NO_REGION.has(current.tagName)) return 'unknown';
|
|
99
|
+
chain.push(current);
|
|
100
|
+
}
|
|
101
|
+
let region = 'unknown';
|
|
102
|
+
for (let index = chain.length - 1; index >= 0; index--) {
|
|
103
|
+
const current = chain[index];
|
|
104
|
+
if (TEMPLATE_REGIONS.has(region)) break;
|
|
105
|
+
const role = current.attrs?.find(item => item.name === 'role')?.value;
|
|
106
|
+
region = LANDMARK_TAGS.get(current.tagName) ?? LANDMARK_ROLES.get(role) ?? region;
|
|
107
|
+
}
|
|
108
|
+
return region;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Parser bounds. The defaults keep a single checked page cheap; a site crawl
|
|
112
|
+
// raises them and keeps the first anchors of a long page instead of failing it
|
|
113
|
+
// (truncateAnchors), reporting linksTruncated so the caller can mark coverage.
|
|
114
|
+
export const PARSE_BOUNDS = Object.freeze({ elements: 25_000, anchors: 1000, parseMs: 500, truncateAnchors: false });
|
|
115
|
+
|
|
116
|
+
// Main-content paragraphs for mention matching: the outermost paragraph-like
|
|
117
|
+
// blocks in the page's main landmark (or outside every template landmark when
|
|
118
|
+
// the page has none), with the text of links inside them wrapped in U+0002 and
|
|
119
|
+
// U+0003 so a reader-visible passage keeps its words while a mention that is
|
|
120
|
+
// already a link can be told apart.
|
|
121
|
+
const PARAGRAPHS = new Set(['p', 'li', 'blockquote', 'td', 'dd', 'dt', 'figcaption', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
122
|
+
export const CONTENT_BLOCK_BOUNDS = Object.freeze({ blocks: 300, blockChars: 1500, totalChars: 24_000, minChars: 30 });
|
|
123
|
+
export const LINK_TEXT_START = '\u0002', LINK_TEXT_END = '\u0003';
|
|
124
|
+
function markedText(block, limit) {
|
|
125
|
+
let text = '', lastBlock = null, inLink = false;
|
|
126
|
+
for (const item of walk(block)) {
|
|
127
|
+
if (item.tagName === 'br') { text += ' '; lastBlock = null; }
|
|
128
|
+
if (item.nodeName !== '#text' && item.tagName !== 'img') continue;
|
|
129
|
+
const value = item.nodeName === '#text' ? item.value : attr(item, 'alt');
|
|
130
|
+
if (!value) continue;
|
|
131
|
+
let link = false;
|
|
132
|
+
for (let cursor = item.parentNode; cursor && cursor !== block.parentNode; cursor = cursor.parentNode) if (cursor.tagName === 'a') { link = true; break; }
|
|
133
|
+
const inner = blockAncestor(item, block);
|
|
134
|
+
if (lastBlock !== null && inner !== lastBlock) text += ' ';
|
|
135
|
+
if (link !== inLink) { text += link ? LINK_TEXT_START : LINK_TEXT_END; inLink = link; }
|
|
136
|
+
text += value; lastBlock = inner;
|
|
137
|
+
if (text.length > limit * 2) break;
|
|
138
|
+
}
|
|
139
|
+
if (inLink) text += LINK_TEXT_END;
|
|
140
|
+
return normalize(text).slice(0, limit);
|
|
141
|
+
}
|
|
142
|
+
function contentBlocks(nodes) {
|
|
143
|
+
const bounds = CONTENT_BLOCK_BOUNDS;
|
|
144
|
+
const hasMain = nodes.some(node => LANDMARK_TAGS.get(node.tagName) === 'main' || LANDMARK_ROLES.get(attr(node, 'role')) === 'main');
|
|
145
|
+
const blocks = [];
|
|
146
|
+
let total = 0, truncated = false;
|
|
147
|
+
for (const node of nodes) {
|
|
148
|
+
if (!PARAGRAPHS.has(node.tagName)) continue;
|
|
149
|
+
// A block inside another block, or inside a link (a card whose whole body is
|
|
150
|
+
// one anchor), is skipped: any words in a linked block are already link text.
|
|
151
|
+
let nested = false;
|
|
152
|
+
for (let cursor = node.parentNode; cursor; cursor = cursor.parentNode) if (PARAGRAPHS.has(cursor.tagName) || cursor.tagName === 'a') { nested = true; break; }
|
|
153
|
+
if (nested) continue;
|
|
154
|
+
const region = landmarkRegion(node);
|
|
155
|
+
if (hasMain ? region !== 'main' : region !== 'unknown') continue;
|
|
156
|
+
const text = markedText(node, bounds.blockChars);
|
|
157
|
+
if (text.replace(/[\u0002\u0003]/gu, '').length < bounds.minChars) continue;
|
|
158
|
+
if (blocks.length >= bounds.blocks || total + text.length > bounds.totalChars) { truncated = true; break; }
|
|
159
|
+
blocks.push(text); total += text.length;
|
|
160
|
+
}
|
|
161
|
+
return { blocks, truncated };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseDocument(html, pageUrl, { includeMetadata = false, textLimit = null, landmarkRegions = false, content = false, bounds = PARSE_BOUNDS } = {}) {
|
|
165
|
+
const limits = { ...PARSE_BOUNDS, ...bounds };
|
|
166
|
+
if (typeof html !== 'string') throw new TypeError('HTML must be a string');
|
|
167
|
+
const errors = [];
|
|
168
|
+
let elements = 0;
|
|
169
|
+
let anchorElements = 0;
|
|
170
|
+
const parserStart = performance.now();
|
|
171
|
+
const treeAdapter = {
|
|
172
|
+
...defaultTreeAdapter,
|
|
173
|
+
createElement(tagName, namespaceURI, attrs) {
|
|
174
|
+
// Bound parser expansion and evidence output independently of body bytes.
|
|
175
|
+
if (++elements > limits.elements || attrs.length > 256 || performance.now() - parserStart > limits.parseMs) {
|
|
176
|
+
const error = new Error('document_too_complex');
|
|
177
|
+
error.reason = 'document_too_complex';
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
if ((tagName === 'a' || tagName === 'area') && ++anchorElements > limits.anchors && !limits.truncateAnchors) {
|
|
181
|
+
const error = new Error('too_many_link_occurrences');
|
|
182
|
+
error.reason = 'too_many_link_occurrences';
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
return defaultTreeAdapter.createElement(tagName, namespaceURI, attrs);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
const document = parse(html, {
|
|
189
|
+
treeAdapter,
|
|
190
|
+
scriptingEnabled: true,
|
|
191
|
+
sourceCodeLocationInfo: true,
|
|
192
|
+
onParseError: ({ code }) => { if (EOF_ERRORS.has(code) && errors.length < 8) errors.push(code); },
|
|
193
|
+
});
|
|
194
|
+
let baseUrl = pageUrl;
|
|
195
|
+
let baseFound = false;
|
|
196
|
+
let canonical = null;
|
|
197
|
+
const links = [];
|
|
198
|
+
const meta = [];
|
|
199
|
+
let title = '';
|
|
200
|
+
let hasScript = false;
|
|
201
|
+
let hasPasswordInput = false;
|
|
202
|
+
let hasChallengeScript = false;
|
|
203
|
+
let visibleText = '';
|
|
204
|
+
const nodes = [...walk(document)];
|
|
205
|
+
for (const node of walk(document, true)) {
|
|
206
|
+
if (node.tagName === 'script') {
|
|
207
|
+
hasScript = true;
|
|
208
|
+
if ((attr(node, 'src') ?? '').includes('/cdn-cgi/challenge-platform/')) hasChallengeScript = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const node of nodes) {
|
|
212
|
+
if (node.tagName === 'body') visibleText = textContent(node, 160);
|
|
213
|
+
if (node.tagName === 'input' && (attr(node, 'type') ?? '').toLowerCase() === 'password') hasPasswordInput = true;
|
|
214
|
+
if (node.tagName === 'base' && !baseFound && attr(node, 'href') !== undefined) {
|
|
215
|
+
// Browsers use only the first base element with an href, even if invalid.
|
|
216
|
+
baseFound = true;
|
|
217
|
+
if (attr(node, 'href').length > 8192) {
|
|
218
|
+
const error = new Error('base_url_too_long');
|
|
219
|
+
error.reason = 'base_url_too_long';
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const base = new URL(attr(node, 'href'), pageUrl);
|
|
224
|
+
if (['http:', 'https:'].includes(base.protocol)) baseUrl = base.href;
|
|
225
|
+
} catch { /* Browser-compatible fallback to the document URL. */ }
|
|
226
|
+
}
|
|
227
|
+
if (node.tagName === 'meta') {
|
|
228
|
+
const name = (attr(node, 'name') ?? '').toLowerCase();
|
|
229
|
+
if ((['robots', 'googlebot', 'bingbot', 'linktrailbot'].includes(name)
|
|
230
|
+
|| (attr(node, 'http-equiv') ?? '').toLowerCase() === 'refresh')
|
|
231
|
+
&& (attr(node, 'content') ?? '').length > 2048) {
|
|
232
|
+
const error = new Error('page_directive_too_long');
|
|
233
|
+
error.reason = 'page_directive_too_long';
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
if (['robots', 'googlebot', 'bingbot', 'linktrailbot'].includes(name)) {
|
|
237
|
+
meta.push({ name, content: (attr(node, 'content') ?? '').slice(0, 2048) });
|
|
238
|
+
}
|
|
239
|
+
if ((attr(node, 'http-equiv') ?? '').toLowerCase() === 'refresh') {
|
|
240
|
+
meta.push({ name: 'refresh', content: (attr(node, 'content') ?? '').slice(0, 2048) });
|
|
241
|
+
}
|
|
242
|
+
if (meta.length > 16) {
|
|
243
|
+
const error = new Error('too_many_page_directives');
|
|
244
|
+
error.reason = 'too_many_page_directives';
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (node.tagName === 'title' && !title) title = textContent(node, 256);
|
|
249
|
+
// Browsers honour the first canonical link element only.
|
|
250
|
+
if (node.tagName === 'link' && canonical === null
|
|
251
|
+
&& (attr(node, 'rel') ?? '').toLowerCase().split(/\s+/u).includes('canonical')) {
|
|
252
|
+
const href = attr(node, 'href') ?? '';
|
|
253
|
+
if (href && href.length <= 8192) {
|
|
254
|
+
try {
|
|
255
|
+
const resolved = new URL(href, baseUrl);
|
|
256
|
+
if (['http:', 'https:'].includes(resolved.protocol)) canonical = resolved.href;
|
|
257
|
+
} catch { /* An unparseable canonical is no canonical, exactly as a browser treats it. */ }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let anchorsSeen = 0, linksTruncated = false;
|
|
262
|
+
for (const node of nodes) {
|
|
263
|
+
if (!['a', 'area'].includes(node.tagName)) continue;
|
|
264
|
+
if (++anchorsSeen > limits.anchors) { linksTruncated = true; break; }
|
|
265
|
+
const href = attr(node, 'href');
|
|
266
|
+
if (href === undefined) continue;
|
|
267
|
+
if (href.length > 8192) {
|
|
268
|
+
const error = new Error('link_url_too_long');
|
|
269
|
+
error.reason = 'link_url_too_long';
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
let target;
|
|
273
|
+
try { target = new URL(href, baseUrl); } catch { continue; }
|
|
274
|
+
if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password) continue;
|
|
275
|
+
const location = node.sourceCodeLocation;
|
|
276
|
+
links.push({
|
|
277
|
+
href,
|
|
278
|
+
targetUrl: target.href,
|
|
279
|
+
anchor: node.tagName === 'area' ? normalize(attr(node, 'alt') ?? '') : textContent(node, 1024),
|
|
280
|
+
rel: [...new Set((attr(node, 'rel') ?? '').toLowerCase().split(/\s+/u).filter(Boolean))],
|
|
281
|
+
context: contextFor(node),
|
|
282
|
+
locator: location ? { line: location.startLine, column: location.startCol, offset: location.startOffset } : null,
|
|
283
|
+
visibility: 'not_rendered',
|
|
284
|
+
...(landmarkRegions ? { region: landmarkRegion(node) } : {}),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const result = { links, linksTruncated, meta, title, baseUrl, canonical, hasScript, hasPasswordInput, hasChallengeScript, visibleText, incomplete: errors.length > 0, parseErrors: errors };
|
|
288
|
+
if (content) result.content = contentBlocks(nodes);
|
|
289
|
+
if (includeMetadata) {
|
|
290
|
+
const description = nodes.find(node => node.tagName === 'meta' && (attr(node, 'name') ?? '').toLowerCase() === 'description');
|
|
291
|
+
result.metadata = {
|
|
292
|
+
title: title || null,
|
|
293
|
+
h1s: nodes.filter(node => node.tagName === 'h1').slice(0, 5).map(node => textContent(node, 500)),
|
|
294
|
+
meta_description: description ? normalize(attr(description, 'content') ?? '').slice(0, 500) || null : null,
|
|
295
|
+
canonical,
|
|
296
|
+
link_counts: { total: links.length },
|
|
297
|
+
asset_candidate_reasons: [],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (textLimit !== null) {
|
|
301
|
+
if (!Number.isInteger(textLimit) || textLimit < 1 || textLimit > 512 * 1024) throw new RangeError('Invalid static text limit');
|
|
302
|
+
const body = nodes.find(node => node.tagName === 'body') ?? document;
|
|
303
|
+
result.staticText = textContentResult(body, textLimit);
|
|
304
|
+
}
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The page's text as a READER sees it, reusing the same walk the parser already uses.
|
|
310
|
+
*
|
|
311
|
+
* `walk` already skips `script`, `style`, `template`, `noscript` and comments, which is the
|
|
312
|
+
* property that matters: a brand name inside a JSON payload or an analytics config is not a
|
|
313
|
+
* mention anyone read. `parseDocument` caps its own copy at 160 characters because it only needs
|
|
314
|
+
* to know whether a page is an empty shell; a mention scan needs the whole document, so it asks
|
|
315
|
+
* for it explicitly rather than raising a cap every check would pay for.
|
|
316
|
+
*
|
|
317
|
+
* Takes no base URL, deliberately: text is not resolved against anything, and a parameter that
|
|
318
|
+
* looks like it matters and does not is how a caller comes to believe the output is absolute.
|
|
319
|
+
*/
|
|
320
|
+
export function pageText(html, limit = 512 * 1024) {
|
|
321
|
+
return parseDocument(String(html), 'https://page-text.invalid/', { textLimit: limit }).staticText.text;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** All HTTP(S) anchor/area occurrences in static HTML; never claims rendered visibility. */
|
|
325
|
+
export function parseLinks(html, baseUrl) {
|
|
326
|
+
return parseDocument(html, new URL(baseUrl).href).links;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function googleDirectiveTokens(meta, header) {
|
|
330
|
+
const values = meta.filter(item => item.name === 'robots' || item.name === 'googlebot').map(item => item.content);
|
|
331
|
+
let agent = '*';
|
|
332
|
+
// A targeted X-Robots-Tag applies to the following comma-separated tokens
|
|
333
|
+
// until another agent prefix. Directive arguments themselves are not agents.
|
|
334
|
+
const argumentDirectives = new Set(['unavailable_after', 'max-snippet', 'max-image-preview', 'max-video-preview']);
|
|
335
|
+
for (let part of String(header ?? '').toLowerCase().split(',')) {
|
|
336
|
+
const prefix = /^\s*([\w-]+)\s*:\s*(.*)$/u.exec(part);
|
|
337
|
+
if (prefix && !argumentDirectives.has(prefix[1])) { agent = prefix[1]; part = prefix[2]; }
|
|
338
|
+
if (agent === '*' || agent === 'googlebot') values.push(part);
|
|
339
|
+
}
|
|
340
|
+
return values.join(',').toLowerCase().split(/[\s,]+/u);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function pageDirectives(meta, header) {
|
|
344
|
+
const xRobotsTag = header ? [header] : [];
|
|
345
|
+
// Preserve targeted header directives without incorrectly applying them to all bots.
|
|
346
|
+
const genericHeader = header && !/(?:^|,)\s*[\w-]+\s*:/u.test(header) ? header : '';
|
|
347
|
+
const generic = [genericHeader, ...meta.filter((item) => item.name === 'robots').map((item) => item.content)]
|
|
348
|
+
.join(',').toLowerCase().split(/[\s,]+/u);
|
|
349
|
+
const google = googleDirectiveTokens(meta, header);
|
|
350
|
+
return {
|
|
351
|
+
meta,
|
|
352
|
+
xRobotsTag,
|
|
353
|
+
noindex: generic.includes('noindex') || generic.includes('none'),
|
|
354
|
+
nofollow: generic.includes('nofollow') || generic.includes('none'),
|
|
355
|
+
googlebot: { noindex: google.includes('noindex') || google.includes('none'),
|
|
356
|
+
nofollow: google.includes('nofollow') || google.includes('none') },
|
|
357
|
+
indexingStatus: 'not_checked',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseDocument, pageDirectives } from './html.js';
|
|
2
|
+
import { validateCaptureReceipt, PageAcquisitionError } from '../page-acquisition/contracts.js';
|
|
3
|
+
|
|
4
|
+
export { parseDocument, parseLinks, pageText, pageDirectives } from './html.js';
|
|
5
|
+
export { extractPageMetadata } from './metadata.js';
|
|
6
|
+
export const STATIC_TEXT_LIMIT = 512 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract from an already acquired document. A receipt is checked when supplied;
|
|
10
|
+
* legacy HTML remains caller-supplied evidence, without invented fetch provenance.
|
|
11
|
+
* Static text is not a claim about CSS visibility, iframes or shadow DOM.
|
|
12
|
+
*/
|
|
13
|
+
export function extractPage(html, { baseUrl, receipt = null, textLimit = STATIC_TEXT_LIMIT, xRobotsTag = null, landmarkRegions = false, content = false, bounds } = {}) {
|
|
14
|
+
const capture = receipt === null ? null : validateCaptureReceipt(receipt, { finalUrl: baseUrl });
|
|
15
|
+
if (capture && (!['raw_html', 'rendered_dom'].includes(capture.representation) || capture.httpStatus < 200 || capture.httpStatus >= 300)) throw new PageAcquisitionError('capture_invalid');
|
|
16
|
+
const parsed = parseDocument(html, baseUrl, { includeMetadata: true, textLimit, landmarkRegions, content, ...(bounds ? { bounds } : {}) });
|
|
17
|
+
return {
|
|
18
|
+
...parsed,
|
|
19
|
+
directives: pageDirectives(parsed.meta, xRobotsTag),
|
|
20
|
+
coverage: {
|
|
21
|
+
documentComplete: !parsed.incomplete,
|
|
22
|
+
textComplete: !parsed.staticText.truncated,
|
|
23
|
+
bodyComplete: capture?.body.complete ?? null,
|
|
24
|
+
transportQualified: capture?.transport.qualified ?? false,
|
|
25
|
+
mainDocument: true, iframes: false, shadowRoots: false,
|
|
26
|
+
},
|
|
27
|
+
provenance: capture ? { kind: 'capture_receipt', receipt: capture }
|
|
28
|
+
: { kind: 'caller_supplied_html', acquiredAt: null, httpStatus: null },
|
|
29
|
+
};
|
|
30
|
+
}
|