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,90 @@
1
+ import { parse, defaultTreeAdapter } from 'parse5';
2
+
3
+ const INERT = new Set(['script', 'style', 'template', 'noscript']);
4
+ const CHROME = new Set(['nav', 'header', 'footer', 'aside', 'head']);
5
+ const ROOT_IDS = new Set(['root', 'app', '__next', '__nuxt']);
6
+ const attr = (node, name) => node.attrs?.find((item) => item.name === name)?.value;
7
+ const hidden = (node) => attr(node, 'hidden') !== undefined
8
+ || (node.tagName === 'dialog' && attr(node, 'open') === undefined)
9
+ || /(?:^|;)\s*(?:display\s*:\s*none|visibility\s*:\s*(?:hidden|collapse))\s*(?:!important\s*)?(?:;|$)/iu.test(attr(node, 'style') ?? '');
10
+
11
+ /** Static evidence only: this does not claim computed CSS visibility or browser readiness. */
12
+ export function assessReadiness({ html, parsed, targetUrl }) {
13
+ if (typeof html !== 'string') throw new TypeError('HTML must be a string');
14
+ const bounded = () => ({ requiresRender: true, reason: 'readiness_limit_exceeded', possibleLoginWall: false });
15
+ if (html.length > 2 * 1024 * 1024) return bounded();
16
+ let elements = 0;
17
+ const started = performance.now();
18
+ let document;
19
+ try {
20
+ document = parse(html, { treeAdapter: {
21
+ ...defaultTreeAdapter,
22
+ createElement(...args) {
23
+ if (++elements > 25_000 || args[2].length > 256 || performance.now() - started > 500) {
24
+ throw new RangeError('readiness_limit_exceeded');
25
+ }
26
+ return defaultTreeAdapter.createElement(...args);
27
+ },
28
+ } });
29
+ } catch (error) {
30
+ if (error instanceof RangeError && error.message === 'readiness_limit_exceeded') return bounded();
31
+ throw error;
32
+ }
33
+ let hasScript = false;
34
+ let targetInScript = false;
35
+ let hasVisiblePassword = false;
36
+ let contentText = '';
37
+ let articleText = '';
38
+ let emptyAppRoot = false;
39
+ let emptyProfileRoot = false;
40
+ const roots = [];
41
+ const stack = [{ node: document, chrome: false, form: false, article: false, root: null }];
42
+ while (stack.length) {
43
+ const state = stack.pop();
44
+ const { node } = state;
45
+ if (hidden(node)) continue;
46
+ if (node.tagName === 'script') {
47
+ hasScript = true;
48
+ // Script data is a rendering hint, never a link occurrence. Keep this exact and narrow.
49
+ if (targetUrl && node.childNodes?.some((child) => child.nodeName === '#text'
50
+ && child.value.replace(/\\\//gu, '/').includes(targetUrl))) targetInScript = true;
51
+ }
52
+ if (INERT.has(node.tagName)) continue;
53
+ const chrome = state.chrome || CHROME.has(node.tagName);
54
+ const form = state.form || node.tagName === 'form';
55
+ const article = state.article || node.tagName === 'article';
56
+ let root = state.root;
57
+ const id = (attr(node, 'id') ?? '').toLowerCase();
58
+ if (ROOT_IDS.has(id) || ['profile', 'profile-root', 'profile-container'].includes(id)) {
59
+ root = { app: ROOT_IDS.has(id), content: false, parent: root };
60
+ roots.push(root);
61
+ }
62
+ if (node.tagName === 'input' && (attr(node, 'type') ?? '').toLowerCase() === 'password') hasVisiblePassword = true;
63
+ if (!chrome && node.nodeName === '#text' && node.value.trim()) {
64
+ for (let ancestor = root; ancestor && !ancestor.content; ancestor = ancestor.parent) ancestor.content = true;
65
+ if (!form) {
66
+ contentText += node.value.slice(0, 256 - contentText.length);
67
+ if (article) articleText += node.value.slice(0, 256 - articleText.length);
68
+ }
69
+ }
70
+ if (!chrome && ['img', 'video', 'audio', 'iframe', 'canvas', 'input', 'textarea'].includes(node.tagName)) {
71
+ for (let ancestor = root; ancestor && !ancestor.content; ancestor = ancestor.parent) ancestor.content = true;
72
+ }
73
+ for (const child of node.childNodes ?? []) stack.push({ node: child, chrome, form, article, root });
74
+ }
75
+ for (const root of roots) {
76
+ if (!root.content && root.app) emptyAppRoot = true;
77
+ if (!root.content && !root.app) emptyProfileRoot = true;
78
+ }
79
+ const substantiveContent = contentText.replace(/\s+/gu, ' ').trim().length >= 80
80
+ || articleText.replace(/\s+/gu, ' ').trim().length >= 40;
81
+ const possibleLoginWall = hasVisiblePassword && !substantiveContent;
82
+ let reason = null;
83
+ if (hasScript && !substantiveContent) {
84
+ if (emptyAppRoot) reason = 'empty_application_root';
85
+ else if (emptyProfileRoot && targetInScript) reason = 'profile_target_in_script_only';
86
+ else if (!(parsed?.links?.length) && !contentText.trim()) reason = 'empty_script_shell';
87
+ }
88
+ return { requiresRender: reason !== null, reason, possibleLoginWall,
89
+ hasVisiblePassword, targetInScript, emptyAppRoot, emptyProfileRoot };
90
+ }
@@ -0,0 +1,57 @@
1
+ import { validatePublicUrl } from './url.js';
2
+ import { retryAfterSeconds } from './fetch.js';
3
+
4
+ const text = (value, max = 256) => typeof value === 'string' && value.length <= max && !/[\x00-\x1f\x7f]/u.test(value);
5
+ const url = value => text(value, 2048) && validatePublicUrl(value).valid;
6
+
7
+ // Native transport metadata is checked separately from the DOM parser. A provider's
8
+ // ready flag cannot turn a challenge, failed session or partial document into proof.
9
+ export function nativeRenderEvidence(doc, version) {
10
+ const invalid = reason => ({ reason });
11
+ if (version !== 1 || doc.rendererVersion !== 'cf-native-1'
12
+ || !text(doc.contentType) || !text(doc.cfMitigated)
13
+ || typeof doc.challenge !== 'boolean' || typeof doc.visibleLogin !== 'boolean'
14
+ || typeof doc.browserClosed !== 'boolean'
15
+ || !text(doc.browserVersion) || !doc.browserVersion || !text(doc.userAgent, 512) || !doc.userAgent
16
+ || !Number.isInteger(doc.requestCount) || doc.requestCount < 1 || doc.requestCount > 150
17
+ || !Number.isFinite(doc.durationMs) || doc.durationMs < 0 || doc.durationMs > 20_000
18
+ || !(doc.terminationReason === null || text(doc.terminationReason, 80))
19
+ || !Array.isArray(doc.robotsHistory) || !doc.robotsHistory.length || doc.robotsHistory.length > 150) return invalid('invalid_native_render_evidence');
20
+ if (doc.browserClosed !== true) return invalid('render_close_unconfirmed');
21
+ if (doc.terminationReason) return invalid('render_acquisition_incomplete');
22
+ if (doc.cfMitigated.toLowerCase() === 'challenge' || doc.challenge) return invalid('render_access_challenge');
23
+ if (doc.cfMitigated) return invalid('render_access_mitigated');
24
+ if (doc.visibleLogin) return invalid('render_login_wall');
25
+ if (!/^(?:text\/html|application\/xhtml\+xml)(?:\s*;|\s*$)/iu.test(doc.contentType)) return invalid('render_not_html');
26
+ const robotsHistory = [];
27
+ for (const row of doc.robotsHistory) {
28
+ if (!row || !url(row.sourceUrl) || !url(row.robotsUrl) || row.allowed !== true
29
+ || row.productToken !== 'LinktrailBot' || !text(row.reason, 80)
30
+ || !Number.isInteger(row.httpStatus) || row.httpStatus < 100 || row.httpStatus > 599
31
+ || (row.crawlDelaySeconds !== null && (!Number.isFinite(row.crawlDelaySeconds) || row.crawlDelaySeconds < 0))) return invalid('invalid_render_robots_evidence');
32
+ robotsHistory.push({ sourceUrl: row.sourceUrl, robotsUrl: row.robotsUrl, allowed: true,
33
+ productToken: 'LinktrailBot', reason: row.reason, httpStatus: row.httpStatus,
34
+ crawlDelaySeconds: row.crawlDelaySeconds });
35
+ }
36
+ // Each main-document hop must have a recorded authorization, including the final URL.
37
+ const authorized = new Set(robotsHistory.map(row => row.sourceUrl));
38
+ if (!authorized.has(doc.finalUrl) || doc.redirects.some(hop => !authorized.has(hop.from) || !authorized.has(hop.to))) return invalid('invalid_render_robots_evidence');
39
+ const evidence = { rendererVersion: doc.rendererVersion, contentType: doc.contentType,
40
+ cfMitigated: doc.cfMitigated, challenge: false, visibleLogin: false, browserClosed: true,
41
+ browserVersion: doc.browserVersion, userAgent: doc.userAgent, requestCount: doc.requestCount,
42
+ durationMs: doc.durationMs, terminationReason: null, robotsHistory };
43
+ for (const key of ['blockedMutationRequests','blockedEssentialRequests']) {
44
+ if (doc[key] === undefined) continue;
45
+ if (!Number.isInteger(doc[key]) || doc[key] < 0 || doc[key] > 150) return invalid('invalid_native_render_evidence');
46
+ evidence[key] = doc[key];
47
+ }
48
+ if (doc.sessionId !== undefined) {
49
+ if (!text(doc.sessionId, 128)) return invalid('invalid_native_render_evidence');
50
+ evidence.sessionId = doc.sessionId;
51
+ }
52
+ if (doc.retryAfter !== undefined) {
53
+ if (!text(doc.retryAfter,128)) return invalid('invalid_native_render_evidence');
54
+ evidence.retryAfterSeconds = retryAfterSeconds(doc.retryAfter);
55
+ }
56
+ return { evidence };
57
+ }
@@ -0,0 +1,85 @@
1
+ // RFC 9309 matching, with conservative treatment of unavailable/access-blocked files
2
+ // performed by the fetcher. Unknown extension directives are not access grants.
3
+ function normalizedOctets(value) {
4
+ return value.replace(/%([0-9a-f]{2})/giu, (_, hex) => {
5
+ const char = String.fromCharCode(Number.parseInt(hex, 16));
6
+ return /[a-z0-9._~-]/iu.test(char) ? char : `%${hex.toUpperCase()}`;
7
+ }).replace(/[^\x00-\x7f]/gu, (char) => encodeURIComponent(char));
8
+ }
9
+
10
+ // Linear-space wildcard matcher; avoids regex backtracking on origin-controlled patterns.
11
+ function matches(pattern, path, budget) {
12
+ let anchored = pattern.endsWith('$');
13
+ if (anchored) pattern = pattern.slice(0, -1);
14
+ else pattern += '*';
15
+ let pi = 0;
16
+ let si = 0;
17
+ let star = -1;
18
+ let retry = 0;
19
+ while (si < path.length) {
20
+ if (--budget.remaining < 0) return null;
21
+ if (pattern[pi] === '*') { star = pi++; retry = si; }
22
+ else if (pattern[pi] === path[si]) { pi++; si++; }
23
+ else if (star !== -1) { pi = star + 1; si = ++retry; }
24
+ else return false;
25
+ }
26
+ while (pattern[pi] === '*') pi++;
27
+ return pi === pattern.length;
28
+ }
29
+
30
+ export function robotsDecision(text, url, productToken = 'LinktrailBot') {
31
+ const groups = [];
32
+ let group = null;
33
+ let hasDirectives = false;
34
+ let ruleCount = 0;
35
+ for (const original of text.replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
36
+ const line = original.split('#', 1)[0].trim();
37
+ const colon = line.indexOf(':');
38
+ if (colon < 0) continue;
39
+ const key = line.slice(0, colon).trim().toLowerCase();
40
+ const value = line.slice(colon + 1).trim();
41
+ if (key === 'user-agent') {
42
+ if (!group || hasDirectives) {
43
+ group = { agents: [], rules: [], crawlDelays: [] };
44
+ groups.push(group);
45
+ hasDirectives = false;
46
+ }
47
+ if (value) group.agents.push(value.toLowerCase());
48
+ } else if (group && ['allow', 'disallow', 'crawl-delay'].includes(key)) {
49
+ hasDirectives = true;
50
+ if (key === 'crawl-delay') {
51
+ const delay = Number(value);
52
+ if (value && Number.isFinite(delay) && delay >= 0) group.crawlDelays.push(delay);
53
+ } else if (value.startsWith('/')) {
54
+ if (++ruleCount > 5000 || value.length > 8192) return { allowed: null, reason: 'robots_too_complex' };
55
+ group.rules.push({ allow: key === 'allow', path: normalizedOctets(value) });
56
+ }
57
+ }
58
+ }
59
+ const token = productToken.toLowerCase();
60
+ // PREFIX, not substring. RFC 9309 and Google's implementation both match a robots `User-agent`
61
+ // value against the beginning of the crawler's product token — which is why `googlebot` selects
62
+ // the group for `Googlebot-News`. Substring matching additionally selected a group written for
63
+ // `bot` or `trail`, and since the common case is `Disallow: /`, that refused fetches we were
64
+ // allowed to make and reported them as the publisher's choice.
65
+ //
66
+ // A short prefix like `link` still matches, and that is the spec working as designed rather than
67
+ // a defect: it is the reason crawler names are chosen to be distinctive.
68
+ const specific = groups.filter((item) => item.agents.some((agent) => agent !== '*' && token.startsWith(agent)));
69
+ const applicable = specific.length ? specific : groups.filter((item) => item.agents.includes('*'));
70
+ const path = normalizedOctets(`${new URL(url).pathname}${new URL(url).search}`);
71
+ const matching = [];
72
+ const budget = { remaining: 2_000_000 };
73
+ for (const rule of applicable.flatMap((item) => item.rules)) {
74
+ const matched = matches(rule.path, path, budget);
75
+ if (matched === null) return { allowed: null, reason: 'robots_too_complex' };
76
+ if (matched) matching.push(rule);
77
+ }
78
+ matching.sort((a, b) => b.path.replace(/[\*$]/gu, '').length - a.path.replace(/[\*$]/gu, '').length || Number(b.allow) - Number(a.allow));
79
+ return {
80
+ allowed: matching.length ? matching[0].allow : true,
81
+ reason: matching.length && !matching[0].allow ? 'robots_disallowed' : 'robots_allowed',
82
+ matchedRule: matching[0]?.path ?? null,
83
+ crawlDelaySeconds: Math.max(0, ...applicable.flatMap((item) => item.crawlDelays)),
84
+ };
85
+ }
@@ -0,0 +1,129 @@
1
+ const CONFIRMATION_MS = 30 * 60 * 1000;
2
+
3
+ const timeOf = (value) => {
4
+ if (value === null || value === undefined || value === '') return null;
5
+ const time = new Date(value).getTime();
6
+ return Number.isFinite(time) ? time : null;
7
+ };
8
+
9
+ function summary(observation) {
10
+ if (!observation) return null;
11
+ const { evidence } = observation;
12
+ const all = observation.occurrences ?? [];
13
+ // State/event snapshots are small references to full observations in D1/R2.
14
+ // linkSignature covers ALL occurrences, preserving change detection beyond excerpts.
15
+ return {
16
+ state: observation.state,
17
+ reason: observation.reason,
18
+ sourceUrl: observation.sourceUrl,
19
+ finalUrl: observation.finalUrl,
20
+ targetUrl: observation.targetUrl,
21
+ targetScope: observation.targetScope,
22
+ httpStatus: observation.httpStatus,
23
+ checkedAt: observation.checkedAt,
24
+ linkSignature: observation.linkSignature,
25
+ occurrenceCount: observation.occurrenceCount ?? all.length,
26
+ occurrencesTruncated: observation.occurrencesTruncated === true || all.length > 10,
27
+ occurrences: all.slice(0, 10).map(({ href, targetUrl, anchor, rel, context, locator }) => ({
28
+ href: href?.slice(0, 512), targetUrl: targetUrl?.slice(0, 512), anchor: anchor?.slice(0, 256),
29
+ rel, context: context?.slice(0, 320), locator,
30
+ })),
31
+ directives: observation.directives ? { noindex: observation.directives.noindex, nofollow: observation.directives.nofollow, indexingStatus: observation.directives.indexingStatus } : undefined,
32
+ evidence: evidence ? {
33
+ complete: evidence.complete, method: evidence.method, checkerVersion: evidence.checkerVersion,
34
+ sha256: evidence.sha256, bytes: evidence.bytes, fetchedAt: evidence.fetchedAt,
35
+ } : undefined,
36
+ };
37
+ }
38
+
39
+ function fingerprint(observation) {
40
+ if (observation?.linkSignature) return observation.linkSignature;
41
+ return JSON.stringify({
42
+ source: observation?.finalUrl,
43
+ occurrences: observation?.occurrences?.map(({ targetUrl, anchor, rel }) => ({ targetUrl, anchor, rel: [...(rel ?? [])].sort() })),
44
+ noindex: observation?.directives?.noindex,
45
+ nofollow: observation?.directives?.nofollow,
46
+ });
47
+ }
48
+
49
+ /** Pure state reducer. Persist its result atomically with the observation + outbox. */
50
+ export function transitionState(previous, observation, now = Date.now()) {
51
+ const prior = previous ?? {};
52
+ const checkedMs = timeOf(observation.checkedAt) ?? timeOf(typeof now === 'function' ? now() : now);
53
+ if (checkedMs === null) throw new TypeError('A valid observation timestamp is required');
54
+ const checkedAt = new Date(checkedMs).toISOString();
55
+ const latestMs = prior.latestAttempt ? timeOf(prior.latestAttempt.checkedAt) : null;
56
+ const base = {
57
+ state: prior.state ?? 'unknown',
58
+ latestAttempt: prior.latestAttempt ?? null,
59
+ lastSuccessfulObservation: prior.lastSuccessfulObservation ?? null,
60
+ firstAbsentAt: prior.firstAbsentAt ?? null,
61
+ absentCount: prior.absentCount ?? 0,
62
+ firstUnavailableAt: prior.firstUnavailableAt ?? null,
63
+ unavailableCount: prior.unavailableCount ?? 0,
64
+ sourceUnavailableConfirmed: prior.sourceUnavailableConfirmed ?? false,
65
+ wasEverPresent: prior.wasEverPresent ?? prior.lastSuccessfulObservation?.state === 'present',
66
+ nextCheckAt: null,
67
+ retryNotBefore: prior.retryNotBefore ?? null,
68
+ uncertain: prior.uncertain ?? true,
69
+ event: null,
70
+ ignored: false,
71
+ };
72
+ if (latestMs !== null && checkedMs <= latestMs) return { ...base, nextCheckAt: prior.nextCheckAt ?? null, ignored: true };
73
+ base.latestAttempt = summary({ ...observation, checkedAt });
74
+ if (observation.state === 'unknown' || observation.evidence?.complete !== true) {
75
+ base.uncertain = true;
76
+ // A scheduler decides retry/backoff after an unknown; no past due timestamp loop. The one
77
+ // thing carried is a floor the publisher stated in a Retry-After — "not before", never "then",
78
+ // so the scheduler takes the later of this and the watch's own cadence.
79
+ if (Number.isFinite(observation.retryAfterSeconds)) {
80
+ base.retryNotBefore = new Date(checkedMs + observation.retryAfterSeconds * 1000).toISOString();
81
+ }
82
+ return base;
83
+ }
84
+ if (!['present', 'absent', 'source_unavailable'].includes(observation.state)) throw new TypeError('Invalid observation state');
85
+ base.lastSuccessfulObservation = base.latestAttempt;
86
+ base.uncertain = false;
87
+ if (observation.state === 'present') {
88
+ base.state = 'present';
89
+ base.wasEverPresent = true;
90
+ base.firstAbsentAt = null;
91
+ base.absentCount = 0;
92
+ base.firstUnavailableAt = null;
93
+ base.unavailableCount = 0;
94
+ base.sourceUnavailableConfirmed = false;
95
+ let type;
96
+ if (!prior.wasEverPresent && prior.lastSuccessfulObservation?.state !== 'present') type = 'placement_acquired';
97
+ else if (prior.state === 'confirmed_missing' || prior.sourceUnavailableConfirmed) type = 'placement_recovered';
98
+ else if (prior.lastSuccessfulObservation?.state === 'present' && fingerprint(prior.lastSuccessfulObservation) !== fingerprint(observation)) type = 'placement_changed';
99
+ if (type) base.event = { type, before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
100
+ return base;
101
+ }
102
+ if (observation.state === 'absent') {
103
+ const first = timeOf(base.firstAbsentAt);
104
+ base.firstAbsentAt = first === null ? checkedAt : base.firstAbsentAt;
105
+ base.absentCount = first === null ? 1 : base.absentCount + 1;
106
+ base.firstUnavailableAt = null;
107
+ base.unavailableCount = 0;
108
+ base.sourceUnavailableConfirmed = false;
109
+ const confirmed = first !== null && checkedMs - first >= CONFIRMATION_MS && base.absentCount >= 2;
110
+ base.state = confirmed ? 'confirmed_missing' : 'suspected_missing';
111
+ if (!confirmed) base.nextCheckAt = new Date((first ?? checkedMs) + CONFIRMATION_MS).toISOString();
112
+ if (confirmed && prior.state !== 'confirmed_missing' && base.wasEverPresent) {
113
+ base.event = { type: 'placement_lost', before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
114
+ }
115
+ return base;
116
+ }
117
+ const first = timeOf(base.firstUnavailableAt);
118
+ base.firstUnavailableAt = first === null ? checkedAt : base.firstUnavailableAt;
119
+ base.unavailableCount = first === null ? 1 : base.unavailableCount + 1;
120
+ base.firstAbsentAt = null;
121
+ base.absentCount = 0;
122
+ base.state = 'source_unavailable';
123
+ base.sourceUnavailableConfirmed = first !== null && checkedMs - first >= CONFIRMATION_MS && base.unavailableCount >= 2;
124
+ if (!base.sourceUnavailableConfirmed) base.nextCheckAt = new Date((first ?? checkedMs) + CONFIRMATION_MS).toISOString();
125
+ if (base.sourceUnavailableConfirmed && !prior.sourceUnavailableConfirmed) {
126
+ base.event = { type: 'source_unavailable', before: summary(prior.lastSuccessfulObservation), after: base.latestAttempt };
127
+ }
128
+ return base;
129
+ }
@@ -0,0 +1,56 @@
1
+ const RESERVED_SUFFIXES = [
2
+ 'localhost', 'local', 'internal', 'intranet', 'lan', 'home', 'home.arpa',
3
+ 'test', 'invalid', 'example', 'onion', 'alt', 'arpa',
4
+ ];
5
+
6
+ /** Lexical screening only. A public-only fetch transport MUST also prevent DNS rebinding. */
7
+ export function validatePublicUrl(input) {
8
+ if (typeof input !== 'string' || input.length > 8192 || /[\u0000-\u0020\u007f\\]/u.test(input)) {
9
+ return { valid: false, reason: 'invalid_url' };
10
+ }
11
+ let url;
12
+ try { url = new URL(input); } catch { return { valid: false, reason: 'invalid_url' }; }
13
+ if (!['http:', 'https:'].includes(url.protocol)) return { valid: false, reason: 'unsupported_protocol' };
14
+ if (url.username || url.password) return { valid: false, reason: 'url_credentials' };
15
+ if (url.port) return { valid: false, reason: 'unsupported_port' };
16
+ // URL canonicalization expands hexadecimal, octal and integer IPv4 spellings.
17
+ // No literal IPs are a supported publisher target, even globally routable ones.
18
+ const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
19
+ if (hostname.startsWith('[') || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
20
+ return { valid: false, reason: 'ip_literal_not_supported' };
21
+ }
22
+ if (hostname.length > 253 || !hostname.includes('.') || hostname.split('.').some(
23
+ (label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label),
24
+ )) return { valid: false, reason: 'non_public_hostname' };
25
+ if (RESERVED_SUFFIXES.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))) {
26
+ return { valid: false, reason: 'non_public_hostname' };
27
+ }
28
+ url.hostname = hostname;
29
+ url.hash = '';
30
+ return { valid: true, url: url.href, hostname };
31
+ }
32
+
33
+ export function matchesTarget(candidate, target, scope) {
34
+ const found = new URL(candidate);
35
+ const wanted = new URL(target);
36
+ found.hostname = found.hostname.toLowerCase().replace(/\.$/, '');
37
+ wanted.hostname = wanted.hostname.toLowerCase().replace(/\.$/, '');
38
+ switch (scope) {
39
+ case 'domain':
40
+ return found.hostname === wanted.hostname || found.hostname.endsWith(`.${wanted.hostname}`);
41
+ case 'subdomain':
42
+ return found.hostname === wanted.hostname;
43
+ case 'path': {
44
+ const prefix = wanted.pathname.endsWith('/') ? wanted.pathname : `${wanted.pathname}/`;
45
+ return found.origin === wanted.origin
46
+ && (found.pathname === wanted.pathname || found.pathname.startsWith(prefix))
47
+ && (!wanted.search || found.search === wanted.search);
48
+ }
49
+ case 'exact':
50
+ found.hash = '';
51
+ wanted.hash = '';
52
+ return found.href === wanted.href;
53
+ default:
54
+ return false;
55
+ }
56
+ }
@@ -0,0 +1,59 @@
1
+ # SITE.md — fill-in template
2
+
3
+ Copy this file to `.claude/SITE.md` in the site's repository and fill in every section. The
4
+ content skills in this package resolve their rules from it: remove a section only if it truly
5
+ does not apply, and say so in its place — an empty section reads as "no rules", which is a
6
+ different statement than "not applicable here". Placeholder text in brackets is not a value;
7
+ nothing ships with brackets left in.
8
+
9
+ Nothing in this file is sent anywhere. It is the customer's own judgment about their own site,
10
+ kept in their own repository.
11
+
12
+ ## Site identity
13
+
14
+ - **Public site:** [https://example.com/] — [static export / SSR / plain HTML; where content lives in the repo]
15
+ - **What the business sells:** [one sentence, in the reader's words]
16
+ - **Niche:** [the niche, stated narrowly enough that "relevant publisher" is decidable]
17
+ - **Page types:** [hub / comparison / review / explainer / tool — the real URL patterns, e.g. /compare/x-vs-y/]
18
+
19
+ ## Voice
20
+
21
+ [2-4 sentences a new writer can hold every page to. Name the reader and what they are checking.
22
+ Evidence beats enthusiasm, or whatever is true for this site. If a fuller voice reference exists
23
+ in the repo, link it here and keep this section as the summary. Read by: source-cited-content-builder
24
+ (drafting), source-cited-humanizer (the pass), pre-publish-review (Pass B).]
25
+
26
+ ## Terminology table
27
+
28
+ One term per concept. Enforced by pre-publish-review; used by the humanizer pass.
29
+
30
+ | Use | Don't use | Notes |
31
+ | --- | --- | --- |
32
+ | [preferred term] | [term to avoid] | [why, or a claims-policy pointer] |
33
+ | [preferred term] | [term to avoid] | |
34
+
35
+ ## Claims policy
36
+
37
+ [What may be claimed, with what evidence, and what may never be claimed. For health-adjacent,
38
+ legal or financial niches this section is load-bearing: state the categories that are CRITICAL
39
+ findings if asserted without evidence (medical, dosage, legal, financial outcomes), the disclosure
40
+ rules (e.g. affiliate disclosure placement), and who signs off on edge cases. Read by:
41
+ source-cited-content-builder (guardrails) and pre-publish-review (claims-policy findings).]
42
+
43
+ ## Niche primary sources
44
+
45
+ [The sources this niche's facts must come from, ranked. Government, standards bodies, utilities,
46
+ manufacturers, recognized industry publications — then the niche's own credible primary sources.
47
+ Name any source class that must NEVER be cited (e.g. vendor marketing claims for health-adjacent
48
+ facts). Read by: source-cited-content-builder, step 2 — this list is what "your niche's preferred
49
+ primary sources (SITE.md)" resolves to.]
50
+
51
+ ## Content roots
52
+
53
+ [The directories or URL prefixes where publishable content lives, for the orphan check and the
54
+ internal-link pass. Example: content pages under /guides/ built from content/ in the repo. Read
55
+ by: internal-linking-optimizer (scan scope, orphan grep) and pre-publish-review (Pass A linking).]
56
+
57
+ - Content directories: [paths in the repository]
58
+ - Published URL patterns: [patterns, e.g. /guides/<slug>/]
59
+ - Sitemap: [path or URL, if one exists]
@@ -0,0 +1,65 @@
1
+ {
2
+ "schema_version": 1,
3
+ "campaigns": {
4
+ "guest_post": {
5
+ "version": 1,
6
+ "goal": "Contribute an original article useful to a relevant publisher's audience.",
7
+ "inputs": ["product audience", "author expertise", "proposed topic", "linkable target"],
8
+ "qualify": ["Publisher covers this audience", "Recent editorial activity", "Published contribution route or explicit editor interest", "Proposed topic adds material the site lacks"],
9
+ "evidence": ["Relevant recent article URL", "Contribution-policy URL and observed date", "Editor or submission form source"],
10
+ "reject": ["Required payment hidden from campaign owner", "Irrelevant audience", "Mass-produced guest-post inventory without editorial review"],
11
+ "agent_work": ["Read contribution terms", "Develop a differentiated outline", "Use the existing email tool when outreach is authorized"],
12
+ "success": "Published article contains an accurate, contextually useful attribution to the target asset."
13
+ },
14
+ "resource_addition": {
15
+ "version": 1,
16
+ "goal": "Help a resource-page editor add a genuinely useful missing resource.",
17
+ "inputs": ["target resource", "reader problem solved", "topic and geography", "resource-page examples"],
18
+ "qualify": ["Page curates outside resources", "Our asset fills an observable gap", "Page's audience matches the asset", "Publisher has a working submission route"],
19
+ "evidence": ["Candidate source page", "Relevant section and current neighboring resources", "Specific missing information our asset supplies"],
20
+ "reject": ["The target duplicates an existing resource without improvement", "Resource is mainly a sales page with little independent utility"],
21
+ "agent_work": ["Read the page in context", "Explain the precise addition", "Record editor response using external message IDs"],
22
+ "success": "The editor adds the resource to a relevant section, then an independent check verifies the placement."
23
+ },
24
+ "broken_link": {
25
+ "version": 1,
26
+ "goal": "Offer an accurate replacement for a demonstrably unavailable cited resource.",
27
+ "inputs": ["source page", "dead destination", "replacement asset", "original citation purpose"],
28
+ "qualify": ["Repeated checks establish unavailability rather than blocking", "Replacement answers the original citation's purpose", "Replacement claims have evidence"],
29
+ "evidence": ["Original anchor and surrounding context", "Check status and time", "Archived original when available", "Replacement comparison"],
30
+ "reject": ["A single timeout or bot challenge treated as dead", "Replacement meaning differs from original", "Historical source unavailable but replacement accuracy unverified"],
31
+ "agent_work": ["Investigate ambiguous browser results", "Compare original context and replacement", "Explain the broken resource factually"],
32
+ "success": "The editor repairs the citation using a suitable destination and monitoring confirms the result."
33
+ },
34
+ "competitor": {
35
+ "version": 1,
36
+ "goal": "Find relevant publisher opportunities from observed competitor placements.",
37
+ "inputs": ["named competitors", "our audience and differentiator", "desired asset", "known source coverage"],
38
+ "qualify": ["Placement is editorially relevant", "Our offering independently merits inclusion", "Source remains active", "There is a real route to contribute or request correction"],
39
+ "evidence": ["Observed source page and destination", "Provider or owned-index provenance and retrieval time", "Fresh source verification", "Editorial inclusion criteria"],
40
+ "reject": ["Competitor link is assumed to guarantee our eligibility", "Affiliate or sponsorship terms concealed", "Provider disappearance treated as confirmed link loss"],
41
+ "agent_work": ["Classify the actual campaign mechanism", "Propose a specific contribution", "Preserve discovery coverage limitations"],
42
+ "success": "A qualified, evidence-backed opportunity becomes an earned, independently verified placement."
43
+ },
44
+ "reclamation": {
45
+ "version": 1,
46
+ "goal": "Repair a previously earned link or request useful attribution for an existing mention.",
47
+ "inputs": ["prior placement or mention", "correct destination", "current observation", "prior publisher relationship if known"],
48
+ "qualify": ["Prior link or relevant mention has evidence", "Change is confirmed rather than an inaccessible page", "Correct destination helps the reader"],
49
+ "evidence": ["Earlier and current observations", "Source URL and surrounding context", "Correct canonical destination"],
50
+ "reject": ["Unknown fetch outcome described as intentional removal", "Unrelated namesake mention", "Attribution request presented as a legal demand without a basis"],
51
+ "agent_work": ["Inspect ambiguous changes", "Check whether our own destination changed", "Use the prior thread when available"],
52
+ "success": "The placement is repaired or useful attribution is added, with the outcome recorded locally."
53
+ },
54
+ "custom": {
55
+ "version": 1,
56
+ "goal": "Define a campaign around the user's actual product, assets and audience.",
57
+ "inputs": ["user objective", "publisher incentive", "target asset", "qualification criteria", "budget and outreach constraints"],
58
+ "qualify": ["State why a publisher and its reader benefit", "Define observable acceptance and rejection criteria", "Specify what evidence is required"],
59
+ "evidence": ["Campaign-specific source URLs", "Observed dates", "Known uncertainties"],
60
+ "reject": ["Invented contact details or publisher policies", "Unsupported authority or ranking guarantees"],
61
+ "agent_work": ["Write a product-specific goal and rubric in campaign notes", "Adapt available research, browser and email tools", "Track outcomes using the common CRM entities"],
62
+ "success": "Campaign-specific acceptance criteria are met and supported by verifiable evidence."
63
+ }
64
+ }
65
+ }