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,151 @@
|
|
|
1
|
+
import { validatePublicUrl } from '../verifier/url.js';
|
|
2
|
+
|
|
3
|
+
// Decides what the crawler is allowed to queue, and in what order. Pure: no fetch, no
|
|
4
|
+
// persistence, no clock of its own. Persistence and leasing belong to the store; this
|
|
5
|
+
// module owns the judgement that keeps a crawl finite and on-topic.
|
|
6
|
+
export const FRONTIER_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export const FRONTIER_LIMITS = Object.freeze({
|
|
9
|
+
pathDepth: 12, // deeper than any editorial page we want; archives spiral past this
|
|
10
|
+
querySize: 4, // faceted navigation multiplies past this
|
|
11
|
+
repeatedSegment: 3, // /a/b/a/b/a/b is a cycle, not a hierarchy
|
|
12
|
+
urlLength: 2048,
|
|
13
|
+
hostPages: 500, // per host, per run
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Stripped before deduplication, never a reason to reject. These change nothing about the
|
|
17
|
+
// page served, so keeping them would fetch one page once per inbound campaign link.
|
|
18
|
+
const TRACKING_PARAMS = [/^utm_/i, /^(gclid|fbclid|msclkid|mc_[ce]id|igshid|ref|ref_src|source|_ga|yclid|dclid|s_kwcid|vero_id|oly_enc_id)$/i];
|
|
19
|
+
// Parameters whose whole purpose is to enumerate. Presence is a trap signal, not a strip.
|
|
20
|
+
const ENUMERATING_PARAMS = /^(sid|sessionid|jsessionid|phpsessid|calendar|cal_date|date|from_date|to_date|start_date|end_date|month|year|day|week|filter|facet|orderby|order_by|sort_by|price_min|price_max|replytocom)$/i;
|
|
21
|
+
// Endpoints that generate results rather than publish pages.
|
|
22
|
+
const GENERATED_PATHS = /(^|\/)(search|cart|checkout|login|signin|signup|register|logout|account|wp-admin|wp-login\.php|xmlrpc\.php|feed|rss|atom|print|amp|tag|author|comment-page-\d+|page\/\d{3,})(\/|$)/i;
|
|
23
|
+
// Files that are not pages we can extract editorial links from.
|
|
24
|
+
const NON_PAGE_EXTENSION = /\.(jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?|mp4|webm|mov|avi|mp3|wav|ogg|flac|pdf|zip|gz|tar|rar|7z|dmg|exe|msi|pkg|deb|rpm|css|js|mjs|json|xml|txt|csv|xlsx?|docx?|pptx?|woff2?|ttf|otf|eot)$/i;
|
|
25
|
+
|
|
26
|
+
export class FrontierRejection extends Error {
|
|
27
|
+
constructor(reason) { super(reason); this.name = 'FrontierRejection'; this.reason = reason; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Canonical deduplication key. Two URLs sharing a key serve the same page for our purposes,
|
|
32
|
+
* so the frontier fetches one of them. Returns null when the URL is not crawlable at all.
|
|
33
|
+
*/
|
|
34
|
+
export function frontierKey(rawUrl) {
|
|
35
|
+
const checked = validatePublicUrl(rawUrl);
|
|
36
|
+
if (!checked.valid) return null;
|
|
37
|
+
const url = new URL(checked.url);
|
|
38
|
+
// Sorting alone is not enough. Scrapy's canonicalize_url sorts query keys and keeps
|
|
39
|
+
// duplicates, so `?a=1&a=1` and `?a=1` fetch the same page twice; collapse identical pairs.
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const kept = [...url.searchParams.entries()]
|
|
42
|
+
.filter(([name]) => !TRACKING_PARAMS.some(pattern => pattern.test(name)))
|
|
43
|
+
.filter(([name, value]) => { const pair = `${name}=${value}`; if (seen.has(pair)) return false; seen.add(pair); return true; })
|
|
44
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
45
|
+
url.search = '';
|
|
46
|
+
for (const [name, value] of kept) url.searchParams.append(name, value);
|
|
47
|
+
// A trailing slash and an explicit directory index address the same document far more
|
|
48
|
+
// often than not, and treating them as two pages doubles every crawl.
|
|
49
|
+
url.pathname = url.pathname.replace(/\/index\.(html?|php|aspx?)$/i, '/').replace(/(.)\/$/, '$1');
|
|
50
|
+
return url.href;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Longest run of a repeated path segment, which marks a cycle rather than a hierarchy. */
|
|
54
|
+
function repeatedSegmentRun(segments) {
|
|
55
|
+
const seen = new Map();
|
|
56
|
+
let worst = 0;
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
const count = (seen.get(segment) ?? 0) + 1;
|
|
59
|
+
seen.set(segment, count);
|
|
60
|
+
if (count > worst) worst = count;
|
|
61
|
+
}
|
|
62
|
+
return worst;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decides whether a discovered URL may enter the frontier.
|
|
67
|
+
*
|
|
68
|
+
* @param rawUrl Absolute URL found on a crawled page.
|
|
69
|
+
* @param context.reason How it was found: seed, sitemap, outlink or refresh.
|
|
70
|
+
* @param context.seenKeys Keys already queued or crawled. A Set, read only.
|
|
71
|
+
* @param context.hostPages Pages already queued or crawled for this host, this run.
|
|
72
|
+
* @param context.allowedHosts When present, only these hosts (and their subdomains) admit.
|
|
73
|
+
* @param context.robotsAllows Callback (pathname) => boolean. Defaults to allowing.
|
|
74
|
+
* @returns {{admit: boolean, reason: string, key: string|null, hostname: string|null, priority: number}}
|
|
75
|
+
*/
|
|
76
|
+
export function admitUrl(rawUrl, context = {}) {
|
|
77
|
+
const { reason = 'outlink', seenKeys, hostPages = 0, allowedHosts, robotsAllows } = context;
|
|
78
|
+
const reject = why => ({ admit: false, reason: why, key: null, hostname: null, priority: 0 });
|
|
79
|
+
|
|
80
|
+
if (typeof rawUrl !== 'string' || rawUrl.length > FRONTIER_LIMITS.urlLength) return reject('url_too_long');
|
|
81
|
+
const checked = validatePublicUrl(rawUrl);
|
|
82
|
+
if (!checked.valid) return reject(checked.reason);
|
|
83
|
+
const key = frontierKey(rawUrl);
|
|
84
|
+
if (!key) return reject('invalid_url');
|
|
85
|
+
const url = new URL(key), hostname = checked.hostname;
|
|
86
|
+
|
|
87
|
+
if (allowedHosts?.length && !allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`)))
|
|
88
|
+
return reject('host_not_in_scope');
|
|
89
|
+
if (seenKeys?.has(key)) return reject('already_seen');
|
|
90
|
+
// The budget is per host so one large publisher cannot consume a whole run.
|
|
91
|
+
if (hostPages >= FRONTIER_LIMITS.hostPages) return reject('host_budget_exhausted');
|
|
92
|
+
if (NON_PAGE_EXTENSION.test(url.pathname)) return reject('not_a_page');
|
|
93
|
+
if (GENERATED_PATHS.test(url.pathname)) return reject('generated_endpoint');
|
|
94
|
+
|
|
95
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
96
|
+
if (segments.length > FRONTIER_LIMITS.pathDepth) return reject('path_too_deep');
|
|
97
|
+
if (repeatedSegmentRun(segments) >= FRONTIER_LIMITS.repeatedSegment) return reject('repeated_path_segment');
|
|
98
|
+
|
|
99
|
+
const params = [...url.searchParams.keys()];
|
|
100
|
+
if (params.length > FRONTIER_LIMITS.querySize) return reject('query_too_large');
|
|
101
|
+
if (params.some(name => ENUMERATING_PARAMS.test(name))) return reject('enumerating_parameter');
|
|
102
|
+
// robots is consulted last so a rejected URL is described by its own defect first.
|
|
103
|
+
if (robotsAllows && !robotsAllows(url.pathname + url.search)) return reject('robots_disallowed');
|
|
104
|
+
|
|
105
|
+
return { admit: true, reason, key, hostname, priority: priorityFor(reason, segments.length) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Lower sorts first. Seeds and sitemaps outrank pages we merely stumbled onto, and a
|
|
110
|
+
* shallow page outranks a deep one, because editorial resource pages sit near the root.
|
|
111
|
+
*/
|
|
112
|
+
export function priorityFor(reason, depth) {
|
|
113
|
+
const base = { seed: 0, sitemap: 100, refresh: 200, outlink: 300 }[reason] ?? 400;
|
|
114
|
+
return base + Math.min(depth, FRONTIER_LIMITS.pathDepth);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Selects the next batch to fetch: cheapest priority first, at most one URL per host per
|
|
119
|
+
* batch so a batch never stacks requests on one origin, and nothing whose host is still
|
|
120
|
+
* inside its crawl delay.
|
|
121
|
+
*
|
|
122
|
+
* @param entries [{ key, hostname, priority, nextEligibleAt }]
|
|
123
|
+
* @param now ISO timestamp.
|
|
124
|
+
* @param batchSize Maximum URLs to return.
|
|
125
|
+
*/
|
|
126
|
+
export function selectBatch(entries, { now, batchSize = 10 } = {}) {
|
|
127
|
+
const at = Date.parse(now);
|
|
128
|
+
if (!Number.isFinite(at)) throw new FrontierRejection('invalid_clock');
|
|
129
|
+
const ready = entries
|
|
130
|
+
.filter(entry => !entry.nextEligibleAt || Date.parse(entry.nextEligibleAt) <= at)
|
|
131
|
+
.sort((a, b) => a.priority - b.priority || (a.key < b.key ? -1 : 1));
|
|
132
|
+
const hosts = new Set(), batch = [];
|
|
133
|
+
for (const entry of ready) {
|
|
134
|
+
if (hosts.has(entry.hostname)) continue;
|
|
135
|
+
hosts.add(entry.hostname);
|
|
136
|
+
batch.push(entry);
|
|
137
|
+
if (batch.length >= batchSize) break;
|
|
138
|
+
}
|
|
139
|
+
return batch;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* When a host may next be fetched. A robots crawl delay is honoured as stated; otherwise a
|
|
144
|
+
* courtesy floor applies. Never returns a time earlier than the delay a publisher asked for.
|
|
145
|
+
*/
|
|
146
|
+
export function nextEligibleAt(now, { crawlDelaySeconds = null, courtesySeconds = 5 } = {}) {
|
|
147
|
+
const at = Date.parse(now);
|
|
148
|
+
if (!Number.isFinite(at)) throw new FrontierRejection('invalid_clock');
|
|
149
|
+
const seconds = Math.max(courtesySeconds, crawlDelaySeconds ?? 0);
|
|
150
|
+
return new Date(at + seconds * 1000).toISOString();
|
|
151
|
+
}
|
|
@@ -17,6 +17,7 @@ export const DISCOVERY_DATA_MODES = Object.freeze(['synthetic', 'owned_corpus',
|
|
|
17
17
|
export const IMPORT_SUPPLIERS = Object.freeze([
|
|
18
18
|
'ahrefs', 'google_search_console', 'bing_webmaster_tools', 'semrush', 'majestic', 'moz', 'dataforseo', 'linkody', 'csv',
|
|
19
19
|
]);
|
|
20
|
+
export const ImportTargetBasis = z.enum(['supplier_observed', 'user_supplied_target_expectation']);
|
|
20
21
|
|
|
21
22
|
// A data mode belongs to exactly one source, except fixtures, which any source may model.
|
|
22
23
|
const SOURCE_DATA_MODES = Object.freeze({ linktrail_corpus: 'owned_corpus', dataforseo: 'provider_index', imported: 'imported' });
|
|
@@ -92,12 +93,16 @@ export const DiscoveryCandidate = z.strictObject({
|
|
|
92
93
|
provider_metrics: z.union([
|
|
93
94
|
z.strictObject({ dataforseo: z.strictObject({
|
|
94
95
|
rank: count.max(1000).nullable(), page_from_rank: count.max(1000).nullable(),
|
|
96
|
+
domain_from: z.string().min(1).max(253).optional(),
|
|
95
97
|
domain_from_rank: count.max(1000).nullable(), backlink_spam_score: count.max(100).nullable(),
|
|
96
98
|
rank_scale: z.literal('one_thousand'),
|
|
97
99
|
}) }),
|
|
98
100
|
z.strictObject({ imported: z.strictObject({
|
|
99
101
|
supplier: z.enum(IMPORT_SUPPLIERS),
|
|
100
102
|
supplier_row_id: nullableText(256),
|
|
103
|
+
// Caller-authored destination provenance stays separate from opaque supplier columns.
|
|
104
|
+
// Older imports omit it and keep their exact destination behavior.
|
|
105
|
+
target_basis: ImportTargetBasis.optional(),
|
|
101
106
|
// What the EXPORT says about its own age, which is often nothing. `provider_retrieved_at`
|
|
102
107
|
// on the row above is when WE read the file; these are different facts and conflating
|
|
103
108
|
// them would let an import of a two-year-old export read as fresh data.
|
|
@@ -108,6 +113,9 @@ export const DiscoveryCandidate = z.strictObject({
|
|
|
108
113
|
// the scale is the supplier's name itself.
|
|
109
114
|
supplier_metrics: z.record(z.string().min(1).max(64), z.union([z.number(), z.string().max(256), z.null()]))
|
|
110
115
|
.refine(value => Object.keys(value).length <= 32, 'At most 32 supplier metrics.').nullable(),
|
|
116
|
+
}).superRefine((imported, ctx) => {
|
|
117
|
+
if (imported.target_basis === 'user_supplied_target_expectation' && imported.supplier !== 'google_search_console')
|
|
118
|
+
ctx.addIssue({ code: 'custom', message: 'A target expectation is supported only for Google Search Console imports.', path: ['target_basis'] });
|
|
111
119
|
}) }),
|
|
112
120
|
z.strictObject({ linktrail_corpus: z.strictObject({
|
|
113
121
|
// Real crawl facts. Outlink counts qualify a page; they are not an authority metric.
|
package/src/discovery/import.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// nothing here turns them into an AgentLinkOps score.
|
|
20
20
|
import { z } from 'zod';
|
|
21
21
|
import { validatePublicUrl, matchesTarget } from '../verifier/url.js';
|
|
22
|
-
import { DISCOVERY_VERSION, DISCOVERY_LIMITS, IMPORT_SUPPLIERS, DiscoveryCandidate, candidateId, sourceQueryTerms } from './contract.js';
|
|
22
|
+
import { DISCOVERY_VERSION, DISCOVERY_LIMITS, IMPORT_SUPPLIERS, ImportTargetBasis, DiscoveryCandidate, candidateId, sourceQueryTerms } from './contract.js';
|
|
23
23
|
|
|
24
24
|
export class ImportError extends Error {
|
|
25
25
|
constructor(code, status = 400) { super(code); this.name = 'ImportError'; this.code = code; this.status = status; }
|
|
@@ -30,6 +30,7 @@ const fail = (code, status) => { throw new ImportError(code, status); };
|
|
|
30
30
|
export const ImportRow = z.object({
|
|
31
31
|
source_url: z.string().max(4096),
|
|
32
32
|
target_url: z.string().max(4096),
|
|
33
|
+
target_basis: ImportTargetBasis.optional(),
|
|
33
34
|
anchor: z.string().max(4096).nullish(),
|
|
34
35
|
rel: z.union([z.string().max(1024), z.array(z.string().max(128)).max(32)]).nullish(),
|
|
35
36
|
dofollow: z.boolean().nullish(),
|
|
@@ -71,6 +72,16 @@ export function normalizeRow(row, { runId, workspaceId, projectId, supplier, tas
|
|
|
71
72
|
// A row about someone else's site is not a row about this import's target. Accepting it
|
|
72
73
|
// would quietly widen the run's scope past what its coverage statement describes.
|
|
73
74
|
if (!matchesTarget(destination.url, target, scope)) return { error: 'target_url: outside the scope of this import' };
|
|
75
|
+
if (value.target_basis === 'user_supplied_target_expectation') {
|
|
76
|
+
if (supplier !== 'google_search_console')
|
|
77
|
+
return { error: 'target_basis: a target expectation is supported only for Google Search Console imports' };
|
|
78
|
+
const expected = validatePublicUrl(target);
|
|
79
|
+
const siteScope = scope === 'domain' || scope === 'subdomain';
|
|
80
|
+
const rootExpectation = expected.valid && new URL(expected.url).pathname === '/' && !new URL(expected.url).search;
|
|
81
|
+
if (!expected.valid || expected.url !== target || (!siteScope && scope !== 'exact')
|
|
82
|
+
|| (siteScope && !rootExpectation) || destination.url !== target)
|
|
83
|
+
return { error: 'target_basis: the target expectation must match the canonical import target' };
|
|
84
|
+
}
|
|
74
85
|
|
|
75
86
|
const dates = {};
|
|
76
87
|
for (const field of ['first_seen', 'last_seen']) {
|
|
@@ -101,6 +112,7 @@ export function normalizeRow(row, { runId, workspaceId, projectId, supplier, tas
|
|
|
101
112
|
source_http_status: null, target_http_status: null, links_count: null,
|
|
102
113
|
provider_metrics: { imported: {
|
|
103
114
|
supplier, supplier_row_id: value.supplier_row_id ?? null,
|
|
115
|
+
...(supplier === 'google_search_console' && value.target_basis !== undefined ? { target_basis: value.target_basis } : {}),
|
|
104
116
|
supplier_generated_at: generatedAt, supplier_metrics: value.supplier_metrics ?? null,
|
|
105
117
|
} },
|
|
106
118
|
verification_status: 'not_checked', verified_at: null, observation_id: null, evidence_id: null,
|
package/src/mentions.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {pageQualityReason} from './verifier/page-quality.js';
|
|
2
|
+
import { analyzeReadiness } from './verifier/readiness.js';
|
|
3
|
+
// Unlinked mentions: a page that names the customer and does not link to them (DP-0009-T03).
|
|
4
|
+
//
|
|
5
|
+
// The [scope rule](../docs/PRODUCT-DIRECTION-2026-09-10.md) admits anything that fits declare,
|
|
6
|
+
// verify, watch, diff, and names reclamation first. This fits exactly, and it is the cheapest new
|
|
7
|
+
// observation available because both hard parts already exist: the verifier parses a page into
|
|
8
|
+
// per-occurrence links with raw rel tokens, and the corpus holds real pages to test against.
|
|
9
|
+
//
|
|
10
|
+
// **Everything interesting here is a refusal.** Finding the string is trivial; the work is in the
|
|
11
|
+
// four cases where finding it means nothing.
|
|
12
|
+
import { extractPage, STATIC_TEXT_LIMIT } from './page-extraction/index.js';
|
|
13
|
+
import { matchesTarget, validatePublicUrl, hasRedirectWrapperToTarget } from './verifier/url.js';
|
|
14
|
+
|
|
15
|
+
export const MENTION_LIMITS = Object.freeze({
|
|
16
|
+
occurrences: 25,
|
|
17
|
+
contextChars: 200,
|
|
18
|
+
brands: 5,
|
|
19
|
+
textChars: STATIC_TEXT_LIMIT,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export class MentionError extends Error {
|
|
23
|
+
constructor(code) { super(code); this.name = 'MentionError'; this.code = code; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Escapes a literal for use inside a regular expression. */
|
|
27
|
+
const literal = value => String(value).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where a needle appears in text, with bounded context and a character offset.
|
|
31
|
+
*
|
|
32
|
+
* Matching is word-bounded on both sides so `customer.com` does not match inside
|
|
33
|
+
* `notcustomer.community` — the same near-miss the URL layer refuses for links, applied to text.
|
|
34
|
+
*/
|
|
35
|
+
export function findOccurrences(text, needle, { limit = MENTION_LIMITS.occurrences, contextChars = MENTION_LIMITS.contextChars } = {}) {
|
|
36
|
+
const found = [];
|
|
37
|
+
// Counted past the cap on purpose. `found.length` is how many we KEPT, and reporting that as
|
|
38
|
+
// the number of mentions would turn a storage limit into a measurement — a page naming a brand
|
|
39
|
+
// eighty times would read as naming it twenty-five, and nothing in the output would say so.
|
|
40
|
+
let total = 0;
|
|
41
|
+
// The lookahead refuses a following label too, so `customer.com.evil.com` is not
|
|
42
|
+
// `customer.com` — the same near-miss the URL layer refuses for links. A trailing full stop at
|
|
43
|
+
// the end of a sentence still matches, because `\.?` consumes it and a space follows.
|
|
44
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{N}@._-])${literal(needle)}(?!\\.?[\\p{L}\\p{N}-])`, 'giu');
|
|
45
|
+
for (const match of String(text).matchAll(pattern)) {
|
|
46
|
+
total += 1;
|
|
47
|
+
if (found.length >= limit) continue;
|
|
48
|
+
const start = Math.max(0, match.index - Math.floor(contextChars / 2));
|
|
49
|
+
found.push({
|
|
50
|
+
offset: match.index,
|
|
51
|
+
text: match[0],
|
|
52
|
+
context: String(text).slice(start, start + contextChars).trim(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
found.total = total;
|
|
56
|
+
found.truncated = total > found.length;
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hostOf = url => { try { return new URL(url).hostname.replace(/^www\./u, ''); } catch { return null; } };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scans one fetched page for mentions of a target, and decides whether they are reclaimable.
|
|
64
|
+
*
|
|
65
|
+
* Takes HTML that has already been fetched — by the verifier, the crawler, or a caller holding a
|
|
66
|
+
* corpus page — because a mention scan should cost nothing when we already hold the bytes.
|
|
67
|
+
*
|
|
68
|
+
* @returns {{state: 'mentioned_not_linked'|'mentioned_and_linked'|'not_mentioned'|'unknown'}}
|
|
69
|
+
*/
|
|
70
|
+
export function scanMentions(html, pageUrl, { targetUrl, targetScope = 'domain', brands = [], receipt = null, bodyTruncated = false, textLimit = MENTION_LIMITS.textChars } = {}) {
|
|
71
|
+
const target = validatePublicUrl(targetUrl);
|
|
72
|
+
if (!target.valid) throw new MentionError(`invalid_target:${target.reason}`);
|
|
73
|
+
const page = validatePublicUrl(pageUrl);
|
|
74
|
+
if (!page.valid) throw new MentionError(`invalid_page:${page.reason}`);
|
|
75
|
+
|
|
76
|
+
const result = {
|
|
77
|
+
v: 1, state: 'unknown', reason: 'not_scanned', page_url: page.url, target_url: target.url,
|
|
78
|
+
target_scope: targetScope, occurrences: [], occurrence_count: 0, links_to_target: 0, complete: false,
|
|
79
|
+
provenance: { kind: receipt === null ? 'caller_supplied_html' : 'capture_receipt', qualified: false },
|
|
80
|
+
coverage: { documentComplete: false, textComplete: false, bodyComplete: receipt === null ? null : false },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (typeof html !== 'string') { result.reason = 'html_required'; return result; }
|
|
84
|
+
if (bodyTruncated === true) { result.reason = 'incomplete_body'; return result; }
|
|
85
|
+
let parsed, analysis;
|
|
86
|
+
try {
|
|
87
|
+
parsed = extractPage(html, { baseUrl: page.url, receipt, textLimit });
|
|
88
|
+
analysis = analyzeReadiness({ html, parsed, targetUrl: target.url });
|
|
89
|
+
}
|
|
90
|
+
catch (error) { result.reason = error?.reason ?? 'html_parse_error'; return result; }
|
|
91
|
+
result.coverage = parsed.coverage;
|
|
92
|
+
result.provenance.qualified = parsed.coverage.transportQualified;
|
|
93
|
+
if (receipt !== null) result.provenance.representation = parsed.provenance.receipt.representation;
|
|
94
|
+
|
|
95
|
+
// The verifier's "cannot conclude absence" rules carry over WHOLE, because the conclusion this
|
|
96
|
+
// tool draws is an absence: no link on this page. On a page we could not read completely, that
|
|
97
|
+
// is not a conclusion, and a reclamation tool that forgot it would generate outreach about
|
|
98
|
+
// links that are already there.
|
|
99
|
+
const qualityReason = pageQualityReason(parsed, analysis);
|
|
100
|
+
if (qualityReason) { result.reason = qualityReason; return result; }
|
|
101
|
+
// The complete link index alone cannot prove an absent mention when text hit its cap.
|
|
102
|
+
if (!parsed.coverage.textComplete) { result.reason = 'text_extraction_truncated'; return result; }
|
|
103
|
+
|
|
104
|
+
const linked = parsed.links.filter(link => matchesTarget(link.targetUrl, target.url, targetScope));
|
|
105
|
+
// A physical wrapper may already lead to this target. Complete captured bytes
|
|
106
|
+
// do not resolve that destination; never turn this hint into reclamation.
|
|
107
|
+
// Evaluate each target separately, and retain genuine direct-link precedence.
|
|
108
|
+
if (!linked.length && hasRedirectWrapperToTarget(parsed.links, target.url, targetScope)) {
|
|
109
|
+
result.reason = 'target_in_redirect_parameter';
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
result.complete = true;
|
|
113
|
+
result.links_to_target = linked.length;
|
|
114
|
+
|
|
115
|
+
// Reader-visible text only: a brand name inside a script, a comment or a JSON payload is not a
|
|
116
|
+
// mention anyone read.
|
|
117
|
+
const text = parsed.staticText.text;
|
|
118
|
+
const host = hostOf(target.url);
|
|
119
|
+
const needles = [];
|
|
120
|
+
if (host) needles.push({ kind: 'domain', value: host });
|
|
121
|
+
for (const brand of brands.slice(0, MENTION_LIMITS.brands)) {
|
|
122
|
+
const value = String(typeof brand === 'string' ? brand : brand?.value ?? '').trim();
|
|
123
|
+
const forced = typeof brand === 'object' && brand?.force === true;
|
|
124
|
+
if (!value) continue;
|
|
125
|
+
// **Length cannot tell a brand from an ordinary word.** "Settled" is seven characters,
|
|
126
|
+
// "Notion" and "Stripe" are six, and a customer called any of them cannot be matched on the
|
|
127
|
+
// word without a dictionary we do not have. So the rule is structural rather than a magic
|
|
128
|
+
// number: a SINGLE-TOKEN brand needs the customer to say explicitly that they mean it, and a
|
|
129
|
+
// multi-word one is distinctive enough on its own. The refusal is reported, so a customer
|
|
130
|
+
// whose brand produced nothing can see why.
|
|
131
|
+
if (!forced && !/\s/u.test(value)) {
|
|
132
|
+
result.rejected_brands = [...(result.rejected_brands ?? []),
|
|
133
|
+
{ brand: value, reason: 'single_word_brand_needs_force', note: 'A one-word brand cannot be told apart from ordinary use. Pass { value, force: true } to match it anyway.' }];
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
needles.push({ kind: 'brand', value });
|
|
137
|
+
}
|
|
138
|
+
let total = 0;
|
|
139
|
+
for (const needle of needles) {
|
|
140
|
+
const found = findOccurrences(text, needle.value);
|
|
141
|
+
total += found.total;
|
|
142
|
+
for (const occurrence of found) result.occurrences.push({ kind: needle.kind, matched: needle.value, ...occurrence });
|
|
143
|
+
}
|
|
144
|
+
const kept = result.occurrences.slice(0, MENTION_LIMITS.occurrences);
|
|
145
|
+
// Three separate numbers, because they answer three different questions: how many times the
|
|
146
|
+
// page names the target, how many of those we kept context for, and whether anything was cut.
|
|
147
|
+
result.occurrence_count = total;
|
|
148
|
+
result.occurrences_kept = kept.length;
|
|
149
|
+
result.occurrences_truncated = total > kept.length;
|
|
150
|
+
result.occurrences = kept;
|
|
151
|
+
|
|
152
|
+
if (!total) { result.state = 'not_mentioned'; result.reason = 'no_mention_in_complete_page'; return result; }
|
|
153
|
+
// A mention that is already linked is not an opportunity, it is a placement — and telling a
|
|
154
|
+
// customer to go ask for a link they already have is the fastest way to lose a publisher.
|
|
155
|
+
if (linked.length) {
|
|
156
|
+
result.state = 'mentioned_and_linked';
|
|
157
|
+
result.reason = 'mention_and_link_present';
|
|
158
|
+
result.anchors = linked.slice(0, 5).map(link => ({ anchor: link.anchor, rel: link.rel, href: link.href }));
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
result.state = 'mentioned_not_linked';
|
|
162
|
+
result.reason = 'mention_without_link_in_complete_page';
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Scans many already-fetched pages and reports the reclaimable ones.
|
|
168
|
+
*
|
|
169
|
+
* Deliberately takes pages rather than fetching: **a scan over bytes we already hold costs
|
|
170
|
+
* nothing and is not billed**, which is the pricing contract's fourth principle, and a corpus of
|
|
171
|
+
* two thousand pages is the obvious first place to point this.
|
|
172
|
+
*/
|
|
173
|
+
export function scanPages(pages, { targetUrl, targetScope = 'domain', brands = [] } = {}) {
|
|
174
|
+
const results = [], counts = {};
|
|
175
|
+
for (const page of pages) {
|
|
176
|
+
let scanned;
|
|
177
|
+
try { scanned = scanMentions(page.html, page.url, { targetUrl, targetScope, brands,
|
|
178
|
+
receipt: page.receipt ?? page.capture ?? null, bodyTruncated: page.body_truncated === true || page.bodyTruncated === true }); }
|
|
179
|
+
catch (error) { scanned = { state: 'unknown', reason: error?.code ?? 'scan_failed', page_url: page.url }; }
|
|
180
|
+
counts[scanned.state] = (counts[scanned.state] ?? 0) + 1;
|
|
181
|
+
if (scanned.state !== 'not_mentioned') results.push(scanned);
|
|
182
|
+
}
|
|
183
|
+
const readable = (counts.mentioned_not_linked ?? 0) + (counts.mentioned_and_linked ?? 0) + (counts.not_mentioned ?? 0);
|
|
184
|
+
return {
|
|
185
|
+
v: 1, target_url: targetUrl, target_scope: targetScope,
|
|
186
|
+
pages_scanned: pages.length, counts,
|
|
187
|
+
// Stated because the denominator is the whole question: 12 reclaimable out of 20 readable is
|
|
188
|
+
// a different fact from 12 out of 2,000, and an unknown is neither a yes nor a no.
|
|
189
|
+
coverage: { readable, unreadable: pages.length - readable },
|
|
190
|
+
reclaimable: results.filter(row => row.state === 'mentioned_not_linked'),
|
|
191
|
+
already_linked: results.filter(row => row.state === 'mentioned_and_linked').length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {publicFetcher} from '../verifier/fetch.js';
|
|
2
|
+
import {ACQUISITION_LIMITS,CAPTURE_VERSION,PageAcquisitionError,sha256Bytes,decodedDigest,boundedAcquisitionLimit,validateCaptureReceipt} from './contracts.js';
|
|
3
|
+
import {validatePublicUrl} from '../verifier/url.js';
|
|
4
|
+
import {acquisitionBudget,runRecovery} from './recovery.js';
|
|
5
|
+
import {verifyRenderedLinks,unknownLinkObservation} from '../verifier/index.js';
|
|
6
|
+
import {unknownTargetObservation} from '../verifier/target.js';
|
|
7
|
+
|
|
8
|
+
const budget=acquisitionBudget;
|
|
9
|
+
|
|
10
|
+
/** A bounded capture primitive. Owner jobs retain storage, entitlement and verdict authority. */
|
|
11
|
+
export function createPageAcquisition({fetchImpl=globalThis.fetch,publicFetchSafe=false,beforeFetch,onRobots,robotsCache=null,clock=Date.now,userAgentContact='https://agentlinkops.com',transportId='workers_http',transportVersion='1'}={}){
|
|
12
|
+
return {async acquire(url,{signal,timeoutMs=ACQUISITION_LIMITS.timeoutMs,maxBytes=ACQUISITION_LIMITS.maxBytes,deadlineAt=null,document=false,validators=null}={}){
|
|
13
|
+
if(publicFetchSafe!==true)throw new PageAcquisitionError('public_fetch_not_configured');
|
|
14
|
+
const checked=validatePublicUrl(url);if(!checked.valid)throw new PageAcquisitionError(`unsafe_url:${checked.reason}`);
|
|
15
|
+
// Documents (sitemaps) may ask for up to the document bound; pages keep the page bound.
|
|
16
|
+
const byteCap=boundedAcquisitionLimit(maxBytes,ACQUISITION_LIMITS.maxBytes,document?ACQUISITION_LIMITS.documentBytes:ACQUISITION_LIMITS.maxBytes);
|
|
17
|
+
const owner=budget({signal,timeoutMs,deadlineAt,clock});
|
|
18
|
+
const trace={finalUrl:checked.url,httpStatus:null,redirects:[]};
|
|
19
|
+
try{
|
|
20
|
+
owner.assert();
|
|
21
|
+
const result=await publicFetcher({fetchImpl,publicFetchSafe,beforeFetch,onRobots,robotsCache,maxBytes:byteCap,userAgentContact},owner.signal).source(checked.url,trace,{document,validators});
|
|
22
|
+
owner.assert();
|
|
23
|
+
if(result.notModified||!result.body)return {...result,trace,capture:null};
|
|
24
|
+
const {body}=result;
|
|
25
|
+
const capture={version:CAPTURE_VERSION,representation:document?'document':'raw_html',requestedUrl:checked.url,finalUrl:trace.finalUrl,httpStatus:trace.httpStatus,
|
|
26
|
+
acquiredAt:new Date(clock()).toISOString(),body:{complete:true,truncated:false,bytes:body.bytes,sha256:await sha256Bytes(body.data??new Uint8Array(0)),decodedSha256:await decodedDigest(body.text),charset:body.charset??null},
|
|
27
|
+
transport:{id:transportId,version:transportVersion,qualified:true},coverage:{mainDocument:true,iframes:false,shadowRoots:false}};
|
|
28
|
+
owner.assert();
|
|
29
|
+
return {...result,trace,capture:validateCaptureReceipt(capture)};
|
|
30
|
+
}catch(error){error.acquisitionTrace=trace;throw error;}finally{owner.close();}
|
|
31
|
+
}};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One logical observation, with a shared absolute budget across direct and alternate routes. */
|
|
35
|
+
export async function verifyWithAcquisition(inputs,options={},{verifyGroup,alternate=null,proxy=null,authorize=async()=>true,recoveryCapabilities={},renderAdmission,proxyAdmission,clock=Date.now}={}){
|
|
36
|
+
const raw=settings=>({...settings,renderPage:undefined,compareRendered:false});
|
|
37
|
+
const transport=(provider,method)=>({capability:recoveryCapabilities[method]??{available:!!provider,reason:'disabled'},receipts:provider?.receipts,
|
|
38
|
+
execute:settings=>verifyGroup(inputs,{...raw(settings),fetchImpl:(url,requestOptions)=>provider.fetch(url,{...requestOptions,deadlineAt:settings.deadlineAt}),
|
|
39
|
+
captureTransport:{id:method,version:'alo-public-node-v1'}})});
|
|
40
|
+
return runRecovery({purpose:options.acquisitionPurpose??'customer_monitor',options,authorize,clock,
|
|
41
|
+
directFailure:reason=>inputs.map(input=>input.sourceUrl===undefined?unknownTargetObservation(input,{now:options.now,reason}):unknownLinkObservation(input,{now:options.now,reason})),
|
|
42
|
+
executeDirect:settings=>verifyGroup(inputs,raw(settings)),routes:{
|
|
43
|
+
node_http:transport(alternate,'node_http'),proxy_http:{...transport(proxy,'proxy_http'),admit:proxyAdmission},
|
|
44
|
+
node_browser:{capability:recoveryCapabilities.node_browser??{available:!!options.renderPage,reason:'disabled'},admit:renderAdmission,
|
|
45
|
+
receipts:options.renderPage?.receipts,execute:async(settings,previous,indices)=>{
|
|
46
|
+
const observed=await verifyRenderedLinks(indices.map(i=>inputs[i]),indices.map(i=>previous[i]),settings);
|
|
47
|
+
const next=[...previous];indices.forEach((index,i)=>{next[index]=observed[i];});return next;
|
|
48
|
+
}},
|
|
49
|
+
}});
|
|
50
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Shared error identity, including callers of the legacy verifier/fetch.js facade. */
|
|
2
|
+
export class VerificationError extends Error {
|
|
3
|
+
constructor(reason, details = {}) {
|
|
4
|
+
super(reason);
|
|
5
|
+
this.name = 'VerificationError';
|
|
6
|
+
this.reason = reason;
|
|
7
|
+
Object.assign(this, details);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Cancellation is best effort and must not extend the owner's deadline. */
|
|
12
|
+
export function cancelBody(response) {
|
|
13
|
+
try { response.body?.cancel().catch(() => {}); } catch { /* Already locked/closed. */ }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Race every blocking phase against the same caller-owned signal. */
|
|
17
|
+
export function withAbort(promise, signal) {
|
|
18
|
+
if (!signal) return Promise.resolve(promise);
|
|
19
|
+
if (signal.aborted) return Promise.reject(new VerificationError('timeout'));
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
22
|
+
const onAbort = () => { cleanup(); reject(new VerificationError('timeout')); };
|
|
23
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
24
|
+
Promise.resolve(promise).then(
|
|
25
|
+
value => { cleanup(); resolve(value); },
|
|
26
|
+
error => { cleanup(); reject(error); },
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Consume a body without decoding it. A transport may expose decoded response bytes
|
|
33
|
+
* even with Content-Encoding present, so that header disables wire-length equality.
|
|
34
|
+
* The returned bytes describe exactly the representation the reader consumed.
|
|
35
|
+
*/
|
|
36
|
+
export async function readBoundedBytes(response, maxBytes, signal) {
|
|
37
|
+
const length = response.headers.get('content-length');
|
|
38
|
+
const encoded = response.headers.get('content-encoding');
|
|
39
|
+
if (!encoded && length && Number(length) > maxBytes) {
|
|
40
|
+
cancelBody(response);
|
|
41
|
+
throw new VerificationError('body_too_large');
|
|
42
|
+
}
|
|
43
|
+
const reader = response.body?.getReader();
|
|
44
|
+
if (!reader) return { data: new Uint8Array(0), bytes: 0 };
|
|
45
|
+
const chunks = [];
|
|
46
|
+
let bytes = 0;
|
|
47
|
+
try {
|
|
48
|
+
while (true) {
|
|
49
|
+
const { done, value } = await withAbort(reader.read(), signal);
|
|
50
|
+
if (done) break;
|
|
51
|
+
bytes += value.byteLength;
|
|
52
|
+
if (bytes > maxBytes) throw new VerificationError('body_too_large');
|
|
53
|
+
chunks.push(value);
|
|
54
|
+
}
|
|
55
|
+
if (!encoded && length && Number(length) !== bytes) throw new VerificationError('incomplete_body');
|
|
56
|
+
const data = new Uint8Array(bytes);
|
|
57
|
+
let offset = 0;
|
|
58
|
+
for (const chunk of chunks) { data.set(chunk, offset); offset += chunk.byteLength; }
|
|
59
|
+
return { data, bytes };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
reader.cancel().catch(() => {});
|
|
62
|
+
throw error;
|
|
63
|
+
} finally {
|
|
64
|
+
reader.releaseLock();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Decode only a complete bounded representation; invalid input is never empty HTML. */
|
|
69
|
+
export function decodeBytes(data, charset = 'utf-8') {
|
|
70
|
+
try { return new TextDecoder(charset, { fatal: true }).decode(data); }
|
|
71
|
+
catch { throw new VerificationError('unsupported_or_invalid_encoding'); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Existing verifier API, including its no-body return shape and charset behavior. */
|
|
75
|
+
export async function readBounded(response, maxBytes, signal) {
|
|
76
|
+
const body = await readBoundedBytes(response, maxBytes, signal);
|
|
77
|
+
if (!response.body) return { text: '', bytes: 0 };
|
|
78
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
79
|
+
const charset = /charset\s*=\s*["']?([^\s;"']+)/iu.exec(contentType)?.[1] ?? 'utf-8';
|
|
80
|
+
return { text: decodeBytes(body.data, charset), bytes: body.bytes, data: body.data };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A refusal may retain a small diagnostic prefix; it is never a complete page capture. */
|
|
84
|
+
export async function readBodyDiagnostic(response, signal, maxBytes = 64 * 1024) {
|
|
85
|
+
const reader=response.body?.getReader(),chunks=[];
|
|
86
|
+
const controller=new AbortController(),abort=()=>controller.abort();
|
|
87
|
+
signal?.addEventListener('abort',abort,{once:true});
|
|
88
|
+
if(signal?.aborted)abort();
|
|
89
|
+
// Diagnostics must not consume the recovery window or wait for a publisher
|
|
90
|
+
// to finish an error stream. The caller's earlier deadline still wins.
|
|
91
|
+
const timer=setTimeout(abort,250);
|
|
92
|
+
let bytes=0,complete=!reader,truncated=false;
|
|
93
|
+
try {
|
|
94
|
+
if(reader)while(true){
|
|
95
|
+
const {done,value}=await withAbort(reader.read(),controller.signal);
|
|
96
|
+
if(done){complete=true;break;}
|
|
97
|
+
const keep=Math.min(value.byteLength,maxBytes-bytes);
|
|
98
|
+
if(keep){chunks.push(value.slice(0,keep));bytes+=keep;}
|
|
99
|
+
if(keep<value.byteLength||bytes===maxBytes){truncated=true;reader.cancel().catch(()=>{});break;}
|
|
100
|
+
}
|
|
101
|
+
}catch(error){
|
|
102
|
+
reader?.cancel().catch(()=>{});
|
|
103
|
+
if(signal?.aborted||!controller.signal.aborted)throw error;
|
|
104
|
+
truncated=true;
|
|
105
|
+
}finally{clearTimeout(timer);signal?.removeEventListener('abort',abort);reader?.releaseLock();}
|
|
106
|
+
const data=new Uint8Array(bytes);let offset=0;
|
|
107
|
+
for(const chunk of chunks){data.set(chunk,offset);offset+=chunk.byteLength;}
|
|
108
|
+
const length=response.headers.get('content-length');
|
|
109
|
+
if(!response.headers.get('content-encoding')&&length&&Number(length)!==bytes)complete=false;
|
|
110
|
+
const sha256=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',data)),b=>b.toString(16).padStart(2,'0')).join('');
|
|
111
|
+
const text=new TextDecoder('utf-8').decode(data);
|
|
112
|
+
// Saved diagnostics carry facts and a digest, never publisher/error text or cookies.
|
|
113
|
+
return {version:1,bytes,complete,truncated,sha256,
|
|
114
|
+
looksLikeHtml:/<(?:!doctype\s+html|html|body|head)\b/iu.test(text)};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Inflate incrementally and apply the cap to output chunks before retaining them.
|
|
119
|
+
* Unlike synchronous gunzip, this never materializes an unbounded expanded document.
|
|
120
|
+
* Web streams keep the implementation usable in both Workers and Node runtimes.
|
|
121
|
+
*/
|
|
122
|
+
export async function inflateGzipBounded(data, maxBytes, signal) {
|
|
123
|
+
if (signal?.aborted) throw new VerificationError('timeout');
|
|
124
|
+
// Feed small slices: a transform must not be handed an entire compressed bomb in
|
|
125
|
+
// one write before its output consumer has an opportunity to enforce the bound.
|
|
126
|
+
let offset = 0;
|
|
127
|
+
const compressed = new ReadableStream({
|
|
128
|
+
pull(controller) {
|
|
129
|
+
if (signal?.aborted) { controller.error(new VerificationError('timeout')); return; }
|
|
130
|
+
if (offset >= data.length) { controller.close(); return; }
|
|
131
|
+
const next = Math.min(offset + 1024, data.length);
|
|
132
|
+
controller.enqueue(data.subarray(offset, next));
|
|
133
|
+
offset = next;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const expanded = compressed.pipeThrough(new DecompressionStream('gzip'));
|
|
137
|
+
return readBoundedBytes(new Response(expanded), maxBytes, signal);
|
|
138
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {CAPTURE_VERSION} from './contracts.js';
|
|
2
|
+
|
|
3
|
+
/** Constructed only after the bounded reader has finished and both digests exist. */
|
|
4
|
+
export function capturedDocument(result,{requestedUrl,representation='raw_html',transportId='public_http',transportVersion='1',qualified=false,charset=null}={}){
|
|
5
|
+
const evidence=result.evidence;
|
|
6
|
+
if(!evidence||!Number.isSafeInteger(evidence.bytes)||!evidence.sha256||!evidence.decodedSha256)return null;
|
|
7
|
+
return {version:CAPTURE_VERSION,representation,requestedUrl,finalUrl:result.finalUrl,httpStatus:result.httpStatus,
|
|
8
|
+
acquiredAt:evidence.fetchedAt??result.checkedAt,
|
|
9
|
+
body:{complete:true,truncated:false,bytes:evidence.bytes,sha256:evidence.sha256,decodedSha256:evidence.decodedSha256,charset},
|
|
10
|
+
transport:{id:transportId,version:transportVersion,qualified},coverage:{mainDocument:true,iframes:false,shadowRoots:false}};
|
|
11
|
+
}
|