agentlinkops 0.6.1 → 0.6.3
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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +0 -0
- package/CHANGELOG.md +17 -0
- package/LICENSE +0 -0
- package/NOTICE +0 -0
- package/README.md +0 -0
- package/bin/agentlinkops.mjs +0 -0
- package/bin/linktrail.mjs +0 -0
- package/cli/adapters/csv.js +0 -0
- package/cli/adapters/sqlite-crm.js +0 -0
- package/cli/adapters/suppliers.js +0 -0
- package/cli/admission.js +130 -0
- package/cli/agent-setup.js +1014 -101
- package/cli/agentlinkops.mjs +0 -0
- package/cli/args.js +0 -0
- package/cli/catalog.js +12837 -4694
- package/cli/check.js +0 -0
- package/cli/citation.js +181 -32
- package/cli/client.js +1 -1
- package/cli/commands.js +3 -3
- package/cli/config.js +2 -1
- package/cli/connection.js +0 -0
- package/cli/context-mcp.js +0 -0
- package/cli/context.js +0 -0
- package/cli/delivery.js +0 -0
- package/cli/disavow.js +54 -0
- package/cli/doctor.js +7 -8
- package/cli/env.js +0 -0
- package/cli/fleet.js +0 -0
- package/cli/gsc-browser.js +0 -0
- package/cli/import.js +0 -0
- package/cli/index-observations.js +89 -0
- package/cli/ledger.js +7 -1
- package/cli/lifecycle.js +40 -0
- package/cli/linktrail.mjs +0 -0
- package/cli/local-result.js +0 -0
- package/cli/main.js +99 -7
- package/cli/mirror.js +0 -0
- package/cli/mix.js +0 -0
- package/cli/platforms.js +0 -0
- package/cli/receipt-performance.js +0 -0
- package/cli/receipts.js +0 -0
- package/cli/report.js +79 -6
- package/cli/setup-plan.js +0 -0
- package/cli/skill.js +41 -4
- package/cli/state.js +0 -0
- package/cli/status.js +0 -0
- package/cli/sync.js +23 -1
- package/package.json +3 -2
- package/references/citation-panels.md +26 -0
- package/references/cli.md +0 -0
- package/references/disavow.md +0 -0
- package/references/discovery-handoff.md +32 -0
- package/references/recipes/catalog.json +148 -0
- package/references/recipes/command-notes.md +9 -0
- package/references/recipes/placement-reconciliation.md +41 -0
- package/references/recipes/qualified-campaign-handoff.md +56 -0
- package/references/recipes/site-context-brief.md +25 -0
- package/references/recipes/sourced-linkable-asset.md +25 -0
- package/references/reports.md +0 -0
- package/scripts/agentlinkops.py +0 -0
- package/scripts/disavow.py +0 -0
- package/scripts/linktrail.py +0 -0
- package/scripts/migrations/001_crm.sql +0 -0
- package/scripts/migrations/002_cloud_events.sql +0 -0
- package/scripts/migrations/003_disavow.sql +0 -0
- package/scripts/migrations/004_placement_costs.sql +0 -0
- package/scripts/report.py +0 -0
- package/shared/admission-contract.js +16 -0
- package/shared/admission-report-contract.js +8 -0
- package/shared/agent-clients.js +0 -0
- package/shared/citation-evidence-contract.js +27 -0
- package/shared/disavow-contract.js +18 -0
- package/shared/index-observation-contract.js +14 -0
- package/shared/lifecycle-contract.js +22 -0
- package/shared/lifecycle-money.js +24 -0
- package/shared/observation-event.js +12 -1
- package/shared/opportunity-record.js +259 -0
- package/shared/response-contract.js +2 -0
- package/shared/search-connection-contract.js +0 -0
- package/skills/agentlinkops-assets/SKILL.md +2 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
- package/skills/agentlinkops-campaigns/SKILL.md +5 -1
- package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/SKILL.md +8 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
- package/skills/agentlinkops-crm/SKILL.md +2 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
- package/skills/agentlinkops-discovery/SKILL.md +17 -0
- package/skills/content-defingerprinting/SKILL.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
- package/skills/internal-linking-optimizer/SKILL.md +0 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
- package/skills/pre-publish-review/SKILL.md +0 -0
- package/skills/source-cited-content-builder/SKILL.md +0 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
- package/skills/source-cited-humanizer/SKILL.md +0 -0
- package/src/citations/adapter.js +102 -21
- package/src/citations/alert.js +38 -13
- package/src/citations/browser/engine.js +145 -50
- package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
- package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
- package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
- package/src/citations/browser/gen/core/providers/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
- package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
- package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
- package/src/citations/browser/gen/lib/browser/display.js +0 -0
- package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
- package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
- package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
- package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
- package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
- package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
- package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
- package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
- package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
- package/src/citations/browser/gen/utils/agent-constants.js +0 -0
- package/src/citations/browser/providers.js +3 -1
- package/src/citations/browser/shims/errors.js +0 -0
- package/src/citations/browser/shims/types.js +0 -0
- package/src/citations/browser/shims/utils.js +1 -0
- package/src/citations/contract.js +55 -9
- package/src/citations/inventory.js +106 -0
- package/src/citations/login.js +122 -0
- package/src/citations/match.js +12 -0
- package/src/citations/panel-builder.js +44 -39
- package/src/citations/report.js +88 -21
- package/src/citations/runner.js +59 -31
- package/src/citations/stats.js +133 -61
- package/src/citations/sync.js +131 -0
- package/src/citations/watch.js +57 -77
- package/src/competitors/contract.js +0 -0
- package/src/competitors/domain-mix.js +0 -0
- package/src/context/ga4.js +0 -0
- package/src/context/gsc.js +0 -0
- package/src/context/site-profile.js +0 -0
- package/src/context/util.js +0 -0
- package/src/context-tool-registry.js +0 -0
- package/src/corpus/acquire.js +47 -8
- package/src/corpus/seeds.js +132 -19
- package/src/discovery/contract.js +0 -0
- package/src/discovery/import.js +0 -0
- package/src/evidence-reference.js +112 -0
- package/src/index-observation.js +161 -0
- package/src/locator.js +112 -0
- package/src/tool-views.js +6 -2
- package/src/verifier/fetch.js +42 -6
- package/src/verifier/index.js +0 -0
- package/src/verifier/observation-kind.js +0 -0
- package/src/verifier/parser.js +0 -0
- package/src/verifier/readiness.js +0 -0
- package/src/verifier/render-evidence.js +0 -0
- package/src/verifier/robots.js +0 -0
- package/src/verifier/transition.js +0 -0
- package/src/verifier/url.js +0 -0
- package/templates/SITE.template.md +0 -0
- package/templates/campaigns.v1.json +0 -0
- package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
- package/src/citations/browser/gen/core/providers/types.js +0 -0
- package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
- package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
- package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
- package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
- package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
- package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
- package/src/citations/fixtures/dataforseo-aio.json +0 -82
package/src/corpus/acquire.js
CHANGED
|
@@ -13,8 +13,11 @@ export const FETCH_KINDS = Object.freeze(['direct', 'rendered', 'proxied']);
|
|
|
13
13
|
export const ACQUISITION_LIMITS = Object.freeze({ maxBytes: 2 * 1024 * 1024, timeoutMs: 20_000, maxRedirects: 5 });
|
|
14
14
|
|
|
15
15
|
// Every way a page attempt can end. The frontier records the outcome; none of them is ever
|
|
16
|
-
// allowed to reach the corpus as "this page has no links".
|
|
17
|
-
|
|
16
|
+
// allowed to reach the corpus as "this page has no links". `unchanged` is the conditional-GET
|
|
17
|
+
// end: a 304 against validators we sent. It is a real observation with provenance, and it is
|
|
18
|
+
// recorded in the refresh ledger (corpus_refresh_checks), never as a corpus_pages row — a page
|
|
19
|
+
// we did not re-read is not a page we fetched, and the pages table has no vocabulary for it.
|
|
20
|
+
export const OUTCOMES = Object.freeze(['fetched', 'not_found', 'blocked', 'unavailable', 'not_configured', 'deferred', 'unchanged']);
|
|
18
21
|
|
|
19
22
|
const BLOCKED_REASONS = /^(robots_disallowed|robots_|access_challenge|source_http_(401|403|429))/;
|
|
20
23
|
// A slot we could not take is not the publisher's fault and not a defect: retry it later.
|
|
@@ -48,7 +51,7 @@ export function createAcquisition(options = {}) {
|
|
|
48
51
|
const runtimes = options.runtimes ?? {};
|
|
49
52
|
const clock = options.now ?? (() => new Date().toISOString());
|
|
50
53
|
|
|
51
|
-
async function direct(url, beforeFetch) {
|
|
54
|
+
async function direct(url, beforeFetch, { validators = null, document = false } = {}) {
|
|
52
55
|
// The public boundary is a property of the transport, not of this module. Refuse rather
|
|
53
56
|
// than fetch on an unasserted one; a lexically public hostname can still resolve inside.
|
|
54
57
|
if (options.publicFetchSafe !== true) throw new AcquisitionError('public_fetch_boundary_not_asserted');
|
|
@@ -57,9 +60,12 @@ export function createAcquisition(options = {}) {
|
|
|
57
60
|
const trace = { finalUrl: url, redirects: [], robots: null, httpStatus: null };
|
|
58
61
|
try {
|
|
59
62
|
const fetcher = publicFetcher({ ...options, beforeFetch: beforeFetch ?? options.beforeFetch, maxBytes, maxRedirects: ACQUISITION_LIMITS.maxRedirects }, controller.signal);
|
|
60
|
-
const { response, body } = await fetcher.source(url, trace);
|
|
63
|
+
const { response, body, notModified, validators: earned } = await fetcher.source(url, trace, { validators, document });
|
|
64
|
+
if (notModified) return { outcome: 'unchanged', trace, html: null, validators: earned };
|
|
61
65
|
if (NOT_FOUND.has(response.status)) return { outcome: 'not_found', trace, html: null };
|
|
62
|
-
return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0
|
|
66
|
+
return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0,
|
|
67
|
+
linkHeader: response.headers.get('link'),
|
|
68
|
+
validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
|
|
63
69
|
} finally {
|
|
64
70
|
clearTimeout(timer);
|
|
65
71
|
}
|
|
@@ -93,20 +99,26 @@ export function createAcquisition(options = {}) {
|
|
|
93
99
|
/**
|
|
94
100
|
* Acquires one page. Always resolves: an attempt that failed is a recorded outcome, not
|
|
95
101
|
* a thrown error, because the corpus must keep attempted outcomes and not only successes.
|
|
102
|
+
*
|
|
103
|
+
* `{ validators }` makes the direct path a conditional GET: pass the ETag/Last-Modified a
|
|
104
|
+
* previous fetch of this URL earned, and an unchanged page comes back as outcome
|
|
105
|
+
* `unchanged` with `http_status` 304 and no body — a dated observation, not an absence.
|
|
96
106
|
*/
|
|
97
|
-
async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null } = {}) {
|
|
107
|
+
async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null, validators = null } = {}) {
|
|
98
108
|
const at = clock();
|
|
99
109
|
const base = { url: rawUrl, final_url: null, kind, http_status: null, robots: null,
|
|
100
|
-
html: null, body_truncated: false, fetched_at: at, reason: null };
|
|
110
|
+
html: null, body_truncated: false, fetched_at: at, reason: null, etag: null, last_modified: null };
|
|
101
111
|
if (!FETCH_KINDS.includes(kind)) return { ...base, outcome: 'unavailable', reason: 'unsupported_fetch_kind' };
|
|
102
112
|
const checked = validatePublicUrl(rawUrl);
|
|
103
113
|
if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
|
|
104
114
|
|
|
105
115
|
try {
|
|
106
116
|
if (kind === 'direct') {
|
|
107
|
-
const { outcome, trace, html } = await direct(checked.url, beforeFetch);
|
|
117
|
+
const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators });
|
|
108
118
|
return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
|
|
109
119
|
robots: trace.robots, redirects: trace.redirects, html,
|
|
120
|
+
etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
|
|
121
|
+
link_header: linkHeader ?? null,
|
|
110
122
|
// The verifier's reader refuses an oversized body outright, so a returned body was
|
|
111
123
|
// read to the end. Truncation reaches extraction from runtimes with their own caps.
|
|
112
124
|
body_truncated: false, render_ms: 0, proxy_bytes: 0 };
|
|
@@ -131,5 +143,32 @@ export function createAcquisition(options = {}) {
|
|
|
131
143
|
return { ...base, outcome, reason };
|
|
132
144
|
}
|
|
133
145
|
},
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Acquires one non-page document: a sitemap or an RSS/Atom feed. Same transport, same
|
|
149
|
+
* robots-per-hop, same public boundary and same outcome vocabulary as `fetchPage`; only
|
|
150
|
+
* the Accept header and the content-type gate differ, because the XML family is the
|
|
151
|
+
* expected representation here. Conditional GET works the same way — feeds revalidate too.
|
|
152
|
+
*
|
|
153
|
+
* The crawl loop never calls this; it exists for the freshness lanes (sitemap diffing,
|
|
154
|
+
* feed polling) that read publisher declarations rather than pages.
|
|
155
|
+
*/
|
|
156
|
+
async fetchDocument(rawUrl, { beforeFetch = null, validators = null } = {}) {
|
|
157
|
+
const at = clock();
|
|
158
|
+
const base = { url: rawUrl, final_url: null, kind: 'direct', http_status: null,
|
|
159
|
+
body: null, fetched_at: at, reason: null, etag: null, last_modified: null };
|
|
160
|
+
const checked = validatePublicUrl(rawUrl);
|
|
161
|
+
if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
|
|
162
|
+
try {
|
|
163
|
+
const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators, document: true });
|
|
164
|
+
return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
|
|
165
|
+
body: outcome === 'fetched' ? html : null,
|
|
166
|
+
etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
|
|
167
|
+
link_header: linkHeader ?? null };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const reason = typeof error?.reason === 'string' ? error.reason : 'acquisition_error';
|
|
170
|
+
return { ...base, outcome: classifyFailure(reason), reason };
|
|
171
|
+
}
|
|
172
|
+
},
|
|
134
173
|
};
|
|
135
174
|
}
|
package/src/corpus/seeds.js
CHANGED
|
@@ -18,7 +18,81 @@ export class SeedError extends Error {
|
|
|
18
18
|
constructor(reason) { super(reason); this.name = 'SeedError'; this.reason = reason; }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// The seed-composition cap (DP-0049-T01 Fix 2). The birding corpus took 32.3% of its pages
|
|
22
|
+
// from six .edu library-guide seed hosts and the citation graph followed the seeds: 59 of 100
|
|
23
|
+
// shortlist rows were library infrastructure. The cap keeps one seed CLASS from dominating a
|
|
24
|
+
// corpus no matter how large its sitemaps are, without touching the per-host cap that keeps
|
|
25
|
+
// one large publisher from doing the same.
|
|
26
|
+
export const SEED_COMPOSITION = Object.freeze({ maxShare: 1 / 3 });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses a seeds file into ordered `{ host, class }` records. Every comment line opens a
|
|
30
|
+
* class for the host lines that follow it, so the class headers the earlier seed files
|
|
31
|
+
* already carried de facto ("--- surfaced by Q1 ... ---") became the explicit unit the
|
|
32
|
+
* composition cap enforces. A bare `#` with nothing after it resets to `unclassified`.
|
|
33
|
+
*/
|
|
34
|
+
export function parseSeedHosts(text) {
|
|
35
|
+
if (typeof text !== 'string') throw new SeedError('seeds_not_text');
|
|
36
|
+
const hosts = [];
|
|
37
|
+
let klass = 'unclassified';
|
|
38
|
+
for (const line of text.split(/\r?\n/u)) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed) continue;
|
|
41
|
+
if (trimmed.startsWith('#')) {
|
|
42
|
+
const label = trimmed.replace(/^#+\s*/u, '').trim();
|
|
43
|
+
klass = label || 'unclassified';
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
hosts.push({ host: trimmed.toLowerCase(), class: klass });
|
|
47
|
+
}
|
|
48
|
+
return hosts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Caps each class's seed-URL contribution. `classHosts` is one record per class in file
|
|
53
|
+
* order: `{ class, hosts: [[url, ...], ...] }` — the per-host URL lists AFTER the per-host
|
|
54
|
+
* cap, in admission order. A class over the cap is down-sampled round-robin across its
|
|
55
|
+
* hosts so the cut lands on every host evenly instead of beheading the last few; the share
|
|
56
|
+
* is computed against the PRE-CAP total and nothing is redistributed to other classes, so
|
|
57
|
+
* the rule is deterministic and auditable. A single-class run is never cut: one class cannot
|
|
58
|
+
* dominate itself.
|
|
59
|
+
*
|
|
60
|
+
* @returns The kept URLs in class order, and the per-class accounting the run report carries.
|
|
61
|
+
*/
|
|
62
|
+
export function capSeedComposition(classHosts, { maxShare = SEED_COMPOSITION.maxShare } = {}) {
|
|
63
|
+
if (!Array.isArray(classHosts) || classHosts.some(entry => !entry || !Array.isArray(entry.hosts))) {
|
|
64
|
+
throw new SeedError('seed_classes_malformed');
|
|
65
|
+
}
|
|
66
|
+
const total = classHosts.reduce((sum, entry) => sum + entry.hosts.reduce((n, urls) => n + urls.length, 0), 0);
|
|
67
|
+
const cap = Math.max(1, Math.floor(maxShare * total));
|
|
68
|
+
const multiClass = classHosts.filter(entry => entry.hosts.some(urls => urls.length)).length > 1;
|
|
69
|
+
const kept = [], perClass = [];
|
|
70
|
+
for (const entry of classHosts) {
|
|
71
|
+
const offered = entry.hosts.reduce((n, urls) => n + urls.length, 0);
|
|
72
|
+
let keptUrls = [];
|
|
73
|
+
if (multiClass && offered > cap) {
|
|
74
|
+
// Round-robin one URL per host per turn, hosts in file order, until the cap is hit.
|
|
75
|
+
const cursors = entry.hosts.map(() => 0);
|
|
76
|
+
while (keptUrls.length < cap) {
|
|
77
|
+
let advanced = false;
|
|
78
|
+
for (let h = 0; h < entry.hosts.length && keptUrls.length < cap; h++) {
|
|
79
|
+
if (cursors[h] < entry.hosts[h].length) {
|
|
80
|
+
keptUrls.push(entry.hosts[h][cursors[h]++]);
|
|
81
|
+
advanced = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!advanced) break;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
keptUrls = entry.hosts.flat();
|
|
88
|
+
}
|
|
89
|
+
kept.push(...keptUrls);
|
|
90
|
+
perClass.push({ class: entry.class, offered, kept: keptUrls.length, cut: offered - keptUrls.length });
|
|
91
|
+
}
|
|
92
|
+
return { kept, cap, applied: multiClass && perClass.some(entry => entry.cut > 0), perClass };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function decodeXml(value) {
|
|
22
96
|
return value.replace(/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]+));/giu, (whole, dec, hex, name) => {
|
|
23
97
|
if (dec || hex) {
|
|
24
98
|
const code = Number.parseInt(dec ?? hex, dec ? 10 : 16);
|
|
@@ -84,7 +158,7 @@ export function withinSitemapScope(candidate, sitemapUrl, scope = 'path') {
|
|
|
84
158
|
*
|
|
85
159
|
* @throws SeedError when the document is too large or is not a sitemap at all.
|
|
86
160
|
*/
|
|
87
|
-
export function
|
|
161
|
+
export function parseSitemapEntries(input, { baseUrl, enforceScope = true, scope = 'path' } = {}) {
|
|
88
162
|
if (typeof input !== 'string') throw new SeedError('sitemap_not_text');
|
|
89
163
|
if (input.length > SEED_LIMITS.documentBytes) throw new SeedError('sitemap_too_large');
|
|
90
164
|
// A byte-order mark before the root element defeats every subsequent match.
|
|
@@ -98,12 +172,24 @@ export function parseSitemap(input, { baseUrl, enforceScope = true, scope = 'pat
|
|
|
98
172
|
if (indexAt < 0 && setAt < 0) return parseTextSitemap(xml, { baseUrl, enforceScope, scope });
|
|
99
173
|
const kind = indexAt >= 0 && (setAt < 0 || indexAt < setAt) ? 'index' : 'urlset';
|
|
100
174
|
|
|
101
|
-
const
|
|
175
|
+
const entries = [];
|
|
176
|
+
const seen = new Set();
|
|
102
177
|
const skipped = {};
|
|
103
178
|
let truncated = false;
|
|
179
|
+
// Collected rather than iterated lazily because each entry's lastmod is searched in the
|
|
180
|
+
// region up to the NEXT `<loc>`, which needs lookahead. Collection stops one past the cap:
|
|
181
|
+
// a document with more `<loc>` blocks than the cap is knowably truncated without reading
|
|
182
|
+
// all of them, which is the same signal the lazy scan's early break used to produce.
|
|
104
183
|
const pattern = /<\s*loc\s*>([\s\S]*?)<\s*\/\s*loc\s*>/giu;
|
|
184
|
+
const matches = [];
|
|
105
185
|
for (const match of xml.matchAll(pattern)) {
|
|
106
|
-
|
|
186
|
+
matches.push(match);
|
|
187
|
+
if (matches.length > SEED_LIMITS.urlsPerDocument) break;
|
|
188
|
+
}
|
|
189
|
+
const lastmodPattern = /<\s*lastmod\s*>([\s\S]*?)<\s*\/\s*lastmod\s*>/iu;
|
|
190
|
+
for (let index = 0; index < matches.length; index++) {
|
|
191
|
+
const match = matches[index];
|
|
192
|
+
if (entries.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
|
|
107
193
|
let raw = decodeXml(match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gu, '$1')).trim();
|
|
108
194
|
if (!raw) continue;
|
|
109
195
|
// The protocol requires an absolute URL here. Resolving a relative value against the
|
|
@@ -114,9 +200,27 @@ export function parseSitemap(input, { baseUrl, enforceScope = true, scope = 'pat
|
|
|
114
200
|
skipped.out_of_sitemap_scope = (skipped.out_of_sitemap_scope ?? 0) + 1;
|
|
115
201
|
continue;
|
|
116
202
|
}
|
|
117
|
-
if (
|
|
203
|
+
if (seen.has(checked.url)) continue;
|
|
204
|
+
seen.add(checked.url);
|
|
205
|
+
// A lastmod belongs to the `<url>` (or `<sitemap>`) block its `<loc>` opens; the region
|
|
206
|
+
// ends where the next entry begins. Captured RAW: this is the publisher's declared change
|
|
207
|
+
// time, parsed where it is spent (`parseTimestamp` in freshness.js), and an unparsable
|
|
208
|
+
// claim is counted there rather than silently dropped here.
|
|
209
|
+
const region = xml.slice(match.index + match[0].length, matches[index + 1]?.index ?? xml.length);
|
|
210
|
+
const lastmodRaw = lastmodPattern.exec(region)?.[1]?.trim() ?? null;
|
|
211
|
+
entries.push({ loc: checked.url, lastmod: lastmodRaw });
|
|
118
212
|
}
|
|
119
|
-
|
|
213
|
+
// The loop can fill the cap on the LAST collected match, with nothing left to iterate and
|
|
214
|
+
// trip the in-loop break. Collection stopping at cap+1 is itself the proof that more
|
|
215
|
+
// `<loc>` blocks existed, which is exactly what truncated means.
|
|
216
|
+
if (!truncated && entries.length >= SEED_LIMITS.urlsPerDocument && matches.length > SEED_LIMITS.urlsPerDocument) truncated = true;
|
|
217
|
+
return { kind, entries, truncated, skipped };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The URL-only view of a sitemap, for callers that do not diff by lastmod. */
|
|
221
|
+
export function parseSitemap(input, options = {}) {
|
|
222
|
+
const { kind, entries, truncated, skipped } = parseSitemapEntries(input, options);
|
|
223
|
+
return { kind, urls: entries.map(entry => entry.loc), truncated, skipped };
|
|
120
224
|
}
|
|
121
225
|
|
|
122
226
|
/** A sitemap entry must stand on its own as a public absolute URL. */
|
|
@@ -125,37 +229,42 @@ function absoluteEntry(value) {
|
|
|
125
229
|
return validatePublicUrl(value);
|
|
126
230
|
}
|
|
127
231
|
|
|
128
|
-
/** The protocol's plain-text format: one absolute URL per line, no markup. */
|
|
232
|
+
/** The protocol's plain-text format: one absolute URL per line, no markup. No lastmod. */
|
|
129
233
|
function parseTextSitemap(text, { baseUrl, enforceScope, scope = 'path' }) {
|
|
130
|
-
const
|
|
234
|
+
const entries = [], seen = new Set(), skipped = {};
|
|
131
235
|
let truncated = false;
|
|
132
236
|
for (const line of text.split(/\r?\n/u)) {
|
|
133
237
|
const value = line.trim();
|
|
134
238
|
if (!value || value.startsWith('#')) continue;
|
|
135
239
|
// Anything with markup is not a text sitemap; do not half-read an unknown document.
|
|
136
240
|
if (value.startsWith('<')) throw new SeedError('not_a_sitemap');
|
|
137
|
-
if (
|
|
241
|
+
if (entries.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
|
|
138
242
|
const checked = absoluteEntry(value);
|
|
139
243
|
if (!checked.valid) { skipped[checked.reason] = (skipped[checked.reason] ?? 0) + 1; continue; }
|
|
140
244
|
if (enforceScope && baseUrl && !withinSitemapScope(checked.url, baseUrl, scope)) {
|
|
141
245
|
skipped.out_of_sitemap_scope = (skipped.out_of_sitemap_scope ?? 0) + 1;
|
|
142
246
|
continue;
|
|
143
247
|
}
|
|
144
|
-
if (
|
|
248
|
+
if (seen.has(checked.url)) continue;
|
|
249
|
+
seen.add(checked.url);
|
|
250
|
+
entries.push({ loc: checked.url, lastmod: null });
|
|
145
251
|
}
|
|
146
|
-
if (!
|
|
147
|
-
return { kind: 'urlset',
|
|
252
|
+
if (!entries.length && !Object.keys(skipped).length) throw new SeedError('not_a_sitemap');
|
|
253
|
+
return { kind: 'urlset', entries, truncated, skipped };
|
|
148
254
|
}
|
|
149
255
|
|
|
150
256
|
/**
|
|
151
|
-
* Walks a sitemap index to its leaf
|
|
152
|
-
* performs no I/O. `fetchDocument(url)` returns the body text or null when it could not
|
|
257
|
+
* Walks a sitemap index to its leaf entries, using a caller-supplied fetch so this module
|
|
258
|
+
* still performs no I/O. `fetchDocument(url)` returns the body text or null when it could not
|
|
259
|
+
* be read. Leaf entries carry their declared lastmod; an index child carries the index's own
|
|
260
|
+
* lastmod, which describes the FILE rather than the URLs inside it, and so is not attached to
|
|
261
|
+
* anything downstream.
|
|
153
262
|
*
|
|
154
263
|
* A document we could not read is counted, never treated as a sitemap with no URLs — the same
|
|
155
264
|
* rule extraction follows, for the same reason.
|
|
156
265
|
*/
|
|
157
266
|
export async function expandSitemaps(roots, fetchDocument, { maxDepth = SEED_LIMITS.indexDepth, maxDocuments = SEED_LIMITS.sitemapDirectives, scope = 'path' } = {}) {
|
|
158
|
-
const
|
|
267
|
+
const entries = [], seenLocs = new Set(), visited = new Set(), failures = {};
|
|
159
268
|
let documents = 0, truncated = false;
|
|
160
269
|
const queue = roots.map(url => ({ url, depth: 0 }));
|
|
161
270
|
while (queue.length) {
|
|
@@ -167,16 +276,20 @@ export async function expandSitemaps(roots, fetchDocument, { maxDepth = SEED_LIM
|
|
|
167
276
|
const body = await fetchDocument(url);
|
|
168
277
|
if (body === null || body === undefined) { failures.unreadable = (failures.unreadable ?? 0) + 1; continue; }
|
|
169
278
|
let parsed;
|
|
170
|
-
try { parsed =
|
|
279
|
+
try { parsed = parseSitemapEntries(body, { baseUrl: url, scope }); }
|
|
171
280
|
catch (error) { failures[error.reason] = (failures[error.reason] ?? 0) + 1; continue; }
|
|
172
281
|
if (parsed.truncated) truncated = true;
|
|
173
282
|
if (parsed.kind === 'index') {
|
|
174
283
|
// An index deeper than the limit is a loop or a mistake; stop rather than follow it.
|
|
175
284
|
if (depth >= maxDepth) { failures.index_too_deep = (failures.index_too_deep ?? 0) + 1; continue; }
|
|
176
|
-
for (const child of parsed.
|
|
285
|
+
for (const child of parsed.entries) queue.push({ url: child.loc, depth: depth + 1 });
|
|
177
286
|
continue;
|
|
178
287
|
}
|
|
179
|
-
for (const found of parsed.
|
|
288
|
+
for (const found of parsed.entries) {
|
|
289
|
+
if (seenLocs.has(found.loc)) continue;
|
|
290
|
+
seenLocs.add(found.loc);
|
|
291
|
+
entries.push(found);
|
|
292
|
+
}
|
|
180
293
|
}
|
|
181
|
-
return { urls, documents, truncated, failures };
|
|
294
|
+
return { urls: entries.map(entry => entry.loc), entries, documents, truncated, failures };
|
|
182
295
|
}
|
|
File without changes
|
package/src/discovery/import.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// The evidence reference (DP-0004-T21): one stable, linkable name for one observation.
|
|
2
|
+
//
|
|
3
|
+
// A report row says a link was present on a date. A reference is the part that lets a reader
|
|
4
|
+
// point at the observation BEHIND that sentence — after the document was forwarded, after the
|
|
5
|
+
// session that produced it is gone, and, for the repository mirror, after the hosted account
|
|
6
|
+
// is cancelled. The buyer-lane demand this answers: an agency has to show a client WHY it
|
|
7
|
+
// believes a placement state, not just that it does.
|
|
8
|
+
//
|
|
9
|
+
// Two storages, because that is where observations actually live:
|
|
10
|
+
//
|
|
11
|
+
// hosted — the workspace's immutable observation row. Resolves through `locate_link`,
|
|
12
|
+
// `get_evidence` or `GET /v1/observations/{id}/evidence` while the workspace
|
|
13
|
+
// exists; the raw snapshot answers 410 EVIDENCE_EXPIRED once its lifecycle
|
|
14
|
+
// removes the bytes, and the reference still names the observation.
|
|
15
|
+
// mirror — the customer's repository observations file, keyed the way `observationKey`
|
|
16
|
+
// keys a row: which entry, when it was checked, and who ran the check. Resolves
|
|
17
|
+
// offline with `agentlinkops locate --ref …` from the repository alone.
|
|
18
|
+
//
|
|
19
|
+
// A reference is DERIVED, never minted. Every component comes from immutable observation
|
|
20
|
+
// data, so the same observation produces the same reference on any machine on any day, and
|
|
21
|
+
// nothing in a reference is live session state — which is what makes an export that carries
|
|
22
|
+
// them survivable past cancelation. Anchoring (DP-0042-T07) will later make the content hash
|
|
23
|
+
// independently verifiable; the reference is the handle that hash attaches to.
|
|
24
|
+
export const EVIDENCE_REFERENCE_VERSION = 1;
|
|
25
|
+
const PREFIX = `agentlinkops:evidence/${EVIDENCE_REFERENCE_VERSION}`;
|
|
26
|
+
const ID = /^[\w-]{1,128}$/u;
|
|
27
|
+
const ORIGINS = Object.freeze(['local', 'cloud']);
|
|
28
|
+
|
|
29
|
+
const validTimestamp = value => typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value));
|
|
30
|
+
|
|
31
|
+
/** A reference to a hosted observation row. Null rather than a reference that cannot resolve. */
|
|
32
|
+
export function hostedEvidenceReference(observationId) {
|
|
33
|
+
if (typeof observationId !== 'string' || !ID.test(observationId)) return null;
|
|
34
|
+
return `${PREFIX}/link/hosted/${observationId}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A reference to an observation in the repository mirror.
|
|
39
|
+
*
|
|
40
|
+
* `checkedAt` is the check's own timestamp (the cloud's, for cloud rows) rather than a local
|
|
41
|
+
* clock, so a replayed event produces the same reference. `origin` says who ran the check,
|
|
42
|
+
* because a local check and a cloud check of the same entry at the same instant are two real
|
|
43
|
+
* observations, not a duplicate.
|
|
44
|
+
*/
|
|
45
|
+
export function mirrorEvidenceReference(input = {}) {
|
|
46
|
+
const { entryId, checkedAt, origin } = input ?? {};
|
|
47
|
+
if (typeof entryId !== 'string' || !ID.test(entryId)) return null;
|
|
48
|
+
if (!ORIGINS.includes(origin)) return null;
|
|
49
|
+
if (!validTimestamp(checkedAt)) return null;
|
|
50
|
+
return `${PREFIX}/link/mirror/${entryId}/${origin}/${encodeURIComponent(checkedAt)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parses a reference back into what it names. Strict on purpose: a malformed reference is a
|
|
55
|
+
* reader's typo or a forged document, and resolving it by similarity would point at the wrong
|
|
56
|
+
* observation while appearing to verify the right one.
|
|
57
|
+
*
|
|
58
|
+
* Target-kind references are not part of this version; a future version adds them rather than
|
|
59
|
+
* this parser accepting a shape nothing resolves.
|
|
60
|
+
*/
|
|
61
|
+
export function parseEvidenceReference(reference) {
|
|
62
|
+
if (typeof reference !== 'string' || reference.length > 512) return null;
|
|
63
|
+
const parts = reference.split('/');
|
|
64
|
+
if (parts[0] !== 'agentlinkops:evidence' || parts[1] !== String(EVIDENCE_REFERENCE_VERSION)) return null;
|
|
65
|
+
if (parts[2] === 'index') {
|
|
66
|
+
if(parts[3]==='mirror' && parts.length===5 && /^[a-f0-9]{64}$/.test(parts[4]))return {v:1,subject:'index',storage:'mirror',receiptId:parts[4]};
|
|
67
|
+
if(parts[3]==='hosted' && parts.length===7 && ID.test(parts[4]) && ID.test(parts[5]) && /^[a-f0-9]{64}$/.test(parts[6]))return {v:1,subject:'index',storage:'hosted',projectId:parts[4],watchId:parts[5],receiptId:parts[6]};
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (parts[2] !== 'link') return null;
|
|
71
|
+
const [storage, ...rest] = parts.slice(3);
|
|
72
|
+
if (storage === 'hosted') {
|
|
73
|
+
const [observationId] = rest;
|
|
74
|
+
if (rest.length !== 1 || !ID.test(observationId ?? '')) return null;
|
|
75
|
+
return { v: EVIDENCE_REFERENCE_VERSION, subject: 'link', storage: 'hosted', observationId };
|
|
76
|
+
}
|
|
77
|
+
if (storage === 'mirror') {
|
|
78
|
+
const [entryId, origin, encodedAt] = rest;
|
|
79
|
+
if (rest.length !== 3 || !ID.test(entryId ?? '') || !ORIGINS.includes(origin ?? '')) return null;
|
|
80
|
+
let checkedAt;
|
|
81
|
+
try { checkedAt = decodeURIComponent(encodedAt ?? ''); } catch { return null; }
|
|
82
|
+
if (!validTimestamp(checkedAt)) return null;
|
|
83
|
+
return { v: EVIDENCE_REFERENCE_VERSION, subject: 'link', storage: 'mirror', entryId, checkedAt, origin };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How a holder resolves a reference, stated beside the reference rather than guessed at. */
|
|
89
|
+
export function describeEvidenceReference(reference) {
|
|
90
|
+
const parsed = parseEvidenceReference(reference);
|
|
91
|
+
if (!parsed) return null;
|
|
92
|
+
if(parsed.subject==='index')return {reference,...parsed,resolves:parsed.storage==='mirror'?['agentlinkops locate --ref <reference> (offline exported index receipt)']:[`agentlinkops call get_index_observation --set projectId=${parsed.projectId} --set watchId=${parsed.watchId} --set receiptId=${parsed.receiptId}`],survives_cancelation:parsed.storage==='mirror'};
|
|
93
|
+
return {
|
|
94
|
+
reference,
|
|
95
|
+
...parsed,
|
|
96
|
+
resolves: parsed.storage === 'hosted'
|
|
97
|
+
? [
|
|
98
|
+
'agentlinkops call locate_link --set observationId=<id> (positions and live-page handoff)',
|
|
99
|
+
'agentlinkops call get_evidence --set subject=link --set observationId=<id> (retained snapshot; 410 once expired)',
|
|
100
|
+
'GET /v1/observations/<id>/evidence (HTTP, workspace credentials)',
|
|
101
|
+
]
|
|
102
|
+
: ['agentlinkops locate --ref <reference> (offline, from the repository ledger and observations)'],
|
|
103
|
+
survives_cancelation: parsed.storage === 'mirror',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function indexEvidenceReference({receiptId,projectId,watchId,storage='mirror'}={}){
|
|
108
|
+
if(!/^[a-f0-9]{64}$/.test(receiptId??''))return null;
|
|
109
|
+
if(storage==='mirror')return `${PREFIX}/index/mirror/${receiptId}`;
|
|
110
|
+
if(storage==='hosted'&&ID.test(projectId??'')&&ID.test(watchId??''))return `${PREFIX}/index/hosted/${projectId}/${watchId}/${receiptId}`;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Pure index-observation semantics. No fetch, OAuth, scraping, quota mutation,
|
|
2
|
+
// persistence or hosted feature activation occurs in this module.
|
|
3
|
+
export const INDEX_OBSERVATION_VERSION = 1;
|
|
4
|
+
export const INDEX_CONFIRMATION = Object.freeze({ probes: 2, minProbeIntervalMs: 30 * 60_000, graceMs: 24 * 60 * 60_000 });
|
|
5
|
+
export const URL_INSPECTION_LIMITS = Object.freeze({ propertyPerDay: 2000, propertyPerMinute: 600, projectPerDay: 10_000_000, projectPerMinute: 15_000 });
|
|
6
|
+
const positiveCoverage = new Set(['Submitted and indexed', 'Indexed, not submitted in sitemap', 'Indexed']);
|
|
7
|
+
const fail = code => { throw Object.assign(new Error(code), { code }); };
|
|
8
|
+
const text = value => typeof value === 'string' && value.length ? value : null;
|
|
9
|
+
function timestamp(value) {
|
|
10
|
+
if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/u.test(value) || !Number.isFinite(Date.parse(value))) fail('INVALID_INDEX_TIMESTAMP');
|
|
11
|
+
return new Date(value).toISOString();
|
|
12
|
+
}
|
|
13
|
+
function url(value) {
|
|
14
|
+
let parsed; try { parsed = new URL(value); } catch { fail('INVALID_INDEX_URL'); }
|
|
15
|
+
if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) fail('INVALID_INDEX_URL');
|
|
16
|
+
return parsed.href;
|
|
17
|
+
}
|
|
18
|
+
export function validateInspectionProperty(inspectionUrl, siteUrl, grantedProperties) {
|
|
19
|
+
const inspected = new URL(url(inspectionUrl));
|
|
20
|
+
if (!Array.isArray(grantedProperties) || !grantedProperties.includes(siteUrl)) fail('INDEX_PROPERTY_NOT_GRANTED');
|
|
21
|
+
if (typeof siteUrl !== 'string') fail('INVALID_INDEX_PROPERTY');
|
|
22
|
+
if (siteUrl.startsWith('sc-domain:')) {
|
|
23
|
+
const domain = siteUrl.slice(10);
|
|
24
|
+
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(domain) || domain.includes('..')) fail('INVALID_INDEX_PROPERTY');
|
|
25
|
+
if (inspected.hostname !== domain && !inspected.hostname.endsWith(`.${domain}`)) fail('INDEX_URL_OUTSIDE_PROPERTY');
|
|
26
|
+
} else {
|
|
27
|
+
const property = new URL(url(siteUrl));
|
|
28
|
+
if (!siteUrl.endsWith('/') || property.search) fail('INVALID_INDEX_PROPERTY');
|
|
29
|
+
if (inspected.origin !== property.origin || !inspected.pathname.startsWith(property.pathname)) fail('INDEX_URL_OUTSIDE_PROPERTY');
|
|
30
|
+
}
|
|
31
|
+
// Keep the exact granted property string; do not silently fix a missing slash.
|
|
32
|
+
return siteUrl;
|
|
33
|
+
}
|
|
34
|
+
function envelope({ observationId, inspectionUrl, checkedAt, payload, lane, backend, sourceKey }) {
|
|
35
|
+
if (!text(observationId) || observationId.length > 128) fail('INVALID_INDEX_OBSERVATION_ID');
|
|
36
|
+
let raw; try { raw = payload === undefined ? null : JSON.parse(JSON.stringify(payload)); } catch { fail('INVALID_INDEX_RESPONSE'); }
|
|
37
|
+
if (new TextEncoder().encode(JSON.stringify(raw)).byteLength > 262144) fail('INDEX_RESPONSE_EXCEEDS_BOUND');
|
|
38
|
+
return { version: INDEX_OBSERVATION_VERSION, observation_id: observationId, url: url(inspectionUrl), checked_at: timestamp(checkedAt),
|
|
39
|
+
lane, backend, source_key: sourceKey, tier: 'unknown', confidence: 'unknown', reason: 'invalid_response',
|
|
40
|
+
raw_response: raw, last_crawl_at: null, live_page_test: false, submission_requested: false };
|
|
41
|
+
}
|
|
42
|
+
export function gscIndexObservation(input) {
|
|
43
|
+
const siteUrl = validateInspectionProperty(input.inspectionUrl, input.siteUrl, input.grantedProperties);
|
|
44
|
+
const observation = envelope({ ...input, lane: 'gsc', backend: 'google_url_inspection', sourceKey: `gsc:${siteUrl}` });
|
|
45
|
+
observation.property = siteUrl;
|
|
46
|
+
const response = observation.raw_response;
|
|
47
|
+
const result = response?.inspectionResult?.indexStatusResult;
|
|
48
|
+
if (response?.error) return { ...observation, reason: 'inspection_request_failed' };
|
|
49
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) return observation;
|
|
50
|
+
const tuple = Object.fromEntries(['verdict', 'coverageState', 'indexingState', 'robotsTxtState', 'pageFetchState', 'googleCanonical', 'userCanonical', 'lastCrawlTime', 'crawledAs'].map(key => [key, text(result[key])]));
|
|
51
|
+
observation.inspection = tuple;
|
|
52
|
+
observation.reason = tuple.coverageState ?? 'coverage_reason_missing';
|
|
53
|
+
observation.last_crawl_at = tuple.lastCrawlTime; // Provider time is not our retrieval time.
|
|
54
|
+
observation.inspection_result_link = text(response.inspectionResult.inspectionResultLink);
|
|
55
|
+
if (!tuple.coverageState) return observation;
|
|
56
|
+
if (tuple.verdict === 'PASS') {
|
|
57
|
+
// coverageState is human-readable and can be localized. v1 admits only these
|
|
58
|
+
// reviewed en-US positive labels; unknown/new labels fail closed.
|
|
59
|
+
let canonicalMatches = false;
|
|
60
|
+
try { canonicalMatches = url(tuple.googleCanonical) === observation.url; } catch { /* missing or invalid is unknown */ }
|
|
61
|
+
if (positiveCoverage.has(tuple.coverageState) && tuple.indexingState === 'INDEXING_ALLOWED' && tuple.pageFetchState === 'SUCCESSFUL' && canonicalMatches) {
|
|
62
|
+
return { ...observation, tier: 'indexed', confidence: 'google_index_snapshot' };
|
|
63
|
+
}
|
|
64
|
+
return { ...observation, reason: 'inconsistent_or_unsupported_index_tuple' };
|
|
65
|
+
}
|
|
66
|
+
if (['NEUTRAL', 'FAIL'].includes(tuple.verdict) && !positiveCoverage.has(tuple.coverageState)) {
|
|
67
|
+
return { ...observation, tier: 'not_indexed', confidence: 'google_index_snapshot' };
|
|
68
|
+
}
|
|
69
|
+
return { ...observation, reason: 'unspecified_or_inconsistent_index_verdict' };
|
|
70
|
+
}
|
|
71
|
+
export function licensedSiteIndexObservation(input) {
|
|
72
|
+
if (!text(input.provider) || input.provider.length > 128) fail('INVALID_INDEX_PROVIDER');
|
|
73
|
+
const inspected = url(input.inspectionUrl);
|
|
74
|
+
if (input.query !== `site:${inspected}`) fail('INDEX_QUERY_SCOPE_MISMATCH');
|
|
75
|
+
if (!text(input.country) || !text(input.language)) fail('INDEX_LOCALE_REQUIRED');
|
|
76
|
+
const sourceKey = JSON.stringify(['licensed_site', input.provider, input.query, input.country, input.language]);
|
|
77
|
+
const observation = envelope({ ...input, lane: 'licensed_site', backend: input.provider, sourceKey });
|
|
78
|
+
observation.query = input.query; observation.country = input.country; observation.language = input.language;
|
|
79
|
+
const result = observation.raw_response;
|
|
80
|
+
if (result?.success !== true || result.complete !== true || !Array.isArray(result.results)) return observation;
|
|
81
|
+
const urls = [];
|
|
82
|
+
for (const row of result.results) {
|
|
83
|
+
try { urls.push(url(row?.url)); } catch { return { ...observation, reason: 'invalid_result_url' }; }
|
|
84
|
+
}
|
|
85
|
+
return { ...observation, tier: urls.includes(inspected) ? 'likely_indexed' : 'not_found_in_site_query',
|
|
86
|
+
confidence: 'search_result_sample', reason: urls.includes(inspected) ? 'exact_url_returned' : 'exact_url_not_returned' };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function transitionIndexObservation(previous, observation, policy = INDEX_CONFIRMATION) {
|
|
90
|
+
if (observation?.version !== INDEX_OBSERVATION_VERSION || !['gsc', 'licensed_site'].includes(observation.lane)
|
|
91
|
+
|| !['indexed', 'not_indexed', 'likely_indexed', 'not_found_in_site_query', 'unknown'].includes(observation.tier)
|
|
92
|
+
|| !text(observation.observation_id) || !text(observation.source_key)) fail('INVALID_INDEX_OBSERVATION');
|
|
93
|
+
if (observation.lane !== 'gsc' && ['indexed', 'not_indexed'].includes(observation.tier)) fail('INVALID_INDEX_CONFIDENCE');
|
|
94
|
+
const at = timestamp(observation.checked_at);
|
|
95
|
+
if (!Number.isInteger(policy.probes) || policy.probes < 2 || policy.probes > 10 || !Number.isFinite(policy.minProbeIntervalMs)
|
|
96
|
+
|| policy.minProbeIntervalMs < 1 || !Number.isFinite(policy.graceMs) || policy.graceMs < policy.minProbeIntervalMs) fail('INVALID_INDEX_CONFIRMATION_POLICY');
|
|
97
|
+
if (previous && (previous.url !== observation.url || previous.source_key !== observation.source_key)) fail('INDEX_STATE_SCOPE_MISMATCH');
|
|
98
|
+
const state = previous ? structuredClone(previous) : { version: 1, url: observation.url, source_key: observation.source_key,
|
|
99
|
+
confirmed: null, pending_negative: null, latest: null };
|
|
100
|
+
if (state.latest && (state.latest.observation_id === observation.observation_id || state.confirmed?.observation_id === observation.observation_id
|
|
101
|
+
|| state.pending_negative?.observation_ids.includes(observation.observation_id) || Date.parse(at) <= Date.parse(state.latest.checked_at))) {
|
|
102
|
+
return { state, event: null, ignored: 'duplicate_or_out_of_order' };
|
|
103
|
+
}
|
|
104
|
+
state.latest = structuredClone(observation);
|
|
105
|
+
if (observation.tier === 'unknown' || observation.lane !== 'gsc') return { state, event: null };
|
|
106
|
+
if (observation.tier === 'indexed') {
|
|
107
|
+
const before = state.confirmed;
|
|
108
|
+
state.confirmed = { tier: 'indexed', observed_at: at, observation_id: observation.observation_id, reason: observation.reason };
|
|
109
|
+
state.pending_negative = null;
|
|
110
|
+
return { state, event: before?.tier === 'not_indexed' ? { type: 'index.reindexed',
|
|
111
|
+
id: `index.reindexed:${before.observation_id}:${observation.observation_id}`, url: observation.url,
|
|
112
|
+
source_key: observation.source_key, before: before.tier, after: 'indexed', observation_ids: [before.observation_id, observation.observation_id] } : null };
|
|
113
|
+
}
|
|
114
|
+
if (observation.tier !== 'not_indexed' || state.confirmed?.tier === 'not_indexed') return { state, event: null };
|
|
115
|
+
const pending = state.pending_negative ?? { first_at: at, last_counted_at: at, observation_ids: [observation.observation_id] };
|
|
116
|
+
if (state.pending_negative && Date.parse(at) - Date.parse(pending.last_counted_at) >= policy.minProbeIntervalMs) {
|
|
117
|
+
if (pending.observation_ids.length < policy.probes) pending.observation_ids.push(observation.observation_id);
|
|
118
|
+
pending.last_counted_at = at;
|
|
119
|
+
}
|
|
120
|
+
state.pending_negative = pending;
|
|
121
|
+
if (pending.observation_ids.length < policy.probes || Date.parse(at) - Date.parse(pending.first_at) < policy.graceMs) return { state, event: null };
|
|
122
|
+
const before = state.confirmed;
|
|
123
|
+
state.confirmed = { tier: 'not_indexed', observed_at: at, observation_id: observation.observation_id, reason: observation.reason };
|
|
124
|
+
state.pending_negative = null;
|
|
125
|
+
return { state, event: before?.tier === 'indexed' ? { type: 'index.deindexed',
|
|
126
|
+
id: `index.deindexed:${pending.observation_ids[0]}:${observation.observation_id}`, url: observation.url,
|
|
127
|
+
source_key: observation.source_key, before: before.tier, after: 'not_indexed',
|
|
128
|
+
observation_ids: [...new Set([...pending.observation_ids, observation.observation_id])], first_negative_at: pending.first_at } : null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Advisory arithmetic only: a future transactional scheduler must reserve all
|
|
132
|
+
// four budgets atomically. This function cannot admit a network call.
|
|
133
|
+
export function inspectionQuotaHeadroom(usage, limits = URL_INSPECTION_LIMITS) {
|
|
134
|
+
return Math.min(...Object.keys(URL_INSPECTION_LIMITS).map(key => {
|
|
135
|
+
const row = usage?.[key];
|
|
136
|
+
if (!row || !Number.isSafeInteger(row.attempted) || row.attempted < 0 || !Number.isSafeInteger(row.reserved) || row.reserved < 0
|
|
137
|
+
|| !Number.isSafeInteger(limits[key]) || limits[key] < 0) fail('INVALID_INDEX_QUOTA_USAGE');
|
|
138
|
+
return Math.max(0, limits[key] - row.attempted - row.reserved);
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Compare dated evidence; never overwrite either source or select a winner.
|
|
143
|
+
export function reconcileIndexObservations(observations,{maxSkewMs=86400000}={}){
|
|
144
|
+
if(!Array.isArray(observations)||observations.length>100||!Number.isFinite(maxSkewMs)||maxSkewMs<0)fail('INVALID_INDEX_RECONCILIATION');
|
|
145
|
+
const bySource=new Map();
|
|
146
|
+
for(const observation of observations){
|
|
147
|
+
if(!observation?.url||!observation.source_key)fail('INVALID_INDEX_OBSERVATION');timestamp(observation.checked_at);
|
|
148
|
+
const before=bySource.get(observation.source_key);if(!before||observation.checked_at>before.checked_at||(observation.checked_at===before.checked_at&&observation.observation_id>before.observation_id))bySource.set(observation.source_key,observation);
|
|
149
|
+
}
|
|
150
|
+
const rows=[...bySource.values()].sort((a,b)=>a.source_key.localeCompare(b.source_key));
|
|
151
|
+
if(new Set(rows.map(r=>r.url)).size>1)fail('INDEX_STATE_SCOPE_MISMATCH');
|
|
152
|
+
const comparisons=[];
|
|
153
|
+
for(let i=0;i<rows.length;i++)for(let j=i+1;j<rows.length;j++){
|
|
154
|
+
const a=rows[i],b=rows[j];if(a.lane===b.lane)continue;
|
|
155
|
+
const signs={indexed:1,likely_indexed:1,not_indexed:-1,not_found_in_site_query:-1};
|
|
156
|
+
const skew=Math.abs(Date.parse(a.checked_at)-Date.parse(b.checked_at));
|
|
157
|
+
const finding=skew>maxSkewMs?'not_comparable':!signs[a.tier]||!signs[b.tier]?'inconclusive':signs[a.tier]===signs[b.tier]?'agreement':'disagreement';
|
|
158
|
+
comparisons.push({finding,resolution:'unresolved',observation_ids:[a.observation_id,b.observation_id],source_keys:[a.source_key,b.source_key],checked_at:[a.checked_at,b.checked_at],tiers:[a.tier,b.tier],time_skew_ms:skew,negative_search_result_is_not_proof:true});
|
|
159
|
+
}
|
|
160
|
+
return {version:1,url:rows[0]?.url??null,comparisons,combined_verdict:null};
|
|
161
|
+
}
|