agentlinkops 0.5.0

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.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +10 -0
  2. package/.codex-plugin/plugin.json +24 -0
  3. package/CHANGELOG.md +52 -0
  4. package/LICENSE +190 -0
  5. package/NOTICE +17 -0
  6. package/README.md +202 -0
  7. package/bin/agentlinkops.mjs +4 -0
  8. package/bin/linktrail.mjs +6 -0
  9. package/cli/adapters/csv.js +68 -0
  10. package/cli/adapters/sqlite-crm.js +79 -0
  11. package/cli/adapters/suppliers.js +180 -0
  12. package/cli/agent-setup.js +203 -0
  13. package/cli/agentlinkops.mjs +4 -0
  14. package/cli/args.js +18 -0
  15. package/cli/catalog.js +7483 -0
  16. package/cli/check.js +119 -0
  17. package/cli/citation.js +109 -0
  18. package/cli/client.js +60 -0
  19. package/cli/commands.js +183 -0
  20. package/cli/config.js +104 -0
  21. package/cli/connection.js +90 -0
  22. package/cli/context-mcp.js +193 -0
  23. package/cli/context.js +340 -0
  24. package/cli/delivery.js +67 -0
  25. package/cli/doctor.js +283 -0
  26. package/cli/env.js +66 -0
  27. package/cli/fleet.js +82 -0
  28. package/cli/gsc-browser.js +239 -0
  29. package/cli/import.js +108 -0
  30. package/cli/ledger.js +232 -0
  31. package/cli/linktrail.mjs +6 -0
  32. package/cli/local-result.js +161 -0
  33. package/cli/main.js +523 -0
  34. package/cli/mirror.js +126 -0
  35. package/cli/mix.js +123 -0
  36. package/cli/platforms.js +161 -0
  37. package/cli/receipt-performance.js +64 -0
  38. package/cli/receipts.js +122 -0
  39. package/cli/report.js +158 -0
  40. package/cli/setup-plan.js +211 -0
  41. package/cli/skill.js +39 -0
  42. package/cli/state.js +106 -0
  43. package/cli/status.js +51 -0
  44. package/cli/sync.js +179 -0
  45. package/package.json +59 -0
  46. package/references/cli.md +198 -0
  47. package/references/disavow.md +73 -0
  48. package/references/reports.md +53 -0
  49. package/scripts/agentlinkops.py +651 -0
  50. package/scripts/disavow.py +207 -0
  51. package/scripts/linktrail.py +10 -0
  52. package/scripts/migrations/001_crm.sql +45 -0
  53. package/scripts/migrations/002_cloud_events.sql +13 -0
  54. package/scripts/migrations/003_disavow.sql +20 -0
  55. package/scripts/migrations/004_placement_costs.sql +2 -0
  56. package/scripts/report.py +113 -0
  57. package/shared/agent-clients.js +49 -0
  58. package/shared/observation-event.js +43 -0
  59. package/shared/response-contract.js +59 -0
  60. package/shared/search-connection-contract.js +18 -0
  61. package/skills/agentlinkops-assets/SKILL.md +13 -0
  62. package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
  63. package/skills/agentlinkops-campaigns/SKILL.md +17 -0
  64. package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
  65. package/skills/agentlinkops-connect/SKILL.md +33 -0
  66. package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
  67. package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
  68. package/skills/agentlinkops-crm/SKILL.md +27 -0
  69. package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
  70. package/skills/content-defingerprinting/SKILL.md +29 -0
  71. package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
  72. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
  73. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
  74. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
  75. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
  76. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
  77. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
  78. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
  79. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
  80. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
  81. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
  82. package/skills/internal-linking-optimizer/SKILL.md +56 -0
  83. package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
  84. package/skills/pre-publish-review/SKILL.md +179 -0
  85. package/skills/source-cited-content-builder/SKILL.md +56 -0
  86. package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
  87. package/skills/source-cited-humanizer/SKILL.md +20 -0
  88. package/src/citations/adapter.js +141 -0
  89. package/src/citations/contract.js +171 -0
  90. package/src/citations/match.js +120 -0
  91. package/src/citations/runner.js +151 -0
  92. package/src/citations/stats.js +74 -0
  93. package/src/competitors/contract.js +157 -0
  94. package/src/competitors/domain-mix.js +199 -0
  95. package/src/context/ga4.js +652 -0
  96. package/src/context/gsc.js +925 -0
  97. package/src/context/site-profile.js +445 -0
  98. package/src/context/util.js +71 -0
  99. package/src/context-tool-registry.js +27 -0
  100. package/src/corpus/acquire.js +135 -0
  101. package/src/corpus/seeds.js +182 -0
  102. package/src/discovery/contract.js +237 -0
  103. package/src/discovery/import.js +156 -0
  104. package/src/tool-views.js +117 -0
  105. package/src/verifier/fetch.js +303 -0
  106. package/src/verifier/index.js +311 -0
  107. package/src/verifier/observation-kind.js +91 -0
  108. package/src/verifier/parser.js +241 -0
  109. package/src/verifier/readiness.js +90 -0
  110. package/src/verifier/render-evidence.js +57 -0
  111. package/src/verifier/robots.js +85 -0
  112. package/src/verifier/transition.js +129 -0
  113. package/src/verifier/url.js +56 -0
  114. package/templates/SITE.template.md +59 -0
  115. package/templates/campaigns.v1.json +65 -0
@@ -0,0 +1,135 @@
1
+ import { publicFetcher, VerificationError } from '../verifier/fetch.js';
2
+ import { validatePublicUrl } from '../verifier/url.js';
3
+
4
+ // One interface over every way we acquire a page, so a runtime can be added without
5
+ // rebuilding the corpus. Direct fetching is implemented here on the verifier's public
6
+ // fetcher, which already validates every redirect hop, applies robots per hop, bounds the
7
+ // body and refuses a transport that follows redirects on its own.
8
+ //
9
+ // Rendered and proxied acquisition are declared and NOT configured. They return an explicit
10
+ // `not_configured` outcome, the same stance the discovery adapter takes towards a supplier
11
+ // it has no credentials for: an unconfigured path yields an error, never fabricated data.
12
+ export const FETCH_KINDS = Object.freeze(['direct', 'rendered', 'proxied']);
13
+ export const ACQUISITION_LIMITS = Object.freeze({ maxBytes: 2 * 1024 * 1024, timeoutMs: 20_000, maxRedirects: 5 });
14
+
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
+ export const OUTCOMES = Object.freeze(['fetched', 'not_found', 'blocked', 'unavailable', 'not_configured', 'deferred']);
18
+
19
+ const BLOCKED_REASONS = /^(robots_disallowed|robots_|access_challenge|source_http_(401|403|429))/;
20
+ // A slot we could not take is not the publisher's fault and not a defect: retry it later.
21
+ const DEFERRABLE = new Set(['origin_busy']);
22
+ const NOT_FOUND = new Set([404, 410]);
23
+
24
+ export class AcquisitionError extends Error {
25
+ constructor(reason) { super(reason); this.name = 'AcquisitionError'; this.reason = reason; }
26
+ }
27
+
28
+ /** Sorts a fetch failure into an outcome. Everything unrecognised is unavailable, never empty. */
29
+ export function classifyFailure(reason) {
30
+ if (DEFERRABLE.has(reason)) return 'deferred';
31
+ if (reason === 'robots_crawl_delay_requires_scheduler') return 'blocked';
32
+ if (BLOCKED_REASONS.test(reason)) return 'blocked';
33
+ if (/^source_http_(404|410)$/.test(reason)) return 'not_found';
34
+ return 'unavailable';
35
+ }
36
+
37
+ /**
38
+ * @param options.publicFetchSafe Asserts the transport enforces a public-only destination
39
+ * boundary, which lexical URL screening cannot. Required for direct fetching.
40
+ * @param options.runtimes { rendered, proxied } acquisition runtimes. Each must assert its
41
+ * OWN boundary; see the safety note on `assertRuntime`.
42
+ * @param options.beforeFetch Pacing hook, called before robots and page requests. Required
43
+ * whenever a publisher asks for a crawl delay, which is why the frontier owns scheduling.
44
+ */
45
+ export function createAcquisition(options = {}) {
46
+ const maxBytes = Math.min(options.maxBytes ?? ACQUISITION_LIMITS.maxBytes, ACQUISITION_LIMITS.maxBytes);
47
+ const timeoutMs = Math.min(Math.max(options.timeoutMs ?? ACQUISITION_LIMITS.timeoutMs, 1), ACQUISITION_LIMITS.timeoutMs);
48
+ const runtimes = options.runtimes ?? {};
49
+ const clock = options.now ?? (() => new Date().toISOString());
50
+
51
+ async function direct(url, beforeFetch) {
52
+ // The public boundary is a property of the transport, not of this module. Refuse rather
53
+ // than fetch on an unasserted one; a lexically public hostname can still resolve inside.
54
+ if (options.publicFetchSafe !== true) throw new AcquisitionError('public_fetch_boundary_not_asserted');
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
57
+ const trace = { finalUrl: url, redirects: [], robots: null, httpStatus: null };
58
+ try {
59
+ const fetcher = publicFetcher({ ...options, beforeFetch: beforeFetch ?? options.beforeFetch, maxBytes, maxRedirects: ACQUISITION_LIMITS.maxRedirects }, controller.signal);
60
+ const { response, body } = await fetcher.source(url, trace);
61
+ 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 };
63
+ } finally {
64
+ clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A runtime that is not the platform's own public fetch does NOT inherit its guarantees.
70
+ * A proxy or headless browser resolves and connects on its own, so the boundary that makes
71
+ * direct fetching safe says nothing about it. Each must assert its own, explicitly.
72
+ */
73
+ function assertRuntime(kind) {
74
+ const runtime = runtimes[kind];
75
+ if (!runtime) throw new AcquisitionError(`${kind}_runtime_not_configured`);
76
+ if (runtime.publicFetchSafe !== true) throw new AcquisitionError(`${kind}_runtime_boundary_not_asserted`);
77
+ if (typeof runtime.fetchPage !== 'function') throw new AcquisitionError(`${kind}_runtime_invalid`);
78
+ return runtime;
79
+ }
80
+
81
+ return {
82
+ /**
83
+ * Whether a fetch kind can actually be served. The crawl loop asks before escalating a
84
+ * page to a browser, because queueing work no runtime can serve would leave the frontier
85
+ * permanently non-empty and the run never finished.
86
+ */
87
+ supports(kind) {
88
+ if (kind === 'direct') return options.publicFetchSafe === true;
89
+ const runtime = runtimes[kind];
90
+ return Boolean(runtime) && runtime.publicFetchSafe === true && typeof runtime.fetchPage === 'function';
91
+ },
92
+
93
+ /**
94
+ * Acquires one page. Always resolves: an attempt that failed is a recorded outcome, not
95
+ * a thrown error, because the corpus must keep attempted outcomes and not only successes.
96
+ */
97
+ async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null } = {}) {
98
+ const at = clock();
99
+ const base = { url: rawUrl, final_url: null, kind, http_status: null, robots: null,
100
+ html: null, body_truncated: false, fetched_at: at, reason: null };
101
+ if (!FETCH_KINDS.includes(kind)) return { ...base, outcome: 'unavailable', reason: 'unsupported_fetch_kind' };
102
+ const checked = validatePublicUrl(rawUrl);
103
+ if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
104
+
105
+ try {
106
+ if (kind === 'direct') {
107
+ const { outcome, trace, html } = await direct(checked.url, beforeFetch);
108
+ return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
109
+ robots: trace.robots, redirects: trace.redirects, html,
110
+ // The verifier's reader refuses an oversized body outright, so a returned body was
111
+ // read to the end. Truncation reaches extraction from runtimes with their own caps.
112
+ body_truncated: false, render_ms: 0, proxy_bytes: 0 };
113
+ }
114
+ const runtime = assertRuntime(kind);
115
+ const result = await runtime.fetchPage(checked.url, { maxBytes, timeoutMs });
116
+ return { ...base, outcome: result.outcome ?? 'fetched', final_url: result.finalUrl ?? checked.url,
117
+ http_status: result.httpStatus ?? null, html: result.html ?? null,
118
+ body_truncated: result.bodyTruncated === true, reason: result.reason ?? null,
119
+ // The units a runtime actually consumed. Rendering and proxy traffic are what make
120
+ // these paths expensive, so dropping them here would leave every metered run
121
+ // reporting zero and make the ceiling unenforceable for exactly the kinds that need
122
+ // one. A direct fetch reports neither, which is correct: it spends neither.
123
+ render_ms: Number.isInteger(result.renderMs) ? result.renderMs : 0,
124
+ proxy_bytes: Number.isInteger(result.proxyBytes) ? result.proxyBytes : 0 };
125
+ } catch (error) {
126
+ // Any error carrying its own reason keeps it, including the pacing hook's, so a
127
+ // slot we could not take is classified as deferrable rather than as a failure.
128
+ const reason = typeof error?.reason === 'string' ? error.reason : 'acquisition_error';
129
+ const outcome = error instanceof AcquisitionError && /not_configured|not_asserted|_invalid$/.test(reason)
130
+ ? 'not_configured' : classifyFailure(reason);
131
+ return { ...base, outcome, reason };
132
+ }
133
+ },
134
+ };
135
+ }
@@ -0,0 +1,182 @@
1
+ import { validatePublicUrl } from '../verifier/url.js';
2
+
3
+ // Turns a publisher's own declarations into crawl seeds. Pure: parsing only, no fetching.
4
+ //
5
+ // Sitemap discovery lives here rather than in the verifier's robots parser because it is a
6
+ // crawl concern. `robotsDecision` answers "may I fetch this URL"; a Sitemap directive is a
7
+ // non-group line that answers a different question and must not change that verdict.
8
+ export const SEED_LIMITS = Object.freeze({
9
+ documentBytes: 10 * 1024 * 1024, // sitemap protocol caps an uncompressed file at 50 MB; we read far less
10
+ urlsPerDocument: 50_000, // the protocol's own per-file URL cap
11
+ sitemapDirectives: 100,
12
+ indexDepth: 2, // an index of indexes is legal; deeper is a loop or a mistake
13
+ });
14
+
15
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
16
+
17
+ export class SeedError extends Error {
18
+ constructor(reason) { super(reason); this.name = 'SeedError'; this.reason = reason; }
19
+ }
20
+
21
+ function decodeXml(value) {
22
+ return value.replace(/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]+));/giu, (whole, dec, hex, name) => {
23
+ if (dec || hex) {
24
+ const code = Number.parseInt(dec ?? hex, dec ? 10 : 16);
25
+ return Number.isSafeInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
26
+ }
27
+ return ENTITIES[name.toLowerCase()] ?? whole;
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Absolute, public sitemap URLs declared in a robots.txt body. A `Sitemap:` line is
33
+ * position-independent and belongs to no user-agent group, so it is read from the whole file.
34
+ */
35
+ export function sitemapDirectives(robotsText, { baseUrl } = {}) {
36
+ if (typeof robotsText !== 'string') return [];
37
+ const found = [];
38
+ for (const line of robotsText.replace(/^/u, '').split(/\r?\n/u)) {
39
+ const stripped = line.split('#', 1)[0].trim();
40
+ const colon = stripped.indexOf(':');
41
+ if (colon < 0 || stripped.slice(0, colon).trim().toLowerCase() !== 'sitemap') continue;
42
+ const value = stripped.slice(colon + 1).trim();
43
+ if (!value) continue;
44
+ let absolute;
45
+ try { absolute = baseUrl ? new URL(value, baseUrl).href : value; } catch { continue; }
46
+ const checked = validatePublicUrl(absolute);
47
+ // A sitemap on another host is legal but is a scope decision for the caller, not a
48
+ // silent expansion here, so it is returned with its host intact and judged upstream.
49
+ if (checked.valid && !found.includes(checked.url)) found.push(checked.url);
50
+ if (found.length >= SEED_LIMITS.sitemapDirectives) break;
51
+ }
52
+ return found;
53
+ }
54
+
55
+ /**
56
+ * A sitemap may only declare URLs at or below its own path. This is the "cross-submit" rule
57
+ * from the sitemap protocol, and it is a scope control, not a formality: without it a sitemap
58
+ * at `anyone.example/sitemap.xml` could inject URLs for a host we never chose to crawl, and a
59
+ * sitemap at `/blog/sitemap.xml` could claim the whole site. The reference implementation
60
+ * (crawler-commons) enforces exactly this, with a lenient mode; we have no lenient mode.
61
+ */
62
+ export function withinSitemapScope(candidate, sitemapUrl, scope = 'path') {
63
+ let url, base;
64
+ try { url = new URL(candidate); base = new URL(sitemapUrl); } catch { return false; }
65
+ if (url.hostname !== base.hostname) return false;
66
+ // A sitemap NAMED IN robots.txt is authorised for the whole host, because robots.txt sits
67
+ // at the root and is itself the authorisation. Only a sitemap found some other way is
68
+ // confined to its own directory. Without this, a perfectly ordinary site that keeps its
69
+ // sitemap at /sitemaps/example.com/sitemap.xml has every one of its URLs rejected —
70
+ // measured against nolo.com, which is exactly that shape.
71
+ if (scope === 'host') return true;
72
+ const directory = base.pathname.slice(0, base.pathname.lastIndexOf('/') + 1);
73
+ return url.pathname.startsWith(directory);
74
+ }
75
+
76
+ /**
77
+ * Reads a sitemap or sitemap index.
78
+ *
79
+ * Deliberately a bounded scan for `<loc>` values rather than a full XML parse: we consume
80
+ * exactly one element type from a document written by a third party, and a scanner cannot be
81
+ * driven into entity expansion or deep-nesting attacks by hostile markup. The tradeoff is
82
+ * recorded rather than hidden — this does not validate the document, and a `<loc>` inside an
83
+ * unexpected element would still be read.
84
+ *
85
+ * @throws SeedError when the document is too large or is not a sitemap at all.
86
+ */
87
+ export function parseSitemap(input, { baseUrl, enforceScope = true, scope = 'path' } = {}) {
88
+ if (typeof input !== 'string') throw new SeedError('sitemap_not_text');
89
+ if (input.length > SEED_LIMITS.documentBytes) throw new SeedError('sitemap_too_large');
90
+ // A byte-order mark before the root element defeats every subsequent match.
91
+ const xml = input.replace(/^\uFEFF/u, '').replace(/^\s+/u, '');
92
+ // Gzipped sitemaps are extremely common and are the acquisition layer's job to decompress.
93
+ // Say so rather than reporting a valid .xml.gz as "not a sitemap".
94
+ if (xml.charCodeAt(0) === 0x1f && xml.charCodeAt(1) === 0x8b) throw new SeedError('sitemap_is_gzipped');
95
+ const indexAt = xml.search(/<\s*sitemapindex[\s>]/iu);
96
+ const setAt = xml.search(/<\s*urlset[\s>]/iu);
97
+ // A plain-text sitemap is one URL per line and is part of the protocol.
98
+ if (indexAt < 0 && setAt < 0) return parseTextSitemap(xml, { baseUrl, enforceScope, scope });
99
+ const kind = indexAt >= 0 && (setAt < 0 || indexAt < setAt) ? 'index' : 'urlset';
100
+
101
+ const urls = [];
102
+ const skipped = {};
103
+ let truncated = false;
104
+ const pattern = /<\s*loc\s*>([\s\S]*?)<\s*\/\s*loc\s*>/giu;
105
+ for (const match of xml.matchAll(pattern)) {
106
+ if (urls.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
107
+ let raw = decodeXml(match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gu, '$1')).trim();
108
+ if (!raw) continue;
109
+ // The protocol requires an absolute URL here. Resolving a relative value against the
110
+ // sitemap would turn any stray text into a crawlable path on the publisher's host.
111
+ const checked = absoluteEntry(raw);
112
+ if (!checked.valid) { skipped[checked.reason] = (skipped[checked.reason] ?? 0) + 1; continue; }
113
+ if (enforceScope && baseUrl && !withinSitemapScope(checked.url, baseUrl, scope)) {
114
+ skipped.out_of_sitemap_scope = (skipped.out_of_sitemap_scope ?? 0) + 1;
115
+ continue;
116
+ }
117
+ if (!urls.includes(checked.url)) urls.push(checked.url);
118
+ }
119
+ return { kind, urls, truncated, skipped };
120
+ }
121
+
122
+ /** A sitemap entry must stand on its own as a public absolute URL. */
123
+ function absoluteEntry(value) {
124
+ try { new URL(value); } catch { return { valid: false, reason: 'not_absolute' }; }
125
+ return validatePublicUrl(value);
126
+ }
127
+
128
+ /** The protocol's plain-text format: one absolute URL per line, no markup. */
129
+ function parseTextSitemap(text, { baseUrl, enforceScope, scope = 'path' }) {
130
+ const urls = [], skipped = {};
131
+ let truncated = false;
132
+ for (const line of text.split(/\r?\n/u)) {
133
+ const value = line.trim();
134
+ if (!value || value.startsWith('#')) continue;
135
+ // Anything with markup is not a text sitemap; do not half-read an unknown document.
136
+ if (value.startsWith('<')) throw new SeedError('not_a_sitemap');
137
+ if (urls.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
138
+ const checked = absoluteEntry(value);
139
+ if (!checked.valid) { skipped[checked.reason] = (skipped[checked.reason] ?? 0) + 1; continue; }
140
+ if (enforceScope && baseUrl && !withinSitemapScope(checked.url, baseUrl, scope)) {
141
+ skipped.out_of_sitemap_scope = (skipped.out_of_sitemap_scope ?? 0) + 1;
142
+ continue;
143
+ }
144
+ if (!urls.includes(checked.url)) urls.push(checked.url);
145
+ }
146
+ if (!urls.length && !Object.keys(skipped).length) throw new SeedError('not_a_sitemap');
147
+ return { kind: 'urlset', urls, truncated, skipped };
148
+ }
149
+
150
+ /**
151
+ * Walks a sitemap index to its leaf URLs, using a caller-supplied fetch so this module still
152
+ * performs no I/O. `fetchDocument(url)` returns the body text or null when it could not be read.
153
+ *
154
+ * A document we could not read is counted, never treated as a sitemap with no URLs — the same
155
+ * rule extraction follows, for the same reason.
156
+ */
157
+ export async function expandSitemaps(roots, fetchDocument, { maxDepth = SEED_LIMITS.indexDepth, maxDocuments = SEED_LIMITS.sitemapDirectives, scope = 'path' } = {}) {
158
+ const urls = [], visited = new Set(), failures = {};
159
+ let documents = 0, truncated = false;
160
+ const queue = roots.map(url => ({ url, depth: 0 }));
161
+ while (queue.length) {
162
+ const { url, depth } = queue.shift();
163
+ if (visited.has(url)) continue;
164
+ visited.add(url);
165
+ if (documents >= maxDocuments) { truncated = true; break; }
166
+ documents++;
167
+ const body = await fetchDocument(url);
168
+ if (body === null || body === undefined) { failures.unreadable = (failures.unreadable ?? 0) + 1; continue; }
169
+ let parsed;
170
+ try { parsed = parseSitemap(body, { baseUrl: url, scope }); }
171
+ catch (error) { failures[error.reason] = (failures[error.reason] ?? 0) + 1; continue; }
172
+ if (parsed.truncated) truncated = true;
173
+ if (parsed.kind === 'index') {
174
+ // An index deeper than the limit is a loop or a mistake; stop rather than follow it.
175
+ if (depth >= maxDepth) { failures.index_too_deep = (failures.index_too_deep ?? 0) + 1; continue; }
176
+ for (const child of parsed.urls) queue.push({ url: child, depth: depth + 1 });
177
+ continue;
178
+ }
179
+ for (const found of parsed.urls) if (!urls.includes(found)) urls.push(found);
180
+ }
181
+ return { urls, documents, truncated, failures };
182
+ }
@@ -0,0 +1,237 @@
1
+ import { z } from 'zod';
2
+ import { validatePublicUrl } from '../verifier/url.js';
3
+
4
+ // Internal v1 contract only. No route, queue or source is activated by this module.
5
+ export const DISCOVERY_VERSION = 1;
6
+ export const DISCOVERY_LIMITS = Object.freeze({ rows: 1000, bodyBytes: 2 * 1024 * 1024, timeoutMs: 15000, cursorBytes: 8192 });
7
+
8
+ // Where an edge came from. `linktrail_corpus` is our own crawl; a purchased index is one
9
+ // alternative, never the assumed default. The `provider_*` prefix is deliberately kept for
10
+ // owned rows: it marks upstream data that has NOT been checked against the customer's
11
+ // expectation, whoever produced it. Only the verification_* fields carry a real check.
12
+ export const DISCOVERY_SOURCES = Object.freeze(['linktrail_corpus', 'dataforseo', 'imported']);
13
+ export const DISCOVERY_DATA_MODES = Object.freeze(['synthetic', 'owned_corpus', 'provider_index', 'imported']);
14
+
15
+ // Suppliers whose exports we know how to read. The name is recorded so a row can always say
16
+ // where it came from; it is NOT a claim that we queried them or that they endorse anything.
17
+ export const IMPORT_SUPPLIERS = Object.freeze([
18
+ 'ahrefs', 'google_search_console', 'bing_webmaster_tools', 'semrush', 'majestic', 'moz', 'dataforseo', 'linkody', 'csv',
19
+ ]);
20
+
21
+ // A data mode belongs to exactly one source, except fixtures, which any source may model.
22
+ const SOURCE_DATA_MODES = Object.freeze({ linktrail_corpus: 'owned_corpus', dataforseo: 'provider_index', imported: 'imported' });
23
+ // A source orders and scores rows in its own terms. An owned corpus has no authority metric,
24
+ // so it must not borrow a supplier's rank scale to look like one — and an IMPORT has no
25
+ // ordering at all. A customer's export arrives in whatever order their tool wrote it, filtered
26
+ // however they filtered it, so `as_supplied` is the only honest ordering to claim.
27
+ const SOURCE_ORDER_BY = Object.freeze({ linktrail_corpus: 'first_seen,desc', dataforseo: 'rank,desc', imported: 'as_supplied' });
28
+ const SOURCE_RANK_SCALE = Object.freeze({ linktrail_corpus: null, dataforseo: 'one_thousand', imported: null });
29
+ const discoverySource = z.enum(DISCOVERY_SOURCES);
30
+ const dataMode = z.enum(DISCOVERY_DATA_MODES);
31
+
32
+ // Reject a source/mode pair that cannot exist, and hold each source to its own ordering terms.
33
+ function refineSourceShape(row, ctx) {
34
+ if (row.data_mode !== 'synthetic' && SOURCE_DATA_MODES[row.provider] !== row.data_mode)
35
+ ctx.addIssue({ code: 'custom', message: `Data mode ${row.data_mode} does not belong to source ${row.provider}.`, path: ['data_mode'] });
36
+ }
37
+ function refineQueryForSource(provider, query, ctx) {
38
+ if (query.order_by[0] !== SOURCE_ORDER_BY[provider])
39
+ ctx.addIssue({ code: 'custom', message: `Source ${provider} cannot order by ${query.order_by[0]}.`, path: ['query', 'order_by'] });
40
+ if (query.rank_scale !== SOURCE_RANK_SCALE[provider])
41
+ ctx.addIssue({ code: 'custom', message: `Source ${provider} does not publish that rank scale.`, path: ['query', 'rank_scale'] });
42
+ }
43
+ const count = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
44
+ const timestamp = z.iso.datetime({ precision: 3 });
45
+ const identifier = z.string().min(1).max(128).regex(/^[a-zA-Z0-9_-]+$/);
46
+ const nullableText = max => z.string().max(max).nullable();
47
+ const publicUrl = z.string().max(4096).refine(value => {
48
+ const checked = validatePublicUrl(value);
49
+ return checked.valid && checked.url === value;
50
+ }, 'Expected a canonical public HTTP(S) URL.');
51
+ const hash = z.string().regex(/^[a-f0-9]{64}$/);
52
+
53
+ export const DiscoveryQuery = z.strictObject({
54
+ target_kind: z.enum(['domain', 'exact_url']),
55
+ target: z.string().min(1).max(4096),
56
+ include_subdomains: z.boolean(),
57
+ backlinks_status_type: z.enum(['live', 'lost', 'all']),
58
+ exclude_internal_backlinks: z.boolean(),
59
+ mode: z.literal('as_is'),
60
+ filters: z.null(),
61
+ order_by: z.tuple([z.enum(Object.values(SOURCE_ORDER_BY))]),
62
+ rank_scale: z.enum(['one_thousand']).nullable(),
63
+ page_limit: count.min(1).max(DISCOVERY_LIMITS.rows),
64
+ row_limit: count.min(1).max(DISCOVERY_LIMITS.rows),
65
+ }).superRefine((q, ctx) => {
66
+ const url = validatePublicUrl(q.target_kind === 'domain' ? `https://${q.target}/` : q.target);
67
+ if (!url.valid || (q.target_kind === 'domain' ? url.hostname !== q.target || q.target.startsWith('www.') : url.url !== q.target))
68
+ ctx.addIssue({ code: 'custom', message: 'Invalid or noncanonical discovery target.', path: ['target'] });
69
+ if (q.target_kind === 'exact_url' && q.include_subdomains)
70
+ ctx.addIssue({ code: 'custom', message: 'Exact URL scope cannot include subdomains.', path: ['include_subdomains'] });
71
+ if (q.page_limit > q.row_limit)
72
+ ctx.addIssue({ code: 'custom', message: 'Page limit exceeds run row limit.', path: ['page_limit'] });
73
+ });
74
+
75
+ export const DiscoveryCandidate = z.strictObject({
76
+ v: z.literal(DISCOVERY_VERSION),
77
+ id: z.string().regex(/^dc_[a-f0-9]{64}$/),
78
+ workspace_id: identifier, project_id: identifier, discovery_run_id: identifier,
79
+ source_url: publicUrl, target_url: publicUrl,
80
+ provider: discoverySource, data_mode: dataMode,
81
+ provider_task_id: identifier,
82
+ provider_retrieved_at: timestamp,
83
+ provider_first_seen: timestamp.nullable(), provider_prev_seen: timestamp.nullable(), provider_last_seen: timestamp.nullable(),
84
+ provider_status: z.strictObject({ is_lost: z.boolean().nullable(), is_broken: z.boolean().nullable(), is_new: z.boolean().nullable() }),
85
+ anchor: nullableText(4096), rel: z.array(z.string().min(1).max(128)).max(32).nullable(),
86
+ dofollow: z.boolean().nullable(), link_type: nullableText(64),
87
+ source_http_status: z.number().int().min(100).max(599).nullable(),
88
+ target_http_status: z.number().int().min(100).max(599).nullable(),
89
+ links_count: count.nullable(),
90
+ // Exactly one source's metric block, keyed by that source. No shape is mandatory across
91
+ // sources: an owned crawl publishes what it actually observed and no authority score.
92
+ provider_metrics: z.union([
93
+ z.strictObject({ dataforseo: z.strictObject({
94
+ rank: count.max(1000).nullable(), page_from_rank: count.max(1000).nullable(),
95
+ domain_from_rank: count.max(1000).nullable(), backlink_spam_score: count.max(100).nullable(),
96
+ rank_scale: z.literal('one_thousand'),
97
+ }) }),
98
+ z.strictObject({ imported: z.strictObject({
99
+ supplier: z.enum(IMPORT_SUPPLIERS),
100
+ supplier_row_id: nullableText(256),
101
+ // What the EXPORT says about its own age, which is often nothing. `provider_retrieved_at`
102
+ // on the row above is when WE read the file; these are different facts and conflating
103
+ // them would let an import of a two-year-old export read as fresh data.
104
+ supplier_generated_at: timestamp.nullable(),
105
+ // The supplier's own numbers, on the supplier's own scale, never normalised. Ahrefs DR,
106
+ // Moz DA and Majestic Trust Flow measure different things on different scales; a single
107
+ // "authority" column would be nonsense dressed as a metric. Each keeps its own name, and
108
+ // the scale is the supplier's name itself.
109
+ supplier_metrics: z.record(z.string().min(1).max(64), z.union([z.number(), z.string().max(256), z.null()]))
110
+ .refine(value => Object.keys(value).length <= 32, 'At most 32 supplier metrics.').nullable(),
111
+ }) }),
112
+ z.strictObject({ linktrail_corpus: z.strictObject({
113
+ // Real crawl facts. Outlink counts qualify a page; they are not an authority metric.
114
+ source_outlink_count: count.nullable(), source_external_outlink_count: count.nullable(),
115
+ fetch_kind: z.enum(['direct', 'rendered', 'proxied']),
116
+ // False when the body was truncated or parsing stopped early. Carries the truncation
117
+ // rule into the data: an incomplete read can confirm a link it saw, never absence.
118
+ extraction_complete: z.boolean(),
119
+ }) }),
120
+ ]),
121
+ // Provider flags, dates and scores may never fill these independent check fields.
122
+ verification_status: z.enum(['not_checked', 'present', 'absent', 'unknown', 'source_unavailable']),
123
+ verified_at: timestamp.nullable(), observation_id: identifier.nullable(), evidence_id: z.string().min(1).max(1024).nullable(),
124
+ }).superRefine((c, ctx) => {
125
+ const unverified = c.verification_status === 'not_checked';
126
+ if ([c.verified_at, c.observation_id, c.evidence_id].some(value => (value === null) !== unverified))
127
+ ctx.addIssue({ code: 'custom', message: 'A check result requires its own timestamp, observation and evidence; unverified candidates have none.' });
128
+ refineSourceShape(c, ctx);
129
+ if (!Object.hasOwn(c.provider_metrics, c.provider))
130
+ ctx.addIssue({ code: 'custom', message: `Metrics are not keyed by the source ${c.provider} that produced this row.`, path: ['provider_metrics'] });
131
+ });
132
+
133
+ export const ProviderCheckpoint = z.strictObject({
134
+ query_hash: hash,
135
+ page_index: count,
136
+ offset: count.max(20000),
137
+ search_after_token: z.string().min(1).max(DISCOVERY_LIMITS.cursorBytes).nullable(),
138
+ // Preserve the offset of the request that started a token chain, if any.
139
+ request_offset: count.max(20000).nullable().default(null),
140
+ });
141
+
142
+ export const ProviderPage = z.strictObject({
143
+ v: z.literal(DISCOVERY_VERSION), provider: discoverySource, data_mode: dataMode,
144
+ provider_task_id: identifier, provider_retrieved_at: timestamp,
145
+ provider_total_count: count.nullable(), provider_reported_cost_microusd: count.nullable(),
146
+ returned_rows: count.max(DISCOVERY_LIMITS.rows), rejected_rows: count, duplicate_rows: count,
147
+ candidates: z.array(DiscoveryCandidate).max(DISCOVERY_LIMITS.rows),
148
+ coverage: z.enum(['complete_for_query', 'capped', 'partial']),
149
+ coverage_reason: nullableText(128), next_checkpoint: ProviderCheckpoint.nullable(),
150
+ }).superRefine((page, ctx) => {
151
+ if (page.candidates.length + page.rejected_rows + page.duplicate_rows !== page.returned_rows)
152
+ ctx.addIssue({ code: 'custom', message: 'Provider page row accounting does not balance.' });
153
+ if (page.coverage === 'complete_for_query' && (page.rejected_rows > 0 || page.next_checkpoint !== null || page.provider_total_count === null))
154
+ ctx.addIssue({ code: 'custom', message: 'Provider page cannot claim complete query coverage.' });
155
+ // An import can never claim completeness. We are reading a file a customer exported, and
156
+ // nothing in it tells us whether they took page one of forty or filtered it first. Saying
157
+ // "complete" about someone else's export would be the one claim in this contract we have no
158
+ // way at all to check.
159
+ if (page.provider === 'imported' && page.coverage === 'complete_for_query')
160
+ ctx.addIssue({ code: 'custom', message: 'An import cannot claim complete query coverage: its scope is the export, which is unknown.', path: ['coverage'] });
161
+ refineSourceShape(page, ctx);
162
+ if (page.candidates.some(c => c.provider !== page.provider || c.data_mode !== page.data_mode))
163
+ ctx.addIssue({ code: 'custom', message: 'A page cannot carry rows from another source or data mode.', path: ['candidates'] });
164
+ // A truncated read cannot have seen every link on the page, so it cannot close the query.
165
+ if (page.coverage === 'complete_for_query'
166
+ && page.candidates.some(c => c.provider_metrics.linktrail_corpus?.extraction_complete === false))
167
+ ctx.addIssue({ code: 'custom', message: 'An incomplete extraction cannot claim complete query coverage.', path: ['coverage'] });
168
+ });
169
+
170
+ export const DiscoveryRun = z.strictObject({
171
+ v: z.literal(DISCOVERY_VERSION), id: identifier, workspace_id: identifier, project_id: identifier,
172
+ provider: discoverySource, data_mode: dataMode,
173
+ query: DiscoveryQuery, query_hash: hash,
174
+ status: z.enum(['queued', 'running', 'succeeded', 'partial', 'failed', 'reconciliation_required']),
175
+ created_at: timestamp, started_at: timestamp.nullable(), finished_at: timestamp.nullable(),
176
+ coverage: z.enum(['pending', 'complete_for_query', 'capped', 'partial', 'unknown']),
177
+ coverage_reason: nullableText(128),
178
+ provider_total_count: count.nullable(), checkpoint: ProviderCheckpoint.nullable(),
179
+ usage: z.strictObject({
180
+ unit: z.literal('discovery'), currency: z.literal('USD'), quote_id: identifier,
181
+ max_cost_microusd: count, reserved_cost_microusd: count,
182
+ provider_reported_cost_microusd: count.nullable(),
183
+ request_count: count, returned_rows: count, accepted_candidates: count, rejected_rows: count, duplicate_rows: count,
184
+ }),
185
+ }).superRefine((run, ctx) => {
186
+ const u = run.usage;
187
+ if (u.returned_rows > run.query.row_limit || u.accepted_candidates + u.rejected_rows + u.duplicate_rows !== u.returned_rows)
188
+ ctx.addIssue({ code: 'custom', message: 'Discovery row accounting does not balance within the cap.' });
189
+ if (u.reserved_cost_microusd > u.max_cost_microusd)
190
+ ctx.addIssue({ code: 'custom', message: 'Reservation exceeds the authorized ceiling.' });
191
+ if (run.checkpoint && run.checkpoint.query_hash !== run.query_hash)
192
+ ctx.addIssue({ code: 'custom', message: 'Checkpoint belongs to another query.' });
193
+ if (run.coverage === 'complete_for_query' && (run.status !== 'succeeded' || u.rejected_rows > 0 || run.checkpoint !== null || run.provider_total_count === null))
194
+ ctx.addIssue({ code: 'custom', message: 'Incomplete or rejected data cannot claim complete query coverage.' });
195
+ if (run.provider === 'imported' && run.coverage === 'complete_for_query')
196
+ ctx.addIssue({ code: 'custom', message: 'An import cannot claim complete query coverage: its scope is the export, which is unknown.', path: ['coverage'] });
197
+ // An import is a file, not a metered request. A ceiling above zero would mean we intended to
198
+ // spend on it, and a receipt for a purchase nobody made is worse than no receipt.
199
+ if (run.provider === 'imported' && (u.max_cost_microusd !== 0 || u.reserved_cost_microusd !== 0 || (u.provider_reported_cost_microusd ?? 0) !== 0))
200
+ ctx.addIssue({ code: 'custom', message: 'An import costs nothing and must not carry a spend.', path: ['usage'] });
201
+ refineSourceShape(run, ctx);
202
+ refineQueryForSource(run.provider, run.query, ctx);
203
+ });
204
+
205
+ export async function discoveryHash(value) {
206
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value)));
207
+ return Array.from(new Uint8Array(bytes), byte => byte.toString(16).padStart(2, '0')).join('');
208
+ }
209
+
210
+ // Parsing fixes key order before hashing; transport pagination is not query identity.
211
+ export async function queryHash(query) { return discoveryHash(DiscoveryQuery.parse(query)); }
212
+ // Identity is (run, source page, target page), which is exactly the uniqueness the schema
213
+ // enforces. A run already fixes its source, so naming one here would only make the ID
214
+ // unstable if a source were ever relabelled.
215
+ export async function candidateId(runId, sourceUrl, targetUrl) {
216
+ identifier.parse(runId); publicUrl.parse(sourceUrl); publicUrl.parse(targetUrl);
217
+ return `dc_${await discoveryHash([DISCOVERY_VERSION, runId, sourceUrl, targetUrl])}`;
218
+ }
219
+
220
+ // The ordering terms a source accepts, for a caller assembling a query it can run.
221
+ export function sourceQueryTerms(provider) {
222
+ discoverySource.parse(provider);
223
+ return { order_by: [SOURCE_ORDER_BY[provider]], rank_scale: SOURCE_RANK_SCALE[provider] };
224
+ }
225
+
226
+ export async function createDiscoveryRun({ id, workspaceId, projectId, provider, query, dataMode, quoteId, maxCostMicrousd, now }) {
227
+ const normalized = DiscoveryQuery.parse(query);
228
+ return DiscoveryRun.parse({
229
+ v: DISCOVERY_VERSION, id, workspace_id: workspaceId, project_id: projectId,
230
+ provider: discoverySource.parse(provider), data_mode: dataMode, query: normalized, query_hash: await queryHash(normalized),
231
+ status: 'queued', created_at: now, started_at: null, finished_at: null,
232
+ coverage: 'pending', coverage_reason: null, provider_total_count: null, checkpoint: null,
233
+ usage: { unit: 'discovery', currency: 'USD', quote_id: quoteId, max_cost_microusd: maxCostMicrousd,
234
+ reserved_cost_microusd: 0, provider_reported_cost_microusd: null, request_count: 0,
235
+ returned_rows: 0, accepted_candidates: 0, rejected_rows: 0, duplicate_rows: 0 },
236
+ });
237
+ }