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,16 @@
|
|
|
1
|
+
import { parseDocument } from './html.js';
|
|
2
|
+
|
|
3
|
+
/** Pure metadata extraction; no requests, supplier calls or judgments. */
|
|
4
|
+
export function extractPageMetadata(html, { baseUrl } = {}) {
|
|
5
|
+
if (typeof html !== 'string' || !html) return {
|
|
6
|
+
title: null, h1s: [], meta_description: null, canonical: null,
|
|
7
|
+
link_counts: null, asset_candidate_reasons: null,
|
|
8
|
+
};
|
|
9
|
+
// A private resolution base lets the parser count relative physical links.
|
|
10
|
+
// It must never escape as a claimed publisher canonical URL.
|
|
11
|
+
const resolutionBase = baseUrl ?? 'https://page-metadata.invalid/';
|
|
12
|
+
const parsed = parseDocument(html, resolutionBase, { includeMetadata: true });
|
|
13
|
+
if (parsed.incomplete) throw Object.assign(new Error('incomplete_document'), { reason: 'incomplete_document' });
|
|
14
|
+
if (baseUrl === undefined && parsed.metadata.canonical?.startsWith('https://page-metadata.invalid/')) parsed.metadata.canonical = null;
|
|
15
|
+
return parsed.metadata;
|
|
16
|
+
}
|
package/src/response-shaping.js
CHANGED
|
@@ -13,17 +13,18 @@ const pick = (row, keys) => Object.fromEntries(keys.filter(key => row[key] !== u
|
|
|
13
13
|
const attempt = value => value && typeof value === 'object' ? pick(value, ['state','reason','checkedAt','httpStatus','occurrenceCount','checkerVersion']) : value;
|
|
14
14
|
export function conciseObservationState(state) {
|
|
15
15
|
if (!state || typeof state !== 'object') return state ?? {};
|
|
16
|
-
const out = pick(state, ['state','uncertain','checked_at','reason','confirmedState','lastChangedAt','firstAbsentAt','consecutiveAbsent','nextCheckAt','lastSuccessfulAt','lastHealthyAt','firstUnavailableAt','unavailableCount','retryNotBefore']);
|
|
16
|
+
const out = pick(state, ['state','wasEverPresent','uncertain','checked_at','reason','confirmedState','lastChangedAt','firstAbsentAt','consecutiveAbsent','nextCheckAt','lastSuccessfulAt','lastHealthyAt','firstUnavailableAt','unavailableCount','retryNotBefore']);
|
|
17
17
|
if (state.latestAttempt !== undefined) out.latestAttempt = attempt(state.latestAttempt);
|
|
18
18
|
if (state.lastSuccessfulObservation !== undefined) out.lastSuccessfulObservation = attempt(state.lastSuccessfulObservation);
|
|
19
19
|
if (state.lastSuccessfulObservationSameAsLatestAttempt !== undefined) out.lastSuccessfulObservationSameAsLatestAttempt = state.lastSuccessfulObservationSameAsLatestAttempt;
|
|
20
20
|
return out;
|
|
21
21
|
}
|
|
22
22
|
const withState = (row, keys) => ({ ...pick(row, keys), observation_state: conciseObservationState(row.observation_state) });
|
|
23
|
+
const categoryFields=['category_contract','chosen_category','page_assessment','display_category'];
|
|
23
24
|
// An event snapshot (shared/observation-event.js) minus the occurrence list, expectations and
|
|
24
25
|
// signatures: the state, uncertainty, check state, reason, status and timing stay.
|
|
25
26
|
const conciseSnapshot = snapshot => snapshot && typeof snapshot === 'object'
|
|
26
|
-
? pick(snapshot, ['state','uncertain','check_state','reason','checked_at','http_status','occurrence_count','occurrences_truncated','source_url','target_url','url','next_check_at','watch_id','target_id'])
|
|
27
|
+
? pick(snapshot, ['state','wasEverPresent','uncertain','check_state','reason','checked_at','http_status','occurrence_count','occurrences_truncated','source_url','target_url','url','next_check_at','watch_id','target_id'])
|
|
27
28
|
: snapshot;
|
|
28
29
|
const observationResult = result => result && typeof result === 'object'
|
|
29
30
|
? { ...pick(result, ['state','reason','checkedAt','httpStatus','checkerVersion','evidence','redirects']), occurrenceCount: result.occurrenceCount ?? result.occurrences?.length ?? 0 }
|
|
@@ -34,7 +35,7 @@ const observationResult = result => result && typeof result === 'object'
|
|
|
34
35
|
export const SHAPES = {
|
|
35
36
|
list_rank_history: { rows: 'items', keeps: ['overview_run_id','slot_at','rank','observed_at','retrieved_at','freshness','outcome','execution','cost'],
|
|
36
37
|
row: r => pick(r, ['overview_run_id','slot_at','rank','observed_at','retrieved_at','freshness','outcome','execution','cost']) },
|
|
37
|
-
list_link_watches: { rows: 'items', keeps: ['id','source_url','target_url','status','state','observation_state','next_check_at','local_reference'], row: r => withState(r, ['id','source_url','target_url','status','state','next_check_at','local_reference']) },
|
|
38
|
+
list_link_watches: { rows: 'items', keeps: ['id','workspace_id','project_id','source_url','target_url','status','state','observation_state','next_check_at','local_reference','intent','outreachStatus','lastContactedAt','campaign',...categoryFields], row: r => withState(r, ['id','workspace_id','project_id','source_url','target_url','status','state','next_check_at','local_reference','intent','outreachStatus','lastContactedAt','campaign',...categoryFields]) },
|
|
38
39
|
list_targets: { rows: 'items', keeps: ['id','url','status','state','observation_state','next_check_at'], row: r => withState(r, ['id','url','status','state','next_check_at']) },
|
|
39
40
|
get_target_placements: { rows: 'items', keeps: ['id','source_url','target_url','target_scope','status','state'], row: r => pick(r, ['id','source_url','target_url','target_scope','status','state']) },
|
|
40
41
|
list_check_jobs: { rows: 'items', keeps: ['id','state','type','watch_id','target_id','attempt_count','error_code','created_at','completed_at'], row: r => pick(r, ['id','state','type','watch_id','target_id','attempt_count','error_code','created_at','completed_at']) },
|
package/src/tool-views.js
CHANGED
|
@@ -3,25 +3,28 @@
|
|
|
3
3
|
// script, the MCP router, the CLI index and the generators all read the same definitions.
|
|
4
4
|
// Rules: docs/technical/AGENT-SURFACE-CONTRACT.md (DP-0036).
|
|
5
5
|
|
|
6
|
-
export const TOOLSETS = ['monitoring','evidence','rank','licensed','discovery','baseline','library','competitors','reports','workspace','notifications','webhooks','admission','disavow','lifecycle','digests'];
|
|
6
|
+
export const TOOLSETS = ['monitoring','classification','evidence','rank','licensed','discovery','baseline','library','competitors','internal-links','reports','workspace','notifications','webhooks','admission','disavow','lifecycle','campaigns','digests'];
|
|
7
7
|
|
|
8
8
|
// One line per toolset: what the group is for. Printed by the site brief, the REST index and the
|
|
9
9
|
// catalog snapshot so the same wording reaches every client; a toolset without a summary is a
|
|
10
10
|
// gate failure in the measurement script.
|
|
11
11
|
export const TOOLSET_SUMMARIES = {
|
|
12
12
|
admission: 'Project rules, URL previews and retained admission decisions.',
|
|
13
|
-
disavow: 'Propose, review and export website disavow rules.',
|
|
13
|
+
disavow: 'Propose, review and export website disavow rules; check the copy Google holds (handoffs).',
|
|
14
14
|
lifecycle: 'Placement costs, expiry, renewal events and currency reports.',
|
|
15
|
-
|
|
15
|
+
campaigns: 'Campaign contacts and supplied outreach outcomes.',
|
|
16
|
+
digests: 'Own-address digest delivery history and exact retained events.',
|
|
16
17
|
monitoring: 'Watch earned links and destination URLs: create, list, update, import, export, recheck.',
|
|
18
|
+
classification: 'Manual link types and tags with retained placement context and metadata.',
|
|
17
19
|
evidence: 'What a check observed: histories, snapshots, change feeds, check jobs, published contacts.',
|
|
18
20
|
rank: 'Synthetic DataForSEO rank schedules and dated history; hosted collection disabled.',
|
|
19
21
|
licensed: 'Licensed source-search evidence and charge custody; operator admission required.',
|
|
20
22
|
discovery: 'Import candidate rows, read stored runs, verify selected candidates, enroll them.',
|
|
21
|
-
baseline: 'Scoped backlink baselines;
|
|
23
|
+
baseline: 'Scoped backlink baselines; new live work requires admission in this deployment.',
|
|
22
24
|
library: 'Read and export the coverage-stated opportunity library.',
|
|
23
|
-
competitors: 'Competitor sets, dated inventories, scheduled refresh
|
|
24
|
-
|
|
25
|
+
competitors: 'Competitor sets, dated inventories, scheduled refresh and gap reports.',
|
|
26
|
+
'internal-links': 'Owned-site page inventories, bounded captures, internal-link health and graph evidence.',
|
|
27
|
+
reports: 'Placement chronology, service assurance, backlink and domain-mix reports.',
|
|
25
28
|
workspace: 'Workspace, projects, usage, members, invitations, scratch-resource cleanup.',
|
|
26
29
|
notifications: 'Email notification preferences, previews, tests and deliveries.',
|
|
27
30
|
webhooks: 'Webhook endpoints, state, secrets and delivery records.',
|
|
@@ -57,15 +60,22 @@ export function oneLiner(description, max = 60) {
|
|
|
57
60
|
return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 20)).replace(/[,;:]$/, '')}…`;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
// entries: [{ name, description, toolset, tier, readOnly }]. Grouped
|
|
61
|
-
|
|
63
|
+
// entries: [{ name, description, toolset, tier, readOnly }]. Grouped under each toolset's summary,
|
|
64
|
+
// one line per command with its core and write markers. Only core commands carry a one-liner;
|
|
65
|
+
// a deferred command's detail is one describe away. Per-command one-liners were cut from 28 to
|
|
66
|
+
// 23 characters as commands were added until the 2,500-token budget (measure:tools) had no room
|
|
67
|
+
// left; names under a toolset summary keep that budget without a further cut per command.
|
|
68
|
+
export function manifestText(entries, { max = 23 } = {}) {
|
|
62
69
|
const groups = new Map(TOOLSETS.map(t => [t, []]));
|
|
63
70
|
for (const entry of entries) (groups.get(entry.toolset) ?? groups.set(entry.toolset, []).get(entry.toolset)).push(entry);
|
|
64
|
-
const lines = ['Commands by toolset.
|
|
71
|
+
const lines = ['Commands by toolset. exec help / agentlinkops --help.' + (entries.some(entry=>entry.toolset==='licensed') ? ' Licensed: operator admission.' : '')];
|
|
65
72
|
for (const [toolset, members] of groups) {
|
|
66
73
|
if (!members.length) continue;
|
|
67
|
-
lines.push(`\n${toolset}
|
|
68
|
-
for (const entry of members.sort((a, b) => a.name.localeCompare(b.name)))
|
|
74
|
+
lines.push(`\n${toolset}:${TOOLSET_SUMMARIES[toolset] ? ` ${TOOLSET_SUMMARIES[toolset]}` : ''}`);
|
|
75
|
+
for (const entry of members.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
76
|
+
const core = entry.tier === 'core';
|
|
77
|
+
lines.push(`- ${entry.name}${core ? ' (core)' : ''}${entry.readOnly ? '' : ' [write]'}${core ? `: ${oneLiner(entry.description, max)}` : ''}`);
|
|
78
|
+
}
|
|
69
79
|
}
|
|
70
80
|
return lines.join('\n');
|
|
71
81
|
}
|
|
@@ -138,7 +148,7 @@ export function viewMembers(entries, view) {
|
|
|
138
148
|
// stays registered and callable there through exec call and by a direct tools/call.
|
|
139
149
|
if (view === 'default') return [];
|
|
140
150
|
if (view === 'code') return [];
|
|
141
|
-
const m = /^toolset:([a-z]+)(:readonly)?$/.exec(view);
|
|
151
|
+
const m = /^toolset:([a-z]+(?:-[a-z]+)*)(:readonly)?$/.exec(view);
|
|
142
152
|
if (!m) throw new Error(`Unknown view ${view}`);
|
|
143
153
|
const members = visible.filter(e => e.toolset === m[1] || e.tier === 'core');
|
|
144
154
|
return m[2] ? members.filter(e => e.readOnly) : members;
|
package/src/verifier/fetch.js
CHANGED
|
@@ -1,339 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
constructor(reason, details = {}) {
|
|
6
|
-
super(reason);
|
|
7
|
-
this.name = 'VerificationError';
|
|
8
|
-
this.reason = reason;
|
|
9
|
-
Object.assign(this, details);
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function cancelBody(response) {
|
|
14
|
-
// Cancellation must not extend the request deadline on a misbehaving transport.
|
|
15
|
-
response.body?.cancel().catch(() => {});
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function readBounded(response, maxBytes, signal) {
|
|
19
|
-
const length = response.headers.get('content-length');
|
|
20
|
-
const encoded = response.headers.get('content-encoding');
|
|
21
|
-
if (!encoded && length && Number(length) > maxBytes) {
|
|
22
|
-
cancelBody(response);
|
|
23
|
-
throw new VerificationError('body_too_large');
|
|
24
|
-
}
|
|
25
|
-
const reader = response.body?.getReader();
|
|
26
|
-
if (!reader) return { text: '', bytes: 0 };
|
|
27
|
-
const chunks = [];
|
|
28
|
-
let bytes = 0;
|
|
29
|
-
try {
|
|
30
|
-
while (true) {
|
|
31
|
-
const { done, value } = await withAbort(reader.read(), signal);
|
|
32
|
-
if (done) break;
|
|
33
|
-
bytes += value.byteLength;
|
|
34
|
-
if (bytes > maxBytes) throw new VerificationError('body_too_large');
|
|
35
|
-
chunks.push(value);
|
|
36
|
-
}
|
|
37
|
-
if (!encoded && length && Number(length) !== bytes) throw new VerificationError('incomplete_body');
|
|
38
|
-
const combined = new Uint8Array(bytes);
|
|
39
|
-
let offset = 0;
|
|
40
|
-
for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; }
|
|
41
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
42
|
-
const charset = /charset\s*=\s*["']?([^\s;"']+)/iu.exec(contentType)?.[1] ?? 'utf-8';
|
|
43
|
-
let text;
|
|
44
|
-
try { text = new TextDecoder(charset, { fatal: true }).decode(combined); }
|
|
45
|
-
catch { throw new VerificationError('unsupported_or_invalid_encoding'); }
|
|
46
|
-
return { text, bytes, data: combined };
|
|
47
|
-
} catch (error) {
|
|
48
|
-
reader.cancel().catch(() => {});
|
|
49
|
-
throw error;
|
|
50
|
-
} finally {
|
|
51
|
-
reader.releaseLock();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function withAbort(promise, signal) {
|
|
56
|
-
if (signal.aborted) return Promise.reject(new VerificationError('timeout'));
|
|
57
|
-
return new Promise((resolve, reject) => {
|
|
58
|
-
const onAbort = () => reject(new VerificationError('timeout'));
|
|
59
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
60
|
-
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** One shared budget for robots, redirects, hooks and source-body reads. */
|
|
65
|
-
export const PRODUCT_TOKEN = 'LinktrailBot';
|
|
66
|
-
|
|
67
|
-
// A day. Anything longer is a publisher saying "not today", and a scheduler that honoured a
|
|
68
|
-
// month-long Retry-After would silently retire the check instead of retrying it.
|
|
69
|
-
export const MAX_RETRY_AFTER_SECONDS = 86_400;
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* RFC 9110 `Retry-After`: delta-seconds or an HTTP-date. Returns null for anything else.
|
|
73
|
-
*
|
|
74
|
-
* Both forms appear in the wild and a parser that reads only the integer form silently discards
|
|
75
|
-
* the date form, which is the one a CDN tends to send.
|
|
76
|
-
*/
|
|
77
|
-
export function retryAfterSeconds(value, now = Date.now()) {
|
|
78
|
-
if (!value) return null;
|
|
79
|
-
const text = String(value).trim();
|
|
80
|
-
if (/^\d+$/u.test(text)) {
|
|
81
|
-
const seconds = Number(text);
|
|
82
|
-
return Number.isFinite(seconds) ? Math.min(seconds, MAX_RETRY_AFTER_SECONDS) : null;
|
|
83
|
-
}
|
|
84
|
-
// `Date.parse` accepts far more than an HTTP-date — it reads "-5" as a year and returns a real
|
|
85
|
-
// timestamp, which turned a malformed header into a wait of zero. An IMF-fixdate always carries
|
|
86
|
-
// a month and day name, so requiring a letter is enough to keep the loose parser off junk.
|
|
87
|
-
if (!/[A-Za-z]/u.test(text)) return null;
|
|
88
|
-
const at = Date.parse(text);
|
|
89
|
-
if (!Number.isFinite(at)) return null;
|
|
90
|
-
// A date already in the past means "now", never a negative wait.
|
|
91
|
-
return Math.min(Math.max(0, Math.ceil((at - now) / 1000)), MAX_RETRY_AFTER_SECONDS);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function publicFetcher(options, signal) {
|
|
95
|
-
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
96
|
-
const robotsByOrigin = new Map();
|
|
97
|
-
// The product token is what robots matches on and never changes. A contact URL may be appended
|
|
98
|
-
// so a publisher who sees us in their logs can find out who we are — the convention every
|
|
99
|
-
// reputable crawler follows, and the thing most likely to move a reflexive 403 or 429.
|
|
100
|
-
//
|
|
101
|
-
// It defaults to ABSENT on purpose. It was written before AgentLinkOps had a public domain, and a bot
|
|
102
|
-
// advertising a URL that does not resolve is worse than one advertising nothing: it reads as
|
|
103
|
-
// impersonation rather than identification. Set it when a real domain exists.
|
|
104
|
-
const userAgent = options.userAgentContact
|
|
105
|
-
? `${PRODUCT_TOKEN}/0.1 (+${String(options.userAgentContact).slice(0, 200)})`
|
|
106
|
-
: `${PRODUCT_TOKEN}/0.1`;
|
|
107
|
-
const maxRedirects = Math.min(options.maxRedirects ?? 5, 5);
|
|
108
|
-
const maxBytes = Math.min(options.maxBytes ?? 2 * 1024 * 1024, 2 * 1024 * 1024);
|
|
109
|
-
|
|
110
|
-
async function request(url, context = {}) {
|
|
111
|
-
const checked = validatePublicUrl(url);
|
|
112
|
-
if (!checked.valid) throw new VerificationError(`unsafe_url:${checked.reason}`);
|
|
113
|
-
if (options.beforeFetch) await withAbort(Promise.resolve().then(() => options.beforeFetch(checked.url, { ...context, signal })), signal);
|
|
114
|
-
let response;
|
|
115
|
-
try {
|
|
116
|
-
response = await withAbort(Promise.resolve().then(() => {
|
|
117
|
-
if (signal.aborted) throw new VerificationError('timeout');
|
|
118
|
-
// Conditional headers ride only on the request that earned them. A validator belongs
|
|
119
|
-
// to one resource; across a redirect hop it would be re-sent to a different one, so
|
|
120
|
-
// the caller drops validators after the first hop and the redirect loop revalidates
|
|
121
|
-
// the destination from scratch rather than trusting a moved resource's old validators.
|
|
122
|
-
const conditional = {};
|
|
123
|
-
if (context.kind === 'source' && context.validators?.etag) conditional['If-None-Match'] = context.validators.etag;
|
|
124
|
-
if (context.kind === 'source' && context.validators?.lastModified) conditional['If-Modified-Since'] = context.validators.lastModified;
|
|
125
|
-
return fetchImpl(checked.url, {
|
|
126
|
-
method: 'GET',
|
|
127
|
-
redirect: 'manual',
|
|
128
|
-
credentials: 'omit',
|
|
129
|
-
signal,
|
|
130
|
-
headers: {
|
|
131
|
-
'User-Agent': userAgent,
|
|
132
|
-
Accept: context.kind === 'robots' ? 'text/plain' : context.accept ?? 'text/html',
|
|
133
|
-
...conditional,
|
|
134
|
-
},
|
|
135
|
-
});
|
|
136
|
-
}), signal);
|
|
137
|
-
} catch (error) {
|
|
138
|
-
if (error instanceof VerificationError) throw error;
|
|
139
|
-
throw new VerificationError(signal.aborted ? 'timeout' : 'network_error');
|
|
140
|
-
}
|
|
141
|
-
// Custom transports must never silently follow redirects or bypass destination checks.
|
|
142
|
-
if (response.redirected || (response.url && new URL(response.url).href !== checked.url)) {
|
|
143
|
-
cancelBody(response);
|
|
144
|
-
throw new VerificationError('transport_followed_redirect');
|
|
145
|
-
}
|
|
146
|
-
return response;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function fetchRobots(origin, diagnostic) {
|
|
150
|
-
let url = `${origin}/robots.txt`;
|
|
151
|
-
const seen = new Set();
|
|
152
|
-
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
153
|
-
diagnostic.robotsUrl = url;
|
|
154
|
-
diagnostic.httpStatus = null;
|
|
155
|
-
if (seen.has(url)) throw new VerificationError('robots_redirect_loop');
|
|
156
|
-
seen.add(url);
|
|
157
|
-
const response = await request(url, { kind: 'robots' });
|
|
158
|
-
diagnostic.httpStatus = response.status;
|
|
159
|
-
// A challenge replaces the requested resource, even when its status would
|
|
160
|
-
// otherwise mean missing robots or a redirect. Never infer policy from it.
|
|
161
|
-
if (response.headers.get('cf-mitigated') === 'challenge') {
|
|
162
|
-
cancelBody(response);
|
|
163
|
-
const retry = retryAfterSeconds(response.headers.get('retry-after'));
|
|
164
|
-
if (retry !== null) diagnostic.retryAfterSeconds = retry;
|
|
165
|
-
throw new VerificationError('robots_access_challenge', retry === null ? {} : { retryAfterSeconds: retry });
|
|
166
|
-
}
|
|
167
|
-
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
168
|
-
cancelBody(response);
|
|
169
|
-
const location = response.headers.get('location');
|
|
170
|
-
if (!location) throw new VerificationError('robots_redirect_missing_location');
|
|
171
|
-
const next = validatePublicUrl(new URL(location, url).href);
|
|
172
|
-
if (!next.valid) throw new VerificationError(`unsafe_robots_redirect:${next.reason}`);
|
|
173
|
-
if (diagnostic.redirects.length < 5) diagnostic.redirects.push({ from: url, to: next.url, status: response.status });
|
|
174
|
-
url = next.url;
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
// RFC 9309 §2.3.1.3 and Google's implementation agree: a 4xx on robots.txt means the file is
|
|
178
|
-
// UNAVAILABLE, and an unavailable robots.txt imposes no restrictions. Only 404 and 410 were
|
|
179
|
-
// honoured that way here, so a 401 or 403 — a WAF answering before the file is even looked
|
|
180
|
-
// for — was read as "this publisher forbids us" and refused the whole fetch. Measured on 800
|
|
181
|
-
// real destinations, that was 39 refusals, about 5% of the run, attributed to publishers who
|
|
182
|
-
// had said nothing.
|
|
183
|
-
//
|
|
184
|
-
// Proceeding is also BETTER REPORTING even when the page then fails: `target_http_403` says
|
|
185
|
-
// the publisher refused the page, where `robots_http_403` said we never asked.
|
|
186
|
-
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
187
|
-
cancelBody(response);
|
|
188
|
-
return { text: '', reason: response.status === 404 || response.status === 410 ? 'robots_not_found' : `robots_unavailable_${response.status}` };
|
|
189
|
-
}
|
|
190
|
-
// 429 and 5xx are the other half of the same rule and stay a refusal: they mean TEMPORARILY
|
|
191
|
-
// unavailable, and the spec's answer to that is to stay away rather than to assume consent.
|
|
192
|
-
if (!response.ok) {
|
|
193
|
-
cancelBody(response);
|
|
194
|
-
const retry = retryAfterSeconds(response.headers.get('retry-after'));
|
|
195
|
-
if (retry !== null) diagnostic.retryAfterSeconds = retry;
|
|
196
|
-
throw new VerificationError(`robots_http_${response.status}`, retry === null ? {} : { retryAfterSeconds: retry });
|
|
197
|
-
}
|
|
198
|
-
const body = await readBounded(response, 512 * 1024, signal);
|
|
199
|
-
if (/text\/html/iu.test(response.headers.get('content-type') ?? '') || /<(?:!doctype|html|body)\b/iu.test(body.text)) {
|
|
200
|
-
throw new VerificationError('robots_not_plain_text');
|
|
201
|
-
}
|
|
202
|
-
return { text: body.text, reason: 'robots_fetched' };
|
|
203
|
-
}
|
|
204
|
-
throw new VerificationError('robots_redirect_limit');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function checkRobots(url, trace) {
|
|
208
|
-
const origin = new URL(url).origin;
|
|
209
|
-
const diagnostic = { sourceUrl: url, robotsUrl: `${origin}/robots.txt`, httpStatus: null,
|
|
210
|
-
productToken: PRODUCT_TOKEN, allowed: null, reason: null, matchedRule: null,
|
|
211
|
-
crawlDelaySeconds: null, fetched: null, redirects: [] };
|
|
212
|
-
// Write before fetching/evaluating so refusals keep the failing hop, not the
|
|
213
|
-
// last allowed origin. Both histories are capped by our redirect budget.
|
|
214
|
-
trace.robots = diagnostic;
|
|
215
|
-
trace.robotsHistory ??= [];
|
|
216
|
-
if (trace.robotsHistory.length < 6) trace.robotsHistory.push(diagnostic);
|
|
217
|
-
try {
|
|
218
|
-
if (!robotsByOrigin.has(origin)) {
|
|
219
|
-
const record = await fetchRobots(origin, diagnostic);
|
|
220
|
-
robotsByOrigin.set(origin, { ...record, diagnostic: structuredClone(diagnostic) });
|
|
221
|
-
}
|
|
222
|
-
const record = robotsByOrigin.get(origin);
|
|
223
|
-
Object.assign(diagnostic, structuredClone(record.diagnostic), { sourceUrl: url });
|
|
224
|
-
const decision = robotsDecision(record.text, url);
|
|
225
|
-
Object.assign(diagnostic, decision, { fetched: record.reason });
|
|
226
|
-
options.onRobots?.(url, decision);
|
|
227
|
-
if (decision.allowed !== true) throw new VerificationError(decision.reason);
|
|
228
|
-
if (decision.crawlDelaySeconds > 0 && !options.beforeFetch) {
|
|
229
|
-
throw new VerificationError('robots_crawl_delay_requires_scheduler');
|
|
230
|
-
}
|
|
231
|
-
return diagnostic;
|
|
232
|
-
} catch (error) {
|
|
233
|
-
diagnostic.reason = error instanceof VerificationError ? error.reason : 'verification_error';
|
|
234
|
-
throw error;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
// Authorize a native browser document without refetching its body through HTTP.
|
|
240
|
-
// The caller still needs an independently verified browser egress boundary.
|
|
241
|
-
async authorizeNavigation(url, trace = {}) {
|
|
242
|
-
const checked = validatePublicUrl(url);
|
|
243
|
-
if (!checked.valid) throw new VerificationError('unsafe_render_navigation');
|
|
244
|
-
const robots = await checkRobots(checked.url, trace);
|
|
245
|
-
if (signal.aborted) throw new VerificationError('timeout');
|
|
246
|
-
await options.beforeFetch?.(checked.url, { kind: 'source', crawlDelaySeconds: robots.crawlDelaySeconds, signal });
|
|
247
|
-
if (signal.aborted) throw new VerificationError('timeout');
|
|
248
|
-
return robots;
|
|
249
|
-
},
|
|
250
|
-
/**
|
|
251
|
-
* Fetches one source document. `{ validators }` turns this into a conditional GET: the
|
|
252
|
-
* caller passes the ETag/Last-Modified a previous fetch earned, and a 304 comes back as
|
|
253
|
-
* `{ notModified: true }` rather than an error. A 304 we did not ask for stays an error —
|
|
254
|
-
* the server revalidating nobody's cache is a defect, not evidence of anything.
|
|
255
|
-
*
|
|
256
|
-
* `{ document: true }` fetches a feed or sitemap instead of a page: the Accept header and
|
|
257
|
-
* the content-type gate admit the XML family rather than HTML. Everything else — robots per
|
|
258
|
-
* hop, public-destination screening, the redirect budget — is identical, because a feed is
|
|
259
|
-
* fetched from the same publishers under the same politeness rules as a page.
|
|
260
|
-
*/
|
|
261
|
-
async source(initialUrl, trace, { validators = null, document = false } = {}) {
|
|
262
|
-
const accept = document
|
|
263
|
-
? 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.9, text/plain;q=0.8'
|
|
264
|
-
: 'text/html';
|
|
265
|
-
let url = initialUrl;
|
|
266
|
-
let revalidators = validators;
|
|
267
|
-
const seen = new Set();
|
|
268
|
-
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
|
|
269
|
-
trace.finalUrl = url;
|
|
270
|
-
if (seen.has(url)) throw new VerificationError('redirect_loop');
|
|
271
|
-
seen.add(url);
|
|
272
|
-
const robots = await checkRobots(url, trace);
|
|
273
|
-
trace.robots = robots;
|
|
274
|
-
const response = await request(url, { kind: 'source', crawlDelaySeconds: robots.crawlDelaySeconds, validators: revalidators, accept });
|
|
275
|
-
trace.httpStatus = response.status;
|
|
276
|
-
trace.sourceResponse = { url, httpStatus: response.status,
|
|
277
|
-
contentType: /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+/iu.exec(response.headers.get('content-type') ?? '')?.[0].slice(0, 128) ?? null,
|
|
278
|
-
retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')),
|
|
279
|
-
challenge: response.headers.get('cf-mitigated') === 'challenge' };
|
|
280
|
-
if (trace.sourceResponse.challenge) {
|
|
281
|
-
cancelBody(response);
|
|
282
|
-
const retry = trace.sourceResponse.retryAfterSeconds;
|
|
283
|
-
throw new VerificationError('access_challenge', retry === null ? {} : { retryAfterSeconds: retry });
|
|
284
|
-
}
|
|
285
|
-
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
286
|
-
cancelBody(response);
|
|
287
|
-
const location = response.headers.get('location');
|
|
288
|
-
if (!location) throw new VerificationError('redirect_missing_location');
|
|
289
|
-
let next;
|
|
290
|
-
try { next = validatePublicUrl(new URL(location, url).href); }
|
|
291
|
-
catch { throw new VerificationError('invalid_redirect'); }
|
|
292
|
-
if (!next.valid) throw new VerificationError(`unsafe_redirect:${next.reason}`);
|
|
293
|
-
trace.redirects.push({ from: url, to: next.url, status: response.status });
|
|
294
|
-
url = next.url;
|
|
295
|
-
// Validators were earned for the pre-redirect resource; the destination revalidates
|
|
296
|
-
// from scratch (see the conditional-header note in `request`).
|
|
297
|
-
revalidators = null;
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
if ([404, 410].includes(response.status)) {
|
|
301
|
-
cancelBody(response);
|
|
302
|
-
return { response, body: null };
|
|
303
|
-
}
|
|
304
|
-
if (response.status === 304) {
|
|
305
|
-
cancelBody(response);
|
|
306
|
-
if (!revalidators) throw new VerificationError('not_modified_without_baseline');
|
|
307
|
-
return { response, body: null, notModified: true,
|
|
308
|
-
validators: { etag: response.headers.get('etag') ?? revalidators.etag ?? null,
|
|
309
|
-
lastModified: response.headers.get('last-modified') ?? revalidators.lastModified ?? null } };
|
|
310
|
-
}
|
|
311
|
-
if (response.status !== 200) {
|
|
312
|
-
cancelBody(response);
|
|
313
|
-
// A 429 or a 503 may state WHEN to come back. We keep it and never act on it here:
|
|
314
|
-
// sleeping inside a request holds an origin lease and a worker for someone else's
|
|
315
|
-
// backoff, and this system already decided that **the cron is the sleep**. Surfacing it
|
|
316
|
-
// lets a scheduler re-queue at the stated time, the same way a robots crawl delay is
|
|
317
|
-
// handed to the scheduler rather than slept on.
|
|
318
|
-
throw new VerificationError(`source_http_${response.status}`,
|
|
319
|
-
retryAfterSeconds(response.headers.get('retry-after')) === null ? {}
|
|
320
|
-
: { retryAfterSeconds: retryAfterSeconds(response.headers.get('retry-after')) });
|
|
321
|
-
}
|
|
322
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
323
|
-
// The document gate admits the XML family (`application/xml`, `text/xml`,
|
|
324
|
-
// `application/rss+xml`, `application/atom+xml`) and plain text (the sitemap protocol's
|
|
325
|
-
// text format). It is still a gate: a feed URL serving HTML is a page wearing a feed's
|
|
326
|
-
// name, and parsing it as a feed would read markup as entries.
|
|
327
|
-
const expected = document ? /(?:[+/]xml|text\/plain)(?:\s*;|$)/iu : /^text\/html(?:;|$)/iu;
|
|
328
|
-
if (!expected.test(contentType)) {
|
|
329
|
-
cancelBody(response);
|
|
330
|
-
throw new VerificationError('unsupported_content_type');
|
|
331
|
-
}
|
|
332
|
-
const body = await readBounded(response, maxBytes, signal);
|
|
333
|
-
return { response, body, notModified: false,
|
|
334
|
-
validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
|
|
335
|
-
}
|
|
336
|
-
throw new VerificationError('redirect_limit');
|
|
337
|
-
},
|
|
338
|
-
};
|
|
339
|
-
}
|
|
1
|
+
// Compatibility facade: every verifier and installed-package consumer shares the same
|
|
2
|
+
// HTTP policy and error identity. Keep the legacy export names stable.
|
|
3
|
+
export { VerificationError, readBounded, PRODUCT_TOKEN, MAX_RETRY_AFTER_SECONDS,
|
|
4
|
+
retryAfterSeconds, publicFetcher } from '../page-acquisition/http.js';
|