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.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +24 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +190 -0
- package/NOTICE +17 -0
- package/README.md +202 -0
- package/bin/agentlinkops.mjs +4 -0
- package/bin/linktrail.mjs +6 -0
- package/cli/adapters/csv.js +68 -0
- package/cli/adapters/sqlite-crm.js +79 -0
- package/cli/adapters/suppliers.js +180 -0
- package/cli/agent-setup.js +203 -0
- package/cli/agentlinkops.mjs +4 -0
- package/cli/args.js +18 -0
- package/cli/catalog.js +7483 -0
- package/cli/check.js +119 -0
- package/cli/citation.js +109 -0
- package/cli/client.js +60 -0
- package/cli/commands.js +183 -0
- package/cli/config.js +104 -0
- package/cli/connection.js +90 -0
- package/cli/context-mcp.js +193 -0
- package/cli/context.js +340 -0
- package/cli/delivery.js +67 -0
- package/cli/doctor.js +283 -0
- package/cli/env.js +66 -0
- package/cli/fleet.js +82 -0
- package/cli/gsc-browser.js +239 -0
- package/cli/import.js +108 -0
- package/cli/ledger.js +232 -0
- package/cli/linktrail.mjs +6 -0
- package/cli/local-result.js +161 -0
- package/cli/main.js +523 -0
- package/cli/mirror.js +126 -0
- package/cli/mix.js +123 -0
- package/cli/platforms.js +161 -0
- package/cli/receipt-performance.js +64 -0
- package/cli/receipts.js +122 -0
- package/cli/report.js +158 -0
- package/cli/setup-plan.js +211 -0
- package/cli/skill.js +39 -0
- package/cli/state.js +106 -0
- package/cli/status.js +51 -0
- package/cli/sync.js +179 -0
- package/package.json +59 -0
- package/references/cli.md +198 -0
- package/references/disavow.md +73 -0
- package/references/reports.md +53 -0
- package/scripts/agentlinkops.py +651 -0
- package/scripts/disavow.py +207 -0
- package/scripts/linktrail.py +10 -0
- package/scripts/migrations/001_crm.sql +45 -0
- package/scripts/migrations/002_cloud_events.sql +13 -0
- package/scripts/migrations/003_disavow.sql +20 -0
- package/scripts/migrations/004_placement_costs.sql +2 -0
- package/scripts/report.py +113 -0
- package/shared/agent-clients.js +49 -0
- package/shared/observation-event.js +43 -0
- package/shared/response-contract.js +59 -0
- package/shared/search-connection-contract.js +18 -0
- package/skills/agentlinkops-assets/SKILL.md +13 -0
- package/skills/agentlinkops-assets/agents/openai.yaml +5 -0
- package/skills/agentlinkops-campaigns/SKILL.md +17 -0
- package/skills/agentlinkops-campaigns/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/SKILL.md +33 -0
- package/skills/agentlinkops-connect/agents/openai.yaml +12 -0
- package/skills/agentlinkops-connect/references/agentlinkops.md +266 -0
- package/skills/agentlinkops-crm/SKILL.md +27 -0
- package/skills/agentlinkops-crm/agents/openai.yaml +12 -0
- package/skills/content-defingerprinting/SKILL.md +29 -0
- package/skills/content-defingerprinting/scripts/watermarks/README.md +102 -0
- package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +26 -0
- package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +52 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +21 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +96 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +432 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +86 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +460 -0
- package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +651 -0
- package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +27 -0
- package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +212 -0
- package/skills/internal-linking-optimizer/SKILL.md +56 -0
- package/skills/internal-linking-optimizer/references/source-prompt.md +76 -0
- package/skills/pre-publish-review/SKILL.md +179 -0
- package/skills/source-cited-content-builder/SKILL.md +56 -0
- package/skills/source-cited-content-builder/references/source-prompt.md +19 -0
- package/skills/source-cited-humanizer/SKILL.md +20 -0
- package/src/citations/adapter.js +141 -0
- package/src/citations/contract.js +171 -0
- package/src/citations/match.js +120 -0
- package/src/citations/runner.js +151 -0
- package/src/citations/stats.js +74 -0
- package/src/competitors/contract.js +157 -0
- package/src/competitors/domain-mix.js +199 -0
- package/src/context/ga4.js +652 -0
- package/src/context/gsc.js +925 -0
- package/src/context/site-profile.js +445 -0
- package/src/context/util.js +71 -0
- package/src/context-tool-registry.js +27 -0
- package/src/corpus/acquire.js +135 -0
- package/src/corpus/seeds.js +182 -0
- package/src/discovery/contract.js +237 -0
- package/src/discovery/import.js +156 -0
- package/src/tool-views.js +117 -0
- package/src/verifier/fetch.js +303 -0
- package/src/verifier/index.js +311 -0
- package/src/verifier/observation-kind.js +91 -0
- package/src/verifier/parser.js +241 -0
- package/src/verifier/readiness.js +90 -0
- package/src/verifier/render-evidence.js +57 -0
- package/src/verifier/robots.js +85 -0
- package/src/verifier/transition.js +129 -0
- package/src/verifier/url.js +56 -0
- package/templates/SITE.template.md +59 -0
- package/templates/campaigns.v1.json +65 -0
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
// Read-only GSC context and the manual fallback (DP-0017-T02), implemented against the
|
|
2
|
+
// [first-party context contract](../../docs/initiatives/DP-0017-first-party-search-context/first-party-context-contract.md).
|
|
3
|
+
//
|
|
4
|
+
// The contract's rule runs through every function here: Google OBSERVES search behavior, it
|
|
5
|
+
// does not diagnose it, and it never says a placement earned anything. Structured outputs keep
|
|
6
|
+
// three layers separate — what Google reported, the derived rate, and nothing else. Inference
|
|
7
|
+
// and recommendation live in the human/agent-owned files, never in these rows.
|
|
8
|
+
//
|
|
9
|
+
// Ownership follows the DP-0014 ledger split: `gsc.jsonl` and `context-state.json` are
|
|
10
|
+
// tool-owned facts (appended, never rewritten, newest answer wins at read); `manual.md` is
|
|
11
|
+
// human/agent-owned judgment. Absent data follows the verifier's unknown discipline: nulls
|
|
12
|
+
// stay null, a truncated top-rows answer is not an inventory, and an unreadable state is
|
|
13
|
+
// named, never inferred from emptiness.
|
|
14
|
+
//
|
|
15
|
+
// No Google credentials are authorized for this project. Every state below is proven by
|
|
16
|
+
// fixture clients in test/context-gsc.test.js; the HTTP transport at the bottom exists for the
|
|
17
|
+
// day a customer connects their own property and is never exercised by the tests.
|
|
18
|
+
import { rm, readFile } from 'node:fs/promises';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { readJsonl, appendJsonl, readJson, writeJson, dayStamp, isValidDay as isDateLike, addDays, daysBetween, parseDay, numberOrNullOr } from './util.js';
|
|
21
|
+
|
|
22
|
+
const isValidDay = value => isDateLike(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
|
|
23
|
+
|
|
24
|
+
export const GSC_SCOPE = 'https://www.googleapis.com/auth/webmasters.readonly';
|
|
25
|
+
|
|
26
|
+
// The contract's state table, plus `ok` for a clean read. A read resolves to exactly one of
|
|
27
|
+
// these; nothing is left to be inferred from an empty result.
|
|
28
|
+
export const CONTEXT_STATES = Object.freeze([
|
|
29
|
+
'ok', 'manual_only', 'not_authorized', 'revoked', 'property_not_verified', 'permission_restricted',
|
|
30
|
+
'empty_site', 'incomplete_window', 'truncated_top_rows', 'page_outside_property', 'quota_exceeded',
|
|
31
|
+
'window_before_available_data',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
export const RETRIEVED_BY = 'linktrail-gsc';
|
|
35
|
+
|
|
36
|
+
export const CONTEXT_LIMITS = Object.freeze({
|
|
37
|
+
// The product envelope, enforced on ourselves so a refresh is cheap by construction.
|
|
38
|
+
refreshCallsPerProperty: 12, // default budget; the standard window set fits under it
|
|
39
|
+
hardCapCallsPerRun: 50, // absolute ceiling across every property in one run
|
|
40
|
+
rowLimitCap: 25_000, // the API's own documented maximum
|
|
41
|
+
defaultRowLimit: 1_000, // the API's documented default
|
|
42
|
+
availabilityProbes: 24, // bounded backwards monthly probe
|
|
43
|
+
availabilityCacheDays: 30, // the floor is re-observed at most monthly
|
|
44
|
+
freshnessMs: 6 * 60 * 60 * 1000, // an identical window re-query inside this window is served from the repo
|
|
45
|
+
quotaRetryHintMinutes: 15, // Google's documented remediation for load quota
|
|
46
|
+
quotaRetries: 1, // one retry after the hint, then stop
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const SEARCH_TYPES = Object.freeze(['web', 'image', 'video', 'news', 'googleNews', 'discover']);
|
|
50
|
+
export const AGGREGATION_TYPES = Object.freeze(['byPage', 'byProperty']);
|
|
51
|
+
export const DATA_STATES = Object.freeze(['final', 'all']);
|
|
52
|
+
|
|
53
|
+
export class ContextError extends Error {
|
|
54
|
+
constructor(reason, details = {}) { super(reason); this.name = 'ContextError'; this.reason = reason; this.details = details; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when an HTTP status means the grant is gone (contract: 401/403). */
|
|
58
|
+
export const isAuthorizationLoss = status => status === 401 || status === 403;
|
|
59
|
+
|
|
60
|
+
/** Recorded stamps are always ISO strings: a `now` may yield a Date, and freshness reads strings. */
|
|
61
|
+
const isoOf = value => (value instanceof Date ? value : new Date(value)).toISOString();
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Paths. Standalone on purpose: cli/config.js is not edited by this card. The same
|
|
65
|
+
// `.agentlinkops/config.json` `paths` overrides are honoured, with the contract's defaults.
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const CONTEXT_DEFAULT_FILES = Object.freeze({
|
|
69
|
+
gsc: 'context/gsc.jsonl',
|
|
70
|
+
contextState: 'context/context-state.json',
|
|
71
|
+
siteFacts: 'context/site-facts.jsonl',
|
|
72
|
+
manual: 'context/manual.md',
|
|
73
|
+
siteProfile: 'context/site-profile.md',
|
|
74
|
+
ga4: 'context/ga4.jsonl', // T04's tool-owned GA4 fact rows; same config `paths` override as the rest
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolves the context file paths. `dir` is the `.agentlinkops/` directory (from the CLI config);
|
|
79
|
+
* `file` is the parsed config.json when available, because every path is a default the customer
|
|
80
|
+
* may move. The contract is deliberately not opinionated about where files live.
|
|
81
|
+
*/
|
|
82
|
+
export function contextPaths({ dir, file = {} } = {}) {
|
|
83
|
+
const paths = { ...file.paths };
|
|
84
|
+
const at = name => resolve(dir, paths[name] ?? CONTEXT_DEFAULT_FILES[name]);
|
|
85
|
+
return {
|
|
86
|
+
gsc: at('gsc'), contextState: at('contextState'), siteFacts: at('siteFacts'),
|
|
87
|
+
manual: at('manual'), siteProfile: at('siteProfile'), ga4: at('ga4'),
|
|
88
|
+
contextDir: join(dir, 'context'),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reads `.agentlinkops/config.json` when present, for callers that only have the directory. */
|
|
93
|
+
export async function loadContextConfig(dir) {
|
|
94
|
+
try { return JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')); } catch { return {}; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Manual inputs — the floor of the whole card. Every later stage runs on manual.md alone.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const MANUAL_SECTIONS = Object.freeze({
|
|
102
|
+
'target pages': 'target_pages', 'targets': 'target_pages', 'pages': 'target_pages',
|
|
103
|
+
'site description': 'site_description', 'site or niche': 'site_description', 'niche': 'site_description',
|
|
104
|
+
'what we sell': 'offering', 'offering': 'offering', 'business': 'offering',
|
|
105
|
+
'known assets': 'assets', 'assets': 'assets',
|
|
106
|
+
'competitors': 'competitors', 'competitor domains': 'competitors',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parses the human-owned manual.md. Permissive by design: sections are matched case-insensitively
|
|
111
|
+
* by heading, entries are the bullet lines under them, and free prose is kept verbatim. Entries
|
|
112
|
+
* carry `source: 'manual'` and no observation date — which is exactly why they never migrate
|
|
113
|
+
* into fact rows.
|
|
114
|
+
*/
|
|
115
|
+
export function parseManual(markdown) {
|
|
116
|
+
if (typeof markdown !== 'string' || !markdown.trim()) return { present: false, entries: [], target_pages: [], site_description: null, offering: null, assets: [], competitors: [] };
|
|
117
|
+
const parsed = { present: true, entries: [], target_pages: [], site_description: null, offering: null, assets: [], competitors: [] };
|
|
118
|
+
let field = null, prose = [];
|
|
119
|
+
const flushProse = () => {
|
|
120
|
+
if ((field === 'site_description' || field === 'offering') && prose.length) {
|
|
121
|
+
parsed[field] = prose.join(' ').trim();
|
|
122
|
+
parsed.entries.push({ field, value: parsed[field], source: 'manual' });
|
|
123
|
+
}
|
|
124
|
+
prose = [];
|
|
125
|
+
};
|
|
126
|
+
for (const raw of markdown.replace(/^/u, '').split(/\r?\n/u)) {
|
|
127
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/u.exec(raw.trim());
|
|
128
|
+
if (heading) {
|
|
129
|
+
flushProse();
|
|
130
|
+
field = MANUAL_SECTIONS[heading[1].trim().toLowerCase()] ?? null;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const bullet = /^\s*(?:[-*+]|\d+[.)])\s+(.+?)\s*$/u.exec(raw);
|
|
134
|
+
const text = raw.trim();
|
|
135
|
+
if (!text || !field) continue;
|
|
136
|
+
if (Array.isArray(parsed[field])) {
|
|
137
|
+
const value = (bullet ? bullet[1] : text).trim();
|
|
138
|
+
if (!value) continue;
|
|
139
|
+
parsed[field].push(value);
|
|
140
|
+
parsed.entries.push({ field, value, source: 'manual' });
|
|
141
|
+
} else if (field === 'site_description' || field === 'offering') {
|
|
142
|
+
// Prose sections keep their sentences; a bullet under them is still the author's words.
|
|
143
|
+
prose.push((bullet ? bullet[1] : text).trim());
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
flushProse();
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function readManual(path) {
|
|
151
|
+
let text = null;
|
|
152
|
+
try { text = await readFile(path, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
153
|
+
return text === null ? { present: false, entries: [], target_pages: [], site_description: null, offering: null, assets: [], competitors: [] } : parseManual(text);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The complete no-Google context path. Nothing here needs a connection: the pages a campaign
|
|
158
|
+
* should consider are the manual targets, nominated by judgment, with observation left null
|
|
159
|
+
* rather than invented. The three contract layers are carried separately so nothing collapses
|
|
160
|
+
* into "Google says pursue this".
|
|
161
|
+
*/
|
|
162
|
+
export function buildManualContext({ manual, pages = null } = {}) {
|
|
163
|
+
const targets = pages ?? manual.target_pages ?? [];
|
|
164
|
+
return {
|
|
165
|
+
state: 'manual_only',
|
|
166
|
+
source: 'manual.md',
|
|
167
|
+
inputs: manual.entries,
|
|
168
|
+
pages: targets.map(page => ({
|
|
169
|
+
page,
|
|
170
|
+
nominated_by: ['manual'],
|
|
171
|
+
observation: null,
|
|
172
|
+
inference: null, // editable judgment, lives in the profile — never here
|
|
173
|
+
recommendation: null, // a DP-0009 brief's job
|
|
174
|
+
})),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Windows. Comparable deltas only; incomparable comparisons are refused, not printed.
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
export function windowFromRange(start, end) {
|
|
183
|
+
if (!isValidDay(start) || !isValidDay(end)) throw new ContextError('invalid_window', { start, end });
|
|
184
|
+
if (parseDay(end) < parseDay(start)) throw new ContextError('window_end_before_start', { start, end });
|
|
185
|
+
return { start: String(start).slice(0, 10), end: String(end).slice(0, 10), days: daysBetween(start, end) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The last `count` contiguous windows of `days` each ending the day before `end` inclusive.
|
|
190
|
+
* `end` defaults to yesterday: Google's property-days are incomplete at the boundary.
|
|
191
|
+
*/
|
|
192
|
+
export function recentWindows({ days, count = 1, end = null, now = () => new Date() } = {}) {
|
|
193
|
+
if (!Number.isInteger(days) || days < 1 || days > 366) throw new ContextError('invalid_window_length', { days });
|
|
194
|
+
if (end != null && !isValidDay(end)) throw new ContextError('invalid_window', { end });
|
|
195
|
+
const last = end ?? addDays(dayStamp(now()), -1);
|
|
196
|
+
const windows = [];
|
|
197
|
+
for (let index = 0; index < count; index++) {
|
|
198
|
+
const windowEnd = addDays(last, -index * days);
|
|
199
|
+
windows.push(windowFromRange(addDays(windowEnd, -(days - 1)), windowEnd));
|
|
200
|
+
}
|
|
201
|
+
return windows.reverse();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const overlaps = (a, b) => parseDay(a.start) <= parseDay(b.end) && parseDay(b.start) <= parseDay(a.end);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Two windows may be compared only when they are the same length and do not overlap (contiguous
|
|
208
|
+
* or aligned: last-28 vs prior-28, last-7 vs prior-7, the same dates a year apart). Anything
|
|
209
|
+
* else is refused rather than printed with a percent sign — weekly seasonality makes an
|
|
210
|
+
* incomparable delta decoration.
|
|
211
|
+
*/
|
|
212
|
+
export function assertComparable(a, b, { purpose = 'comparison' } = {}) {
|
|
213
|
+
for (const window of [a, b]) {
|
|
214
|
+
if (!window || !isValidDay(window.start) || !isValidDay(window.end)) throw new ContextError('invalid_window', { purpose });
|
|
215
|
+
}
|
|
216
|
+
if (windowFromRange(a.start, a.end).days !== windowFromRange(b.start, b.end).days) {
|
|
217
|
+
throw new ContextError('windows_not_comparable', { purpose, reason: 'unequal_length', a: { days: a.days }, b: { days: b.days } });
|
|
218
|
+
}
|
|
219
|
+
if (a.start === b.start && a.end === b.end) throw new ContextError('windows_not_comparable', { purpose, reason: 'same_window' });
|
|
220
|
+
if (overlaps(a, b)) throw new ContextError('windows_not_comparable', { purpose, reason: 'overlapping', a, b });
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Query validation. aggregationType and type are always explicit; `auto` is not allowed.
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
export function validateSearchQuery({ type = 'web', aggregationType, dimensions = [], filters = [], rowLimit, dataState = 'final' } = {}) {
|
|
229
|
+
if (!SEARCH_TYPES.includes(type)) throw new ContextError('unsupported_type', { type });
|
|
230
|
+
if (!aggregationType || aggregationType === 'auto') throw new ContextError('aggregation_type_required', { aggregationType });
|
|
231
|
+
if (!AGGREGATION_TYPES.includes(aggregationType)) throw new ContextError('unsupported_aggregation_type', { aggregationType });
|
|
232
|
+
if (!DATA_STATES.includes(dataState)) throw new ContextError('unsupported_data_state', { dataState });
|
|
233
|
+
// Google's own constraints, kept next to the rule they constrain.
|
|
234
|
+
if ((type === 'discover' || type === 'googleNews') && aggregationType === 'byProperty') {
|
|
235
|
+
throw new ContextError('byProperty_not_supported_for_type', { type });
|
|
236
|
+
}
|
|
237
|
+
const usesPage = dimensions.includes('page') || filters.some(filter => String(filter?.dimension ?? filter) === 'page');
|
|
238
|
+
if (usesPage && aggregationType === 'byProperty') {
|
|
239
|
+
throw new ContextError('page_grouping_cannot_aggregate_by_property');
|
|
240
|
+
}
|
|
241
|
+
const limit = rowLimit === undefined ? CONTEXT_LIMITS.defaultRowLimit : Math.round(Number(rowLimit));
|
|
242
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > CONTEXT_LIMITS.rowLimitCap) {
|
|
243
|
+
throw new ContextError('row_limit_out_of_range', { rowLimit, max: CONTEXT_LIMITS.rowLimitCap });
|
|
244
|
+
}
|
|
245
|
+
return { type, aggregationType, dimensions: [...dimensions], rowLimit: limit, dataState };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Property coverage — checked, never assumed.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `sc-domain:example.com` covers the domain and every subdomain; a URL-prefix property covers
|
|
254
|
+
* exactly that prefix. Anything else is outside.
|
|
255
|
+
*/
|
|
256
|
+
export function pageWithinProperty(pageUrl, siteUrl) {
|
|
257
|
+
let page, site;
|
|
258
|
+
try { page = new URL(pageUrl); } catch { return false; }
|
|
259
|
+
if (typeof siteUrl !== 'string' || !siteUrl) return false;
|
|
260
|
+
if (siteUrl.toLowerCase().startsWith('sc-domain:')) {
|
|
261
|
+
const domain = siteUrl.slice('sc-domain:'.length).toLowerCase().replace(/^www\./u, '');
|
|
262
|
+
if (!domain) return false;
|
|
263
|
+
const host = page.hostname.toLowerCase();
|
|
264
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
265
|
+
}
|
|
266
|
+
try { site = new URL(siteUrl); } catch { return false; }
|
|
267
|
+
if (page.hostname !== site.hostname) return false;
|
|
268
|
+
return (page.pathname + page.search).startsWith(site.pathname);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The state for a target page against the connected properties: `covered`, or
|
|
273
|
+
* `page_outside_property` — never a row of zeros invented for an uncovered page.
|
|
274
|
+
*/
|
|
275
|
+
export function classifyPageCoverage(pageUrl, properties) {
|
|
276
|
+
const list = Array.isArray(properties) ? properties : [];
|
|
277
|
+
return list.some(property => pageWithinProperty(pageUrl, typeof property === 'string' ? property : property.siteUrl))
|
|
278
|
+
? 'covered' : 'page_outside_property';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Fact rows. Appended, never rewritten; a changed answer is a new row; newest wins at read.
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
export function factKey(row) {
|
|
286
|
+
const window = row.window ?? {};
|
|
287
|
+
return [row.kind, row.property ?? '', row.page ?? '', row.query ?? '', row.type ?? 'web',
|
|
288
|
+
row.aggregationType ?? '', (row.dimensions ?? []).join('+'), row.dataState ?? 'final',
|
|
289
|
+
window.start ?? '', window.end ?? ''].join('|');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Newest row per key. `fetched_at`/`captured_at` order, file order breaking ties (last wins). */
|
|
293
|
+
export function newestWinningRows(rows) {
|
|
294
|
+
const byKey = new Map();
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
const key = factKey(row);
|
|
297
|
+
const stamp = row.fetched_at ?? row.captured_at ?? '';
|
|
298
|
+
const held = byKey.get(key);
|
|
299
|
+
if (!held || stamp >= (held.fetched_at ?? held.captured_at ?? '')) byKey.set(key, row);
|
|
300
|
+
}
|
|
301
|
+
return [...byKey.values()];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function readGscRows(path) { const { rows, problems, missing } = await readJsonl(path); return { rows, problems, missing }; }
|
|
305
|
+
export const appendGscRows = (path, rows) => appendJsonl(path, rows);
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One API answer becomes fact rows. `keys` in the API response map to `page`/`query`/`date`
|
|
309
|
+
* by the dimensions we asked for; metrics are stored raw, rates are computed at read time.
|
|
310
|
+
* A missing metric stays null — zero is an observation, null is the absence of one.
|
|
311
|
+
*/
|
|
312
|
+
export function rowsFromResponse({ property, request, response, window, fetchedAt, retrievedBy = RETRIEVED_BY }) {
|
|
313
|
+
const apiRows = Array.isArray(response?.rows) ? response.rows : [];
|
|
314
|
+
const dimensions = request.dimensions;
|
|
315
|
+
const daysReported = dimensions.includes('date')
|
|
316
|
+
? new Set(apiRows.map(row => row.keys?.[dimensions.indexOf('date')]).filter(Boolean)).size
|
|
317
|
+
: null; // not derivable from this grouping; the aggregate says so instead of backfilling
|
|
318
|
+
const common = {
|
|
319
|
+
property, window, type: request.type, aggregationType: request.aggregationType,
|
|
320
|
+
dimensions, dataState: request.dataState, retrieved_by: retrievedBy, fetched_at: fetchedAt,
|
|
321
|
+
rows_returned: apiRows.length,
|
|
322
|
+
truncated: apiRows.length >= request.rowLimit,
|
|
323
|
+
days_requested: window.days,
|
|
324
|
+
days_reported: daysReported,
|
|
325
|
+
days_reported_basis: dimensions.includes('date') ? 'distinct_dates_in_rows' : 'not_derivable_from_grouping',
|
|
326
|
+
};
|
|
327
|
+
const firstIncomplete = response?.responseMetadata?.firstIncompleteDate ?? null;
|
|
328
|
+
const marker = {
|
|
329
|
+
...common, kind: 'gsc.window', page: null, query: null,
|
|
330
|
+
incomplete: Boolean(firstIncomplete) || request.dataState === 'all',
|
|
331
|
+
first_incomplete_date: firstIncomplete,
|
|
332
|
+
state: apiRows.length ? 'rows' : 'empty_site',
|
|
333
|
+
};
|
|
334
|
+
const factRows = apiRows.map(row => ({
|
|
335
|
+
...common, kind: dimensions.includes('page') && dimensions.includes('query') ? 'gsc.page_query'
|
|
336
|
+
: dimensions.includes('page') ? 'gsc.page' : dimensions.includes('query') ? 'gsc.query'
|
|
337
|
+
: dimensions.includes('date') ? 'gsc.date' : 'gsc.property',
|
|
338
|
+
page: dimensions.includes('page') ? row.keys?.[dimensions.indexOf('page')] ?? null : null,
|
|
339
|
+
query: dimensions.includes('query') ? row.keys?.[dimensions.indexOf('query')] ?? null : null,
|
|
340
|
+
date: dimensions.includes('date') ? row.keys?.[dimensions.indexOf('date')] ?? null : null,
|
|
341
|
+
clicks: numberOrNullOr(row.clicks), impressions: numberOrNullOr(row.impressions),
|
|
342
|
+
position: numberOrNullOr(row.position),
|
|
343
|
+
first_incomplete_date: firstIncomplete,
|
|
344
|
+
}));
|
|
345
|
+
return { marker, factRows };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// The Google client interface + the (unused-by-default) HTTP transport.
|
|
350
|
+
//
|
|
351
|
+
// Everything above this line is tested against injected clients. Everything below makes real
|
|
352
|
+
// HTTP calls and exists for a customer's own connection; no credentials are authorized for this
|
|
353
|
+
// repository, so no test touches it. Tokens come from the customer's environment, are used in
|
|
354
|
+
// one header, and are never logged, returned or written to any file.
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
export class GscApiError extends Error {
|
|
358
|
+
constructor(status, body) {
|
|
359
|
+
super(`gsc_http_${status}`);
|
|
360
|
+
this.name = 'GscApiError';
|
|
361
|
+
this.status = status;
|
|
362
|
+
this.body = body ?? null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function apiPath(siteUrl, verb) {
|
|
367
|
+
return `/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/${verb}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A client over the real Search Console API. Read-only scope only; there is no write verb here. */
|
|
371
|
+
export function createGoogleClient({ fetchImpl = globalThis.fetch, origin = 'https://www.googleapis.com', accessToken = null, getAccessToken = null, now = () => new Date() } = {}) {
|
|
372
|
+
const token = () => accessToken ?? (typeof getAccessToken === 'function' ? getAccessToken() : null);
|
|
373
|
+
async function call(path, body = null) {
|
|
374
|
+
const bearer = token();
|
|
375
|
+
if (!bearer) throw new ContextError('no_access_token');
|
|
376
|
+
let response;
|
|
377
|
+
try {
|
|
378
|
+
response = await fetchImpl(`${origin}${path}`, {
|
|
379
|
+
method: body ? 'POST' : 'GET',
|
|
380
|
+
headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
|
|
381
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
382
|
+
});
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error instanceof ContextError || error instanceof GscApiError) throw error;
|
|
385
|
+
throw new GscApiError(0, { error: String(error?.message ?? error) });
|
|
386
|
+
}
|
|
387
|
+
if (!response.ok) throw new GscApiError(response.status, await response.json().catch(() => null));
|
|
388
|
+
return response.json();
|
|
389
|
+
}
|
|
390
|
+
// Frozen on purpose: the shipped GSC surface is exactly sites.list + searchanalytics.query —
|
|
391
|
+
// the two read verbs the published API offers that this card uses. No Links verb exists and
|
|
392
|
+
// none may be added quietly; test/context-ga4.test.js asserts the key set.
|
|
393
|
+
return Object.freeze({
|
|
394
|
+
scope: GSC_SCOPE,
|
|
395
|
+
/** sites.list: the properties the customer's account can see, with permission levels. */
|
|
396
|
+
async listSites() {
|
|
397
|
+
const body = await call('/webmasters/v3/sites');
|
|
398
|
+
return (body?.siteEntry ?? []).map(site => ({ siteUrl: site.siteUrl, permissionLevel: site.permissionLevel }));
|
|
399
|
+
},
|
|
400
|
+
/** searchanalytics.query: read-only performance rows for one window and grouping. */
|
|
401
|
+
async searchAnalytics(siteUrl, requestBody) {
|
|
402
|
+
return call(apiPath(siteUrl, 'searchAnalytics/query'), requestBody);
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// context-state.json. Connection state, last fetch per window, availability probes.
|
|
409
|
+
// Booleans, property names, permission levels and dates only — never tokens.
|
|
410
|
+
// ---------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
export async function readContextState(path) { return (await readJson(path)) ?? {}; }
|
|
413
|
+
export const writeContextState = writeJson;
|
|
414
|
+
|
|
415
|
+
export const fetchKey = ({ property, request }) => [
|
|
416
|
+
property, request.type, request.aggregationType, request.dimensions.join('+'), request.dataState,
|
|
417
|
+
request.window.start, request.window.end,
|
|
418
|
+
].join('|');
|
|
419
|
+
|
|
420
|
+
const freshEnough = (stamp, nowMs, lifetimeMs) => typeof stamp === 'string' && Number.isFinite(Date.parse(stamp)) && nowMs - Date.parse(stamp) < lifetimeMs;
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Availability probe. Google's retention figure is unpinned, so the floor is OBSERVED per
|
|
424
|
+
// property: bounded backwards monthly probes, cached, reported as a fact about the property.
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
function monthWindow(offset, now) {
|
|
428
|
+
const reference = new Date(now());
|
|
429
|
+
const startOfCurrentMonth = new Date(Date.UTC(reference.getUTCFullYear(), reference.getUTCMonth(), 1));
|
|
430
|
+
const start = new Date(startOfCurrentMonth);
|
|
431
|
+
start.setUTCMonth(start.getUTCMonth() - offset);
|
|
432
|
+
const end = new Date(start);
|
|
433
|
+
end.setUTCMonth(end.getUTCMonth() + 1);
|
|
434
|
+
end.setUTCDate(0);
|
|
435
|
+
return windowFromRange(start.toISOString().slice(0, 10), end.toISOString().slice(0, 10));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Probes backwards one month at a time until a month returns no rows; that month's first day is
|
|
440
|
+
* the observed availability floor. Bounded at 24 probes; a floor found by exhaustion is reported
|
|
441
|
+
* as `probe_exhausted`, which is a fact about the probe, not a retention claim.
|
|
442
|
+
*/
|
|
443
|
+
export async function probeFirstAvailableDate({ client, property, state, now = () => new Date(), onCall = null, maxProbes = CONTEXT_LIMITS.availabilityProbes } = {}) {
|
|
444
|
+
const cached = state.availability?.[property];
|
|
445
|
+
if (cached?.first_available_date !== undefined && freshEnough(cached.probed_at, Date.parse(now()), CONTEXT_LIMITS.availabilityCacheDays * 86_400_000)) {
|
|
446
|
+
return { ...cached, cached: true, calls: 0 };
|
|
447
|
+
}
|
|
448
|
+
let probes = 0;
|
|
449
|
+
for (let offset = 0; offset <= CONTEXT_LIMITS.availabilityProbes; offset++) {
|
|
450
|
+
if (probes >= Math.min(CONTEXT_LIMITS.availabilityProbes, maxProbes)) break;
|
|
451
|
+
const window = monthWindow(offset, now);
|
|
452
|
+
probes++;
|
|
453
|
+
onCall?.();
|
|
454
|
+
const request = validateSearchQuery({ type: 'web', aggregationType: 'byProperty', dimensions: [], rowLimit: 1 });
|
|
455
|
+
const response = await client.searchAnalytics(property, {
|
|
456
|
+
startDate: window.start, endDate: window.end, dimensions: [], type: request.type,
|
|
457
|
+
aggregationType: request.aggregationType, rowLimit: request.rowLimit, dataState: 'final',
|
|
458
|
+
});
|
|
459
|
+
if (!Array.isArray(response?.rows) || response.rows.length === 0) {
|
|
460
|
+
// No rows observed in this month: the floor is its first day. Reported as observed, never
|
|
461
|
+
// as Google's retention policy — a quiet month and a gone month are indistinguishable here,
|
|
462
|
+
// which is exactly why the row carries the probe date and the probe count.
|
|
463
|
+
return { property, first_available_date: window.start, probed_at: isoOf(now()), probes_used: probes, basis: 'observed_first_month_without_rows', calls: probes };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return { property, first_available_date: null, probed_at: isoOf(now()), probes_used: probes, basis: probes < CONTEXT_LIMITS.availabilityProbes ? 'probe_budget_reached' : 'probe_exhausted_data_in_every_probed_month', calls: probes };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// The bounded refresh.
|
|
471
|
+
// ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
const STANDARD_GROUPINGS = Object.freeze([
|
|
474
|
+
{ dimensions: ['page'], aggregationType: 'byPage' },
|
|
475
|
+
{ dimensions: ['page', 'query'], aggregationType: 'byPage' },
|
|
476
|
+
]);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Runs the read-only refresh. Cheap by construction: the standard set is three comparable
|
|
480
|
+
* windows x (page, page+query) per property, under the 12-call default budget and the 50-call
|
|
481
|
+
* hard cap; identical re-queries inside the freshness lifetime are served from the repo; a
|
|
482
|
+
* quota refusal becomes `quota_exceeded` with the 15-minute hint, one retry, then stop.
|
|
483
|
+
*
|
|
484
|
+
* Every state in the contract's table is produced here and nothing is inferred from emptiness:
|
|
485
|
+
* authorization loss mid-run keeps the rows already written and labels the run `revoked`;
|
|
486
|
+
* restricted or unverified properties are surfaced and skipped; an authorized property with no
|
|
487
|
+
* rows gets a window marker, not an error and not invented zeros.
|
|
488
|
+
*/
|
|
489
|
+
export async function refreshContext({
|
|
490
|
+
client = null, paths, properties: wantedProperties = null,
|
|
491
|
+
windows: wantedWindows = null, dataState = 'final', now = () => new Date(), sleep = () => Promise.resolve(),
|
|
492
|
+
freshnessMs = CONTEXT_LIMITS.freshnessMs, limits = {},
|
|
493
|
+
}) {
|
|
494
|
+
const cap = { ...CONTEXT_LIMITS, ...limits };
|
|
495
|
+
for (const key of ['refreshCallsPerProperty', 'hardCapCallsPerRun']) {
|
|
496
|
+
if (!Number.isInteger(cap[key]) || cap[key] < 0) throw new ContextError('invalid_call_budget', { key });
|
|
497
|
+
cap[key] = Math.min(cap[key], CONTEXT_LIMITS[key]);
|
|
498
|
+
}
|
|
499
|
+
for (const window of wantedWindows ?? []) windowFromRange(window.start, window.end);
|
|
500
|
+
const state = await readContextState(paths.contextState);
|
|
501
|
+
const existing = await readGscRows(paths.gsc);
|
|
502
|
+
const nowMs = Date.parse(now());
|
|
503
|
+
const fetchedAt = isoOf(now());
|
|
504
|
+
const output = {
|
|
505
|
+
started_at: fetchedAt, state: 'ok', properties: [], rows_written: 0, markers_written: 0,
|
|
506
|
+
calls: { search_analytics: 0, list_sites: 0, budget_per_property: cap.refreshCallsPerProperty, hard_cap_per_run: cap.hardCapCallsPerRun },
|
|
507
|
+
served_from_repo: 0, states: [], quota: null, notes: [],
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
if (!client) {
|
|
511
|
+
// No connection configured. This is a valid complete configuration, not a failure: manual
|
|
512
|
+
// inputs are the context and every later stage runs on them.
|
|
513
|
+
state.connection = { ...(state.connection ?? {}), grant: 'none', checked_at: fetchedAt };
|
|
514
|
+
await writeContextState(paths.contextState, state);
|
|
515
|
+
output.state = 'manual_only';
|
|
516
|
+
output.notes.push('no Google connection configured; manual.md is the context and handoff rows (if any) are unaffected');
|
|
517
|
+
return output;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let sites;
|
|
521
|
+
try {
|
|
522
|
+
output.calls.list_sites++;
|
|
523
|
+
sites = await client.listSites();
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (error instanceof GscApiError && isAuthorizationLoss(error.status)) {
|
|
526
|
+
state.connection = { ...(state.connection ?? {}), grant: 'refused', last_status: error.status, checked_at: fetchedAt };
|
|
527
|
+
await writeContextState(paths.contextState, state);
|
|
528
|
+
output.state = 'not_authorized';
|
|
529
|
+
return output;
|
|
530
|
+
}
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const requested = [...new Set((wantedProperties ?? state.connection?.properties ?? []).map(String))];
|
|
535
|
+
const known = new Map(sites.map(site => [site.siteUrl, site]));
|
|
536
|
+
for (const siteUrl of requested) {
|
|
537
|
+
const site = known.get(siteUrl);
|
|
538
|
+
if (!site) { output.properties.push({ siteUrl, state: 'not_authorized' }); output.states.push('not_authorized'); continue; }
|
|
539
|
+
if (site.permissionLevel === 'siteUnverifiedUser' || site.permissionLevel === 'siteRestrictedUser') {
|
|
540
|
+
// Surfaced to the customer, never silently used.
|
|
541
|
+
const label = site.permissionLevel === 'siteUnverifiedUser' ? 'property_not_verified' : 'permission_restricted';
|
|
542
|
+
output.properties.push({ siteUrl, permissionLevel: site.permissionLevel, state: label, rows_written: 0 });
|
|
543
|
+
output.states.push(label);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
await refreshProperty({ siteUrl, site, state, existing, output, paths, client, wantedWindows, dataState, now, nowMs, fetchedAt, sleep, cap, freshnessMs });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!output.properties.length) {
|
|
550
|
+
output.notes.push('no properties requested; pass --property or record properties in context-state.json');
|
|
551
|
+
}
|
|
552
|
+
// A mid-run revocation already wrote `grant: 'revoked'` inside refreshProperty; writing 'ok'
|
|
553
|
+
// over it would relabel a refusal as a success after the fact. Quota stoppage is not a grant
|
|
554
|
+
// state — authorization held, only the envelope ran out.
|
|
555
|
+
const grant = output.state === 'revoked' ? 'revoked' : 'ok';
|
|
556
|
+
state.connection = { ...(state.connection ?? {}), grant, checked_at: fetchedAt, properties: requested, permission_levels: Object.fromEntries(requested.filter(u => known.has(u)).map(u => [u, known.get(u).permissionLevel])) };
|
|
557
|
+
await writeContextState(paths.contextState, state);
|
|
558
|
+
return output;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function refreshProperty({ siteUrl, state, existing, output, paths, client, wantedWindows, dataState, now, nowMs, fetchedAt, sleep, cap, freshnessMs }) {
|
|
562
|
+
const record = { siteUrl, permissionLevel: null, state: 'ok', rows_written: 0, windows: [], calls: 0 };
|
|
563
|
+
output.properties.push(record);
|
|
564
|
+
|
|
565
|
+
const windows = wantedWindows ?? recentWindows({ days: 28, count: 2, now }).concat(recentWindows({ days: 7, count: 2, now }));
|
|
566
|
+
const remaining = () => Math.max(0, Math.min(cap.refreshCallsPerProperty - record.calls, cap.hardCapCallsPerRun - output.calls.search_analytics));
|
|
567
|
+
// Availability is optional context. Reserve report calls so long histories cannot spend
|
|
568
|
+
// the entire refresh on probing, repeatedly starving actual page/query reads.
|
|
569
|
+
const reportReserve = Math.min(remaining(), new Set(windows.map(w => `${w.start}|${w.end}`)).size * STANDARD_GROUPINGS.length);
|
|
570
|
+
const available = await probeFirstAvailableDate({
|
|
571
|
+
client, property: siteUrl, state, now, maxProbes: remaining() - reportReserve,
|
|
572
|
+
onCall: () => { record.calls++; output.calls.search_analytics++; },
|
|
573
|
+
});
|
|
574
|
+
state.availability = { ...(state.availability ?? {}), [siteUrl]: { first_available_date: available.first_available_date, probed_at: available.probed_at, probes_used: available.probes_used, basis: available.basis } };
|
|
575
|
+
record.availability = { ...available };
|
|
576
|
+
delete record.availability.calls;
|
|
577
|
+
|
|
578
|
+
if (available.basis === 'probe_budget_reached') output.notes.push(`budget reached while probing availability for ${siteUrl}; availability floor remains unknown`);
|
|
579
|
+
const seenWindows = new Set();
|
|
580
|
+
for (const window of windows) {
|
|
581
|
+
if (seenWindows.has(window.start + window.end)) continue;
|
|
582
|
+
seenWindows.add(window.start + window.end);
|
|
583
|
+
if (available.first_available_date && window.end < available.first_available_date) {
|
|
584
|
+
record.windows.push({ window, state: 'window_before_available_data' });
|
|
585
|
+
output.states.push('window_before_available_data');
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
for (const grouping of STANDARD_GROUPINGS) {
|
|
589
|
+
if (record.calls >= cap.refreshCallsPerProperty || output.calls.search_analytics >= cap.hardCapCallsPerRun) {
|
|
590
|
+
record.windows.push({ window, grouping: grouping.dimensions, state: 'budget_reached', budget: { per_property: cap.refreshCallsPerProperty, hard_cap: cap.hardCapCallsPerRun } });
|
|
591
|
+
output.notes.push(`budget reached before ${window.start}..${window.end} ${grouping.dimensions.join('+')}`);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const request = { ...validateSearchQuery({ type: 'web', aggregationType: grouping.aggregationType, dimensions: grouping.dimensions, dataState }), window };
|
|
595
|
+
const key = fetchKey({ property: siteUrl, request });
|
|
596
|
+
const lastFetch = state.last_fetch?.[key];
|
|
597
|
+
if (freshEnough(lastFetch, nowMs, freshnessMs) && dataState === request.dataState) {
|
|
598
|
+
// Identical re-query, served from the repo. Idempotence by window key.
|
|
599
|
+
output.served_from_repo++;
|
|
600
|
+
record.windows.push({ window, grouping: grouping.dimensions, state: 'served_from_repo' });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
let response, attempt = 0;
|
|
604
|
+
for (;;) {
|
|
605
|
+
if (remaining() === 0) {
|
|
606
|
+
record.windows.push({ window, grouping: grouping.dimensions, state: 'budget_reached' });
|
|
607
|
+
record.state = 'budget_reached';
|
|
608
|
+
output.notes.push(`budget reached before retry for ${siteUrl}`);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
record.calls++; output.calls.search_analytics++;
|
|
613
|
+
response = await client.searchAnalytics(siteUrl, {
|
|
614
|
+
startDate: window.start, endDate: window.end, dimensions: request.dimensions,
|
|
615
|
+
type: request.type, aggregationType: request.aggregationType,
|
|
616
|
+
rowLimit: request.rowLimit, dataState: request.dataState,
|
|
617
|
+
});
|
|
618
|
+
break;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
if (error instanceof GscApiError && error.status === 429) {
|
|
621
|
+
output.quota = { hint_minutes: cap.quotaRetryHintMinutes, retries_used: attempt };
|
|
622
|
+
if (attempt >= cap.quotaRetries || remaining() === 0) {
|
|
623
|
+
// One retry after the hint, then stop. A context refresh is never so urgent it
|
|
624
|
+
// spends the customer's per-site 1,200 queries per minute.
|
|
625
|
+
output.state = output.state === 'ok' ? 'quota_exceeded' : output.state;
|
|
626
|
+
output.states.push('quota_exceeded');
|
|
627
|
+
record.state = record.state === 'ok' ? 'quota_exceeded' : record.state;
|
|
628
|
+
record.windows.push({ window, grouping: grouping.dimensions, state: 'quota_exceeded', hint_minutes: cap.quotaRetryHintMinutes });
|
|
629
|
+
await writeContextState(paths.contextState, state);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
attempt++;
|
|
633
|
+
await sleep(cap.quotaRetryHintMinutes * 60_000);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (error instanceof GscApiError && isAuthorizationLoss(error.status)) {
|
|
637
|
+
// Revocation mid-run: the rows already appended are the customer's own data and are
|
|
638
|
+
// retained; the run is labeled, not rolled back.
|
|
639
|
+
state.connection = { ...(state.connection ?? {}), grant: 'revoked', last_status: error.status, checked_at: fetchedAt };
|
|
640
|
+
await writeContextState(paths.contextState, state);
|
|
641
|
+
output.state = 'revoked';
|
|
642
|
+
output.states.push('revoked');
|
|
643
|
+
record.state = 'revoked';
|
|
644
|
+
record.windows.push({ window, grouping: grouping.dimensions, state: 'revoked', partial: true });
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const { marker, factRows } = rowsFromResponse({ property: siteUrl, request, response, window, fetchedAt });
|
|
651
|
+
await appendGscRows(paths.gsc, [...factRows, marker]);
|
|
652
|
+
record.rows_written += factRows.length; record.markers_written = (record.markers_written ?? 0) + 1;
|
|
653
|
+
output.rows_written += factRows.length; output.markers_written++;
|
|
654
|
+
state.last_fetch = { ...(state.last_fetch ?? {}), [key]: fetchedAt };
|
|
655
|
+
const windowState = marker.state === 'empty_site' ? 'empty_site'
|
|
656
|
+
: marker.truncated ? 'truncated_top_rows'
|
|
657
|
+
: marker.incomplete ? 'incomplete_window' : 'ok';
|
|
658
|
+
record.windows.push({ window, grouping: grouping.dimensions, state: windowState, rows_returned: marker.rows_returned, truncated: marker.truncated, first_incomplete_date: marker.first_incomplete_date });
|
|
659
|
+
output.states.push(windowState);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
record.state = record.state === 'ok' && record.windows.every(w => ['ok', 'served_from_repo', 'empty_site'].includes(w.state)) ? 'ok' : record.state;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
// Read-time outputs. Counts raw, rates derived, position always labeled an average.
|
|
667
|
+
// ---------------------------------------------------------------------------
|
|
668
|
+
|
|
669
|
+
// A rate is derived at read time, never stored. Absent impressions make the rate null: zero is
|
|
670
|
+
// an observation, null is the absence of one.
|
|
671
|
+
const ctrOf = (clicks, impressions) => {
|
|
672
|
+
if (impressions === null || impressions === undefined || clicks === null || clicks === undefined) return null;
|
|
673
|
+
if (impressions === 0) return 0;
|
|
674
|
+
return Math.round((clicks / impressions) * 10_000) / 10_000;
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Page/query context for one page and window, from the repo's own rows. Resolves to exactly one
|
|
679
|
+
* state. `final_through` names the last final day when preliminary data is in view; final and
|
|
680
|
+
* preliminary are never blended into one number.
|
|
681
|
+
*/
|
|
682
|
+
export function pageContext({ rows, page, window: wanted = null, properties = null, connection = null, manual = null } = {}) {
|
|
683
|
+
const latest = newestWinningRows(rows ?? []);
|
|
684
|
+
if (properties !== null && properties.length && classifyPageCoverage(page, properties) === 'page_outside_property') {
|
|
685
|
+
return { state: 'page_outside_property', page, properties, rows_returned: 0, queries: [], page_totals: null, notes: ['the page is not under any connected property; no rows are invented for it'] };
|
|
686
|
+
}
|
|
687
|
+
const windowRows = latest.filter(row => row.kind?.startsWith('gsc.') && row.page === page
|
|
688
|
+
&& (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
|
|
689
|
+
if (!windowRows.length) {
|
|
690
|
+
// No row for this page and window. Why is named, not inferred: an empty_site marker, a
|
|
691
|
+
// pre-floor window, or no connection at all.
|
|
692
|
+
const marker = latest.find(row => row.kind === 'gsc.window' && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
|
|
693
|
+
if (marker?.state === 'empty_site') return { state: 'empty_site', page, window: wanted, rows_returned: 0, queries: [], page_totals: null, notes: ['authorized, property covered, zero rows in this window — a valid state, not an error'] };
|
|
694
|
+
if (wanted && marker && marker.first_incomplete_date && marker.first_incomplete_date <= wanted.end) {
|
|
695
|
+
return { state: 'incomplete_window', page, window: wanted, rows_returned: 0, queries: [], page_totals: null, first_incomplete_date: marker.first_incomplete_date };
|
|
696
|
+
}
|
|
697
|
+
if (connection?.grant === 'revoked' || connection?.grant === 'refused') {
|
|
698
|
+
return { state: connection.grant === 'revoked' ? 'revoked' : 'not_authorized', page, rows_returned: 0, queries: [], page_totals: null, notes: ['existing rows retained; no new rows are written in this state'] };
|
|
699
|
+
}
|
|
700
|
+
return { state: connection?.grant === 'none' || !connection ? 'manual_only' : 'ok', page, rows_returned: 0, queries: [], page_totals: null,
|
|
701
|
+
notes: connection?.grant === 'none' || !connection
|
|
702
|
+
? ['no Google connection configured; manual inputs are the context' + (manual?.present ? '' : ' and manual.md is absent')]
|
|
703
|
+
: ['the page is not in the returned top rows for this window — which is not the claim "no impressions"'] };
|
|
704
|
+
}
|
|
705
|
+
const pageRow = windowRows.find(row => row.kind === 'gsc.page') ?? null;
|
|
706
|
+
const queryRows = windowRows.filter(row => row.kind === 'gsc.page_query').sort((a, b) => (b.clicks ?? 0) - (a.clicks ?? 0));
|
|
707
|
+
const truncated = Boolean(pageRow?.truncated) || queryRows.some(row => row.truncated);
|
|
708
|
+
const incomplete = windowRows.some(row => row.first_incomplete_date || row.dataState === 'all');
|
|
709
|
+
const firstIncomplete = windowRows.map(row => row.first_incomplete_date).filter(Boolean).sort()[0] ?? null;
|
|
710
|
+
const state = truncated ? 'truncated_top_rows' : incomplete ? 'incomplete_window' : 'ok';
|
|
711
|
+
return {
|
|
712
|
+
state, page,
|
|
713
|
+
window: pageRow?.window ?? queryRows[0]?.window ?? wanted ?? null,
|
|
714
|
+
type: pageRow?.type ?? 'web', aggregationType: pageRow?.aggregationType ?? 'byPage',
|
|
715
|
+
dataState: pageRow?.dataState ?? 'final',
|
|
716
|
+
final_through: firstIncomplete ? addDays(firstIncomplete, -1) : (pageRow?.window?.end ?? null),
|
|
717
|
+
rows_returned: 1 + queryRows.length, truncated,
|
|
718
|
+
first_incomplete_date: firstIncomplete,
|
|
719
|
+
days_requested: pageRow?.days_requested ?? null,
|
|
720
|
+
days_reported: pageRow?.days_reported ?? null,
|
|
721
|
+
days_reported_basis: pageRow?.days_reported_basis ?? null,
|
|
722
|
+
page_totals: pageRow ? {
|
|
723
|
+
clicks: pageRow.clicks, impressions: pageRow.impressions,
|
|
724
|
+
ctr: ctrOf(pageRow.clicks, pageRow.impressions),
|
|
725
|
+
average_position: pageRow.position, // the API defines position as an AVERAGE position
|
|
726
|
+
} : null,
|
|
727
|
+
queries: queryRows.map(row => ({
|
|
728
|
+
query: row.query, clicks: row.clicks, impressions: row.impressions,
|
|
729
|
+
ctr: ctrOf(row.clicks, row.impressions), average_position: row.position,
|
|
730
|
+
})),
|
|
731
|
+
notes: truncated ? ['top rows only: Google does not guarantee all data rows; a page absent here is "not in the returned top rows", not "no impressions"'] : [],
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Site totals for one window, from `byProperty` rows. Returned rows are never summed into a
|
|
737
|
+
* "site total" when truncation is set — that number would be a floor wearing a total's clothes.
|
|
738
|
+
*/
|
|
739
|
+
export function siteTotalsContext({ rows, window: wanted, property = null, connection = null } = {}) {
|
|
740
|
+
const latest = newestWinningRows(rows ?? []).filter(row => row.kind === 'gsc.property'
|
|
741
|
+
&& (!property || row.property === property)
|
|
742
|
+
&& (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
|
|
743
|
+
if (!latest.length) {
|
|
744
|
+
const marker = newestWinningRows(rows ?? []).find(row => row.kind === 'gsc.window' && (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)));
|
|
745
|
+
if (marker?.state === 'empty_site') return { state: 'empty_site', window: wanted, totals: null };
|
|
746
|
+
if (connection?.grant === 'none' || !connection) return { state: 'manual_only', window: wanted, totals: null };
|
|
747
|
+
return { state: 'ok', window: wanted, totals: null, notes: ['no byProperty rows for this window'] };
|
|
748
|
+
}
|
|
749
|
+
const row = latest[0];
|
|
750
|
+
if (row.truncated) return { state: 'truncated_top_rows', window: row.window, totals: null, rows_returned: row.rows_returned, notes: ['returned rows hit the cap; a sum of them would be presented as a site total it is not'] };
|
|
751
|
+
return {
|
|
752
|
+
state: row.first_incomplete_date || row.dataState === 'all' ? 'incomplete_window' : 'ok',
|
|
753
|
+
window: row.window, property: row.property, type: row.type, aggregationType: row.aggregationType, dataState: row.dataState,
|
|
754
|
+
totals: { clicks: row.clicks, impressions: row.impressions, ctr: ctrOf(row.clicks, row.impressions), average_position: row.position },
|
|
755
|
+
days_requested: row.days_requested, days_reported: row.days_reported, days_reported_basis: row.days_reported_basis,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** A delta between two comparable windows. Refuses unequal or overlapping windows, and refuses to mix aggregationTypes. */
|
|
760
|
+
export function windowComparison({ rows, windowA, windowB, property = null, page = null } = {}) {
|
|
761
|
+
assertComparable(windowA, windowB);
|
|
762
|
+
const latest = newestWinningRows(rows ?? []);
|
|
763
|
+
const pick = window => {
|
|
764
|
+
const kind = page ? 'gsc.page' : 'gsc.property';
|
|
765
|
+
return latest.find(row => row.kind === kind && (!property || row.property === property)
|
|
766
|
+
&& (!page || row.page === page)
|
|
767
|
+
&& row.window?.start === window.start && row.window?.end === window.end) ?? null;
|
|
768
|
+
};
|
|
769
|
+
const a = pick(windowA), b = pick(windowB);
|
|
770
|
+
if (!a || !b) return { state: 'ok', windows: { a: windowA, b: windowB }, delta: null, missing: [!a && 'a', !b && 'b'].filter(Boolean), notes: ['a window with no rows cannot be delta-ed; absence is reported, never treated as zero'] };
|
|
771
|
+
if (a.aggregationType !== b.aggregationType) throw new ContextError('aggregation_types_mixed', { a: a.aggregationType, b: b.aggregationType });
|
|
772
|
+
const pct = (now, before) => before === null || before === undefined || before === 0 ? null : Math.round(((now - before) / before) * 10_000) / 10_000;
|
|
773
|
+
return {
|
|
774
|
+
state: 'ok', windows: { a: windowA, b: windowB }, property: a.property, page: a.page,
|
|
775
|
+
aggregationType: a.aggregationType,
|
|
776
|
+
delta: {
|
|
777
|
+
clicks: (a.clicks ?? 0) - (b.clicks ?? 0), clicks_change: pct(a.clicks, b.clicks),
|
|
778
|
+
impressions: (a.impressions ?? 0) - (b.impressions ?? 0), impressions_change: pct(a.impressions, b.impressions),
|
|
779
|
+
ctr: ctrOf(a.clicks, a.impressions) === null || ctrOf(b.clicks, b.impressions) === null ? null : Math.round((ctrOf(a.clicks, a.impressions) - ctrOf(b.clicks, b.impressions)) * 10_000) / 10_000,
|
|
780
|
+
average_position: a.position === null || b.position === null ? null : Math.round((a.position - b.position) * 100) / 100,
|
|
781
|
+
},
|
|
782
|
+
truncated: Boolean(a.truncated || b.truncated),
|
|
783
|
+
notes: a.truncated || b.truncated ? ['a contributing window hit the row cap; deltas over truncated top rows are floor-to-floor'] : [],
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// Focus-page selection. Manual works alone; GSC nominates, it never prescribes.
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Nominates pages worth a campaign's attention. Manual targets come first (judgment); observed
|
|
793
|
+
* pages follow by clicks then impressions (observation). The three contract layers are carried
|
|
794
|
+
* separately and inference/recommendation are null here on purpose: "high impressions, low
|
|
795
|
+
* clicks" is an observation, "title mismatch" is an editable inference, "pursue resource-page
|
|
796
|
+
* links" is a DP-0009 brief's job.
|
|
797
|
+
*/
|
|
798
|
+
export function selectFocusPages({ manual, rows = [], window: wanted = null, limit = 10 } = {}) {
|
|
799
|
+
const chosen = [];
|
|
800
|
+
const seen = new Set();
|
|
801
|
+
const latest = rows.length
|
|
802
|
+
? newestWinningRows(rows).filter(row => row.kind === 'gsc.page' && row.page
|
|
803
|
+
&& (!wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end)))
|
|
804
|
+
: [];
|
|
805
|
+
const byPage = new Map(latest.map(row => [row.page, row]));
|
|
806
|
+
const observationOf = row => row ? { window: row.window, clicks: row.clicks, impressions: row.impressions, ctr: ctrOf(row.clicks, row.impressions), average_position: row.position, truncated: Boolean(row.truncated) } : null;
|
|
807
|
+
// Judgment first: a manual target is nominated by judgment, and an observation for it is
|
|
808
|
+
// ATTACHED (a second layer) rather than replacing the nomination — the two never collapse.
|
|
809
|
+
for (const page of manual?.target_pages ?? []) {
|
|
810
|
+
if (seen.has(page) || chosen.length >= limit) continue;
|
|
811
|
+
seen.add(page);
|
|
812
|
+
chosen.push({ page, nominated_by: ['manual'], observation: observationOf(byPage.get(page)), inference: null, recommendation: null });
|
|
813
|
+
}
|
|
814
|
+
const ranked = [...latest].sort((a, b) => (b.clicks ?? 0) - (a.clicks ?? 0) || (b.impressions ?? 0) - (a.impressions ?? 0));
|
|
815
|
+
for (const row of ranked) {
|
|
816
|
+
if (seen.has(row.page) || chosen.length >= limit) continue;
|
|
817
|
+
seen.add(row.page);
|
|
818
|
+
chosen.push({ page: row.page, nominated_by: ['gsc_clicks'], observation: observationOf(row), inference: null, recommendation: null });
|
|
819
|
+
}
|
|
820
|
+
return { pages: chosen, basis: { manual: (manual?.target_pages ?? []).length, gsc: rows.length ? 'rows_present' : 'no_rows' } };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ---------------------------------------------------------------------------
|
|
824
|
+
// Existing-agent context import (the handoff). A connector's rows are never re-labeled.
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
|
|
827
|
+
function parseHandoffText(text) {
|
|
828
|
+
const trimmed = String(text ?? '').trim();
|
|
829
|
+
if (!trimmed) return { rows: [], error: 'empty file' };
|
|
830
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
|
831
|
+
try {
|
|
832
|
+
const parsed = JSON.parse(trimmed);
|
|
833
|
+
return { rows: Array.isArray(parsed) ? parsed : [parsed] };
|
|
834
|
+
} catch {
|
|
835
|
+
// Not one JSON document; try JSONL before giving up.
|
|
836
|
+
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean);
|
|
837
|
+
const rows = [];
|
|
838
|
+
for (const line of lines) {
|
|
839
|
+
try { rows.push(JSON.parse(line)); } catch { return { rows: [], error: 'not_json' }; }
|
|
840
|
+
}
|
|
841
|
+
return { rows };
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// A Markdown snapshot: a pipe table whose header names the row fields.
|
|
845
|
+
const lines = trimmed.split(/\r?\n/u).filter(line => line.trim());
|
|
846
|
+
const divider = /^\s*\|?[\s:|-]+\|?\s*$/u;
|
|
847
|
+
if (!lines.some(line => line.includes('|'))) return { rows: [], error: 'unrecognized_handoff_format' };
|
|
848
|
+
const headerIndex = lines.findIndex(line => line.includes('|') && !divider.test(line));
|
|
849
|
+
if (headerIndex < 0 || headerIndex + 1 >= lines.length || !divider.test(lines[headerIndex + 1])) return { rows: [], error: 'not_a_markdown_table' };
|
|
850
|
+
const cells = line => line.replace(/^\s*\|/u, '').replace(/\|\s*$/u, '').split('|').map(cell => cell.trim());
|
|
851
|
+
const header = cells(lines[headerIndex]);
|
|
852
|
+
const rows = [];
|
|
853
|
+
for (const line of lines.slice(headerIndex + 1).filter(line => line.includes('|'))) {
|
|
854
|
+
if (divider.test(line)) continue;
|
|
855
|
+
const values = cells(line);
|
|
856
|
+
rows.push(Object.fromEntries(header.map((name, index) => [name, values[index] ?? ''])));
|
|
857
|
+
}
|
|
858
|
+
return { rows };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Imports a connector snapshot into `gsc.jsonl`. Inherited from the DP-0014 import doctrine:
|
|
863
|
+
* the connector is named in `retrieved_by` and never re-labeled as fetched by us; `captured_at`
|
|
864
|
+
* stays the connector's date, separate from any later fetch of ours; rows missing `window` or
|
|
865
|
+
* `aggregationType` are refused per file with what is missing; imports never overwrite rows we
|
|
866
|
+
* fetched. A handoff with no Google grant from us at all is a complete configuration.
|
|
867
|
+
*/
|
|
868
|
+
export async function importHandoff({ paths, text = null, file = null, now = () => new Date() } = {}) {
|
|
869
|
+
const raw = text ?? (await readFile(file, 'utf8'));
|
|
870
|
+
const parsed = parseHandoffText(raw);
|
|
871
|
+
if (parsed.error) return { error: parsed.error, accepted: 0, refused: [{ reason: parsed.error }] };
|
|
872
|
+
const existing = (await readGscRows(paths.gsc)).rows;
|
|
873
|
+
const ours = new Set(newestWinningRows(existing).filter(row => row.retrieved_by === RETRIEVED_BY).map(factKey));
|
|
874
|
+
const accepted = [], refused = [];
|
|
875
|
+
parsed.rows.forEach((candidate, index) => {
|
|
876
|
+
// A Markdown table row is flat: `window.start`/`window.end` columns become the nested window.
|
|
877
|
+
if ((!candidate.window || typeof candidate.window !== 'object') && candidate['window.start'] && candidate['window.end']) {
|
|
878
|
+
candidate.window = { start: candidate['window.start'], end: candidate['window.end'] };
|
|
879
|
+
}
|
|
880
|
+
const missing = [];
|
|
881
|
+
if (!candidate.window || !isValidDay(candidate.window.start) || !isValidDay(candidate.window.end)) missing.push('window');
|
|
882
|
+
if (!candidate.aggregationType) missing.push('aggregationType');
|
|
883
|
+
if (!candidate.retrieved_by || candidate.retrieved_by === RETRIEVED_BY) missing.push('retrieved_by (the connector must be named, and never us)');
|
|
884
|
+
if (!candidate.property) missing.push('property');
|
|
885
|
+
if (missing.length) { refused.push({ row: index + 1, reason: `missing ${missing.join(' and ')}` }); return; }
|
|
886
|
+
const row = {
|
|
887
|
+
...candidate,
|
|
888
|
+
window: { start: candidate.window.start.slice(0, 10), end: candidate.window.end.slice(0, 10), days: candidate.window.days ?? daysBetween(candidate.window.start, candidate.window.end) },
|
|
889
|
+
captured_at: candidate.captured_at ?? null, // the connector's date, never fabricated
|
|
890
|
+
// No fetched_at: nothing here was fetched by us, and the label must not drift.
|
|
891
|
+
};
|
|
892
|
+
if (ours.has(factKey(row))) { refused.push({ row: index + 1, reason: 'a row we fetched for this key exists; ours is kept' }); return; }
|
|
893
|
+
accepted.push(row);
|
|
894
|
+
});
|
|
895
|
+
await appendGscRows(paths.gsc, accepted);
|
|
896
|
+
const state = await readContextState(paths.contextState);
|
|
897
|
+
const connectors = [...new Set([...(state.handoff?.connectors ?? []), ...accepted.map(row => row.retrieved_by)])];
|
|
898
|
+
state.handoff = { ...(state.handoff ?? {}), connectors, last_import_at: now(), rows_imported: (state.handoff?.rows_imported ?? 0) + accepted.length };
|
|
899
|
+
await writeContextState(paths.contextState, state);
|
|
900
|
+
return { accepted: accepted.length, refused, connectors };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
// Forget. Deletion is a decision; the human-owned files are never its collateral.
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Removes tool-owned context files and leaves the human-owned `manual.md` and `site-profile.md`
|
|
909
|
+
* untouched. `--source gsc` (the default) removes the search facts and the connection state the
|
|
910
|
+
// tool wrote; `--source site` removes the site facts; `--source all` removes all three.
|
|
911
|
+
*/
|
|
912
|
+
export async function forgetContext({ paths, source = 'gsc' } = {}) {
|
|
913
|
+
const toolOwned = { gsc: ['gsc', 'contextState'], site: ['siteFacts'], all: ['gsc', 'contextState', 'siteFacts'] }[source];
|
|
914
|
+
if (!toolOwned) throw new ContextError('unknown_forget_source', { source });
|
|
915
|
+
const removed = [], kept = [];
|
|
916
|
+
for (const name of ['gsc', 'contextState', 'siteFacts', 'manual', 'siteProfile']) {
|
|
917
|
+
const path = paths[name];
|
|
918
|
+
if (name === 'manual' || name === 'siteProfile') { kept.push({ name, path, owner: 'human/agent' }); continue; }
|
|
919
|
+
if (toolOwned.includes(name)) {
|
|
920
|
+
await rm(path, { force: true });
|
|
921
|
+
removed.push({ name, path });
|
|
922
|
+
} else kept.push({ name, path, owner: 'tool' });
|
|
923
|
+
}
|
|
924
|
+
return { removed, kept, note: 'disconnecting Google stops new rows and retains old ones; rows already fetched are the customer’s own data in their own repo' };
|
|
925
|
+
}
|