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,652 @@
|
|
|
1
|
+
// Optional GA4 business context (DP-0017-T04), implemented against the
|
|
2
|
+
// [first-party context contract](../../docs/initiatives/DP-0017-first-party-search-context/first-party-context-contract.md).
|
|
3
|
+
//
|
|
4
|
+
// The rule this module is built on: GA4 measures SITE behavior in aggregate. It never identifies a
|
|
5
|
+
// person, it never carries a search query, and it never says a placement, a query or this tool's
|
|
6
|
+
// work earned anything. So the module is structurally incapable of the invention the acceptance
|
|
7
|
+
// forbids: the report validator allowlists exactly three aggregate dimensions (landing page,
|
|
8
|
+
// event name, date), refuses the query-bearing dimensions by name, refuses person-level
|
|
9
|
+
// dimensions by name, and every output carries property, window and measurement caveats with the
|
|
10
|
+
// two-layer (search / business) presentation constructed side by side, never joined.
|
|
11
|
+
//
|
|
12
|
+
// The consent is separate: analytics.readonly ONLY, never bundled into the GSC grant, and no other
|
|
13
|
+
// Google scope is requested here. Absent GA4 is a valid complete configuration — `client: null`
|
|
14
|
+
// resolves to `ga4_absent`, writes nothing, throws nothing, and the manual/GSC core path never
|
|
15
|
+
// consults this module. No credentials are authorized for this repository; every state below is
|
|
16
|
+
// proven by fixture clients in test/context-ga4.test.js, and the HTTP transports at the bottom
|
|
17
|
+
// exist for the day a customer connects their own property.
|
|
18
|
+
//
|
|
19
|
+
// Google facts this module encodes were verified against official documentation on 2026-09-12;
|
|
20
|
+
// see ga4-context-implementation-2026-09-12.md in the card folder for the citations.
|
|
21
|
+
import { rm, readFile } from 'node:fs/promises';
|
|
22
|
+
import { readJsonl, appendJsonl, readJson, writeJson, isValidDay, daysBetween, numberOrNullOr } from './util.js';
|
|
23
|
+
import { ContextError, recentWindows, pageContext as gscPageContext } from './gsc.js';
|
|
24
|
+
|
|
25
|
+
export const GA4_SCOPE = 'https://www.googleapis.com/auth/analytics.readonly';
|
|
26
|
+
export const GA4_RETRIEVED_BY = 'linktrail-ga4';
|
|
27
|
+
|
|
28
|
+
export const GA4_STATES = Object.freeze([
|
|
29
|
+
'ok', 'ga4_absent', 'ga4_not_authorized', 'ga4_revoked', 'ga4_property_not_found',
|
|
30
|
+
'ga4_empty_window', 'ga4_no_row_for_page', 'ga4_thresholded', 'ga4_sampled',
|
|
31
|
+
'ga4_data_loss_other_row', 'ga4_metric_restricted', 'ga4_truncated', 'ga4_no_key_events_configured',
|
|
32
|
+
'ga4_quota_exceeded',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const GA4_LIMITS = Object.freeze({
|
|
36
|
+
// The product envelope, enforced on ourselves so a refresh is cheap by construction. GA4's own
|
|
37
|
+
// documented envelope is far larger (200,000 core tokens per property per day, 40,000 per hour;
|
|
38
|
+
// 10 concurrent per property — we run strictly sequential), so this cap can never surprise it.
|
|
39
|
+
refreshReportsPerProperty: 4, // default budget: one window x (landing pages, landing pages x key events)
|
|
40
|
+
hardCapReportsPerRun: 10, // absolute ceiling across every property in one run
|
|
41
|
+
keyEventListPageSize: 200, // the Admin API's documented maximum for keyEvents.list
|
|
42
|
+
keyEventListPages: 2, // bounded pagination; beyond this the config read is partial and says so
|
|
43
|
+
defaultRowLimit: 10_000, // the Data API's documented default when limit is unspecified
|
|
44
|
+
rowLimitCap: 250_000, // the Data API's documented maximum rows returned per request
|
|
45
|
+
freshnessMs: 6 * 60 * 60 * 1000, // identical re-query inside this window is served from the repo
|
|
46
|
+
quotaRetries: 1, // one retry after the wait, then stop
|
|
47
|
+
quotaRetryWaitMs: 15 * 60_000,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The three aggregate shapes this module may request. Nothing else: no channel, no source, no
|
|
51
|
+
// audience, no query. The allowlist is the "no invented query-to-person join" acceptance made
|
|
52
|
+
// structural — a join cannot be built downstream from data that was never fetched.
|
|
53
|
+
export const GA4_REPORT_SHAPES = Object.freeze([
|
|
54
|
+
Object.freeze(['landingPagePlusQueryString']),
|
|
55
|
+
Object.freeze(['landingPagePlusQueryString', 'eventName']),
|
|
56
|
+
Object.freeze(['date']),
|
|
57
|
+
]);
|
|
58
|
+
export const GA4_REPORT_METRICS = Object.freeze(['sessions', 'keyEvents', 'eventCount', 'totalRevenue']);
|
|
59
|
+
|
|
60
|
+
// Refused by name, each with the rule that made the refusal. These names exist in GA4's wider
|
|
61
|
+
// dictionary (paid-ads search queries, site-search terms, person identifiers, thresholded
|
|
62
|
+
// audiences); none of them may enter a business-context report.
|
|
63
|
+
export const GA4_QUERY_DIMENSIONS = Object.freeze(['searchTerm', 'googleAdsQuery', 'firstUserGoogleAdsQuery', 'sa360Query', 'firstUserSa360Query']);
|
|
64
|
+
export const GA4_PERSON_DIMENSIONS = Object.freeze(['userId', 'clientId', 'userKey']);
|
|
65
|
+
export const GA4_THRESHOLD_DIMENSIONS = Object.freeze(['userAgeBracket', 'userGender', 'brandingInterest', 'audienceId', 'audienceName']);
|
|
66
|
+
|
|
67
|
+
// Measurement caveats carried on every output that could be misread. Codes travel on fact rows;
|
|
68
|
+
// the sentences travel with the read.
|
|
69
|
+
export const GA4_CAVEATS = Object.freeze({
|
|
70
|
+
zero_rows_omitted: 'GA4 omits rows where every metric is 0 (keepEmptyRows defaults to false): a landing page with no returned row had no reported activity, which is not a claim about visitors no tracker saw.',
|
|
71
|
+
other_row: 'Some dimension combinations were rolled into an "(other)" row (dataLossFromOtherRow): sums over the returned rows are floors, never complete totals.',
|
|
72
|
+
thresholding: 'The report was subject to GA4 data thresholds (subjectToThresholding): only data meeting minimum aggregation thresholds is returned.',
|
|
73
|
+
sampling: 'The report was sampled: a subset of the property\'s events was analyzed, not all of them.',
|
|
74
|
+
metric_restricted: 'The viewer\'s role restricts one or more requested metrics (schema restrictions); restricted values are absent rather than approximated.',
|
|
75
|
+
currency: 'Revenue is GA4 totalRevenue in the property\'s currency (currencyCode carried). It is not an earnings claim and is not attributable to any placement, query or campaign.',
|
|
76
|
+
counting: 'keyEvents counts key events per the counting method configured on the property (carried verbatim from the Admin API); the method decides whether one session can contribute more than once.',
|
|
77
|
+
attribution: 'GA4 measures site behavior under its own attribution. Nothing here attributes sessions, key events or revenue to a search query, a backlink, a placement, or this tool\'s work.',
|
|
78
|
+
no_join: 'GSC measures search behavior; GA4 measures site behavior. Different populations, different measurement: the two are carried as separate labeled layers, no join is constructed, and none may be inferred.',
|
|
79
|
+
property_dates: 'Dates are the property\'s calendar days (timeZone carried from the response); windows are requested as whole days and no cross-zone alignment is performed.',
|
|
80
|
+
match_basis: 'GA4 reports landing pages as page path + query string, not full URLs. A page is matched by path after stripping the origin — a path match, not an identity.',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const isoOf = value => (value instanceof Date ? value : new Date(value)).toISOString();
|
|
84
|
+
export const isGa4AuthorizationLoss = status => status === 401 || status === 403;
|
|
85
|
+
|
|
86
|
+
/** The shared state file, coalesced: absent means "nothing recorded yet", not null dereference. */
|
|
87
|
+
const readGa4State = async path => (await readJson(path)) ?? {};
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Property identity. GA4 properties are "properties/<numeric id>" in every API path.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/** Accepts `properties/123`, `123` or `123` numeric; normalizes to `properties/<id>`. */
|
|
94
|
+
export function parseGa4Property(input) {
|
|
95
|
+
const text = String(input ?? '').trim();
|
|
96
|
+
const id = text.startsWith('properties/') ? text.slice('properties/'.length) : text;
|
|
97
|
+
if (!/^\d{1,16}$/u.test(id)) throw new ContextError('invalid_ga4_property', { input: String(input).slice(0, 64) });
|
|
98
|
+
return `properties/${id}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Report validation — the structural no-join guard.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validates one runReport shape. Dimensions must be exactly one of the three allowed combinations;
|
|
107
|
+
* metrics must come from the allowlist. Query-bearing dimensions, person-level dimensions and
|
|
108
|
+
* thresholded dimensions are refused BY NAME, each with its own error reason, so the refusal
|
|
109
|
+
* itself is visible in tests and logs rather than silently absent from an allowlist.
|
|
110
|
+
*/
|
|
111
|
+
export function validateGa4Report({ dimensions = [], metrics = ['sessions', 'keyEvents', 'eventCount', 'totalRevenue'], rowLimit } = {}) {
|
|
112
|
+
for (const dimension of dimensions) {
|
|
113
|
+
if (GA4_QUERY_DIMENSIONS.includes(dimension)) throw new ContextError('query_dimension_refused', { dimension, rule: 'no invented query-to-person joins: search-query text never enters GA4 business context' });
|
|
114
|
+
if (GA4_PERSON_DIMENSIONS.includes(dimension)) throw new ContextError('person_level_dimension_refused', { dimension, rule: 'aggregate business context only; no person-level data' });
|
|
115
|
+
if (GA4_THRESHOLD_DIMENSIONS.includes(dimension)) throw new ContextError('thresholded_dimension_refused', { dimension, rule: 'kept off the thresholding budget; an aggregate profile does not need demographics or audiences' });
|
|
116
|
+
if (!GA4_REPORT_SHAPES.some(shape => shape.includes(dimension))) throw new ContextError('dimension_not_allowed', { dimension, allowed: GA4_REPORT_SHAPES.flat() });
|
|
117
|
+
}
|
|
118
|
+
const shapeMatches = GA4_REPORT_SHAPES.some(shape => shape.length === dimensions.length && shape.every((d, i) => dimensions[i] === d));
|
|
119
|
+
if (!shapeMatches) throw new ContextError('unsupported_dimension_combination', { dimensions, allowed: GA4_REPORT_SHAPES.map(s => [...s]) });
|
|
120
|
+
for (const metric of metrics) {
|
|
121
|
+
if (!GA4_REPORT_METRICS.includes(metric)) throw new ContextError('metric_not_allowed', { metric, allowed: [...GA4_REPORT_METRICS] });
|
|
122
|
+
}
|
|
123
|
+
const limit = rowLimit === undefined ? GA4_LIMITS.defaultRowLimit : Math.round(Number(rowLimit));
|
|
124
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > GA4_LIMITS.rowLimitCap) {
|
|
125
|
+
throw new ContextError('row_limit_out_of_range', { rowLimit, max: GA4_LIMITS.rowLimitCap });
|
|
126
|
+
}
|
|
127
|
+
return { dimensions: [...dimensions], metrics: [...metrics], rowLimit: limit, keepEmptyRows: false };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Fact rows (ga4.jsonl). Appended, never rewritten; newest answer wins at read.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export function ga4FactKey(row) {
|
|
135
|
+
const window = row.window ?? {};
|
|
136
|
+
return [row.kind, row.property ?? '', row.landing_page ?? '', row.event_name ?? '', row.date ?? '',
|
|
137
|
+
(row.dimensions ?? []).join('+'), window.start ?? '', window.end ?? ''].join('|');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function newestGa4Rows(rows) {
|
|
141
|
+
const byKey = new Map();
|
|
142
|
+
for (const row of rows ?? []) {
|
|
143
|
+
const key = ga4FactKey(row);
|
|
144
|
+
const stamp = row.fetched_at ?? row.captured_at ?? '';
|
|
145
|
+
const held = byKey.get(key);
|
|
146
|
+
if (!held || stamp >= (held.fetched_at ?? held.captured_at ?? '')) byKey.set(key, row);
|
|
147
|
+
}
|
|
148
|
+
return [...byKey.values()];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function readGa4Rows(path) { const { rows, problems, missing } = await readJsonl(path); return { rows, problems, missing }; }
|
|
152
|
+
export const appendGa4Rows = (path, rows) => appendJsonl(path, rows);
|
|
153
|
+
|
|
154
|
+
const numberMetric = value => numberOrNullOr(value === undefined || value === null || value === '' ? null : Number(value));
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* One runReport answer becomes fact rows. Metrics are stored raw as GA4 reported them; rates are
|
|
158
|
+
* derived at read time. The response's metadata becomes caveat codes on every row it produced,
|
|
159
|
+
* because a number read without its measurement caveats is a different, stronger claim.
|
|
160
|
+
*/
|
|
161
|
+
export function rowsFromGa4Response({ property, request, response, window, fetchedAt, retrievedBy = GA4_RETRIEVED_BY }) {
|
|
162
|
+
const apiRows = Array.isArray(response?.rows) ? response.rows : [];
|
|
163
|
+
const metadata = response?.metadata ?? {};
|
|
164
|
+
const sampling = Array.isArray(metadata.samplingMetadatas) && metadata.samplingMetadatas.length
|
|
165
|
+
? metadata.samplingMetadatas.map(s => ({ samples_read_count: numberMetric(s.samplesReadCount), sampling_space_size: numberMetric(s.samplingSpaceSize) }))
|
|
166
|
+
: null;
|
|
167
|
+
const restrictions = Array.isArray(metadata.schemaRestrictionResponse?.activeMetricRestrictions) ? metadata.schemaRestrictionResponse.activeMetricRestrictions : [];
|
|
168
|
+
const caveats = ['property_dates', 'attribution'];
|
|
169
|
+
if (metadata.dataLossFromOtherRow) caveats.push('other_row');
|
|
170
|
+
if (metadata.subjectToThresholding) caveats.push('thresholding');
|
|
171
|
+
if (sampling) caveats.push('sampling');
|
|
172
|
+
if (restrictions.length) caveats.push('metric_restricted');
|
|
173
|
+
if (request.metrics.includes('keyEvents')) caveats.push('counting');
|
|
174
|
+
if (request.metrics.includes('totalRevenue')) caveats.push('currency');
|
|
175
|
+
const common = {
|
|
176
|
+
property, window, dimensions: [...request.dimensions], metrics: [...request.metrics],
|
|
177
|
+
keep_empty_rows: request.keepEmptyRows, row_count: numberMetric(response?.rowCount) ?? apiRows.length,
|
|
178
|
+
rows_returned: apiRows.length, truncated: apiRows.length >= request.rowLimit,
|
|
179
|
+
currency_code: metadata.currencyCode ?? null, time_zone: metadata.timeZone ?? null,
|
|
180
|
+
empty_reason: metadata.emptyReason ?? null, sampling, metric_restrictions: restrictions.length ? restrictions : null,
|
|
181
|
+
caveats, retrieved_by: retrievedBy, fetched_at: fetchedAt,
|
|
182
|
+
};
|
|
183
|
+
const marker = { ...common, kind: 'ga4.window', landing_page: null, event_name: null, date: null, state: apiRows.length ? 'rows' : 'ga4_empty_window' };
|
|
184
|
+
const indexOf = name => request.dimensions.indexOf(name);
|
|
185
|
+
const factRows = apiRows.map(row => ({
|
|
186
|
+
...common,
|
|
187
|
+
kind: indexOf('eventName') >= 0 && indexOf('landingPagePlusQueryString') >= 0 ? 'ga4.landing_page_event'
|
|
188
|
+
: indexOf('landingPagePlusQueryString') >= 0 ? 'ga4.landing_page'
|
|
189
|
+
: indexOf('date') >= 0 ? 'ga4.date' : 'ga4.aggregate',
|
|
190
|
+
landing_page: indexOf('landingPagePlusQueryString') >= 0 ? row.dimensionValues?.[indexOf('landingPagePlusQueryString')]?.value ?? null : null,
|
|
191
|
+
event_name: indexOf('eventName') >= 0 ? row.dimensionValues?.[indexOf('eventName')]?.value ?? null : null,
|
|
192
|
+
date: indexOf('date') >= 0 ? row.dimensionValues?.[indexOf('date')]?.value ?? null : null,
|
|
193
|
+
sessions: request.metrics.includes('sessions') ? numberMetric(row.metricValues?.[request.metrics.indexOf('sessions')]?.value) : null,
|
|
194
|
+
key_events: request.metrics.includes('keyEvents') ? numberMetric(row.metricValues?.[request.metrics.indexOf('keyEvents')]?.value) : null,
|
|
195
|
+
event_count: request.metrics.includes('eventCount') ? numberMetric(row.metricValues?.[request.metrics.indexOf('eventCount')]?.value) : null,
|
|
196
|
+
total_revenue: request.metrics.includes('totalRevenue') ? numberMetric(row.metricValues?.[request.metrics.indexOf('totalRevenue')]?.value) : null,
|
|
197
|
+
}));
|
|
198
|
+
return { marker, factRows };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The configured key events as fact rows (not windowed — they are property configuration). */
|
|
202
|
+
export function keyEventsConfigRow({ property, keyEvents, fetchedAt, retrievedBy = GA4_RETRIEVED_BY }) {
|
|
203
|
+
return {
|
|
204
|
+
kind: 'ga4.key_events_config', property, window: null,
|
|
205
|
+
key_events: (keyEvents ?? []).map(event => ({
|
|
206
|
+
event_name: event.eventName ?? null,
|
|
207
|
+
counting_method: event.countingMethod ?? null, // carried verbatim; the enum set is Google's to define
|
|
208
|
+
resource_name: event.name ?? null,
|
|
209
|
+
})),
|
|
210
|
+
retrieved_by: retrievedBy, fetched_at: fetchedAt,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// The Google client interface + the (unused-by-default) HTTP transports. Separate origins for the
|
|
216
|
+
// Data API and the Admin API, exactly as documented. Read-only scope only; there is no write verb
|
|
217
|
+
// here. Tokens come from the customer's environment at call time — never logged or written.
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
export class Ga4ApiError extends Error {
|
|
221
|
+
constructor(api, status, body) { super(`ga4_${api}_http_${status}`); this.name = 'Ga4ApiError'; this.api = api; this.status = status; this.body = body ?? null; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function createGa4Client({ fetchImpl = globalThis.fetch, dataOrigin = 'https://analyticsdata.googleapis.com', adminOrigin = 'https://analyticsadmin.googleapis.com', accessToken = null, getAccessToken = null } = {}) {
|
|
225
|
+
const token = () => accessToken ?? (typeof getAccessToken === 'function' ? getAccessToken() : null);
|
|
226
|
+
async function call(url, api, method, body = null) {
|
|
227
|
+
const bearer = token();
|
|
228
|
+
if (!bearer) throw new ContextError('no_access_token');
|
|
229
|
+
let response;
|
|
230
|
+
try {
|
|
231
|
+
response = await fetchImpl(url, {
|
|
232
|
+
method,
|
|
233
|
+
headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
|
|
234
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
235
|
+
});
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof ContextError || error instanceof Ga4ApiError) throw error;
|
|
238
|
+
throw new Ga4ApiError(api, 0, { error: String(error?.message ?? error) });
|
|
239
|
+
}
|
|
240
|
+
if (!response.ok) throw new Ga4ApiError(api, response.status, await response.json().catch(() => null));
|
|
241
|
+
return response.json();
|
|
242
|
+
}
|
|
243
|
+
// Frozen on purpose: the surface this card ships is exactly these two read verbs, and the
|
|
244
|
+
// export-boundary test asserts it — no Links verb, no AI-report verb, nothing else.
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
scope: GA4_SCOPE,
|
|
247
|
+
/** Data API runReport: aggregate report rows for one property. */
|
|
248
|
+
async runReport(property, requestBody) {
|
|
249
|
+
return call(`${dataOrigin}/v1beta/${property}:runReport`, 'data', 'POST', requestBody);
|
|
250
|
+
},
|
|
251
|
+
/** Admin API keyEvents.list: the key events configured on the property. */
|
|
252
|
+
async listKeyEvents(property, { pageSize = GA4_LIMITS.keyEventListPageSize, pageToken = null } = {}) {
|
|
253
|
+
const query = new URLSearchParams({ pageSize: String(pageSize), ...(pageToken ? { pageToken } : {}) });
|
|
254
|
+
return call(`${adminOrigin}/v1beta/${property}/keyEvents?${query}`, 'admin', 'GET');
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// context-state.json (the shared tool-owned state file; GA4 writes under `ga4`).
|
|
261
|
+
// Booleans, property names, dates and key-event names only — never tokens.
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export const ga4FetchKey = ({ property, request }) => [
|
|
265
|
+
property, request.dimensions.join('+'), request.metrics.join('+'),
|
|
266
|
+
request.window.start, request.window.end,
|
|
267
|
+
].join('|');
|
|
268
|
+
|
|
269
|
+
const freshEnough = (stamp, nowMs, lifetimeMs) => typeof stamp === 'string' && Number.isFinite(Date.parse(stamp)) && nowMs - Date.parse(stamp) < lifetimeMs;
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// The bounded refresh. Sequential by construction (GA4 caps concurrency at 10 per property; we
|
|
273
|
+
// spend 1), under a self-imposed report budget, idempotent by window+shape key inside the
|
|
274
|
+
// freshness lifetime, one retry on quota then stop.
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
export async function refreshGa4Context({
|
|
278
|
+
client = null, paths, property: wantedProperty = null, windows: wantedWindows = null,
|
|
279
|
+
now = () => new Date(), sleep = () => Promise.resolve(), freshnessMs = GA4_LIMITS.freshnessMs, limits = {},
|
|
280
|
+
}) {
|
|
281
|
+
const cap = { ...GA4_LIMITS, ...limits };
|
|
282
|
+
const state = await readGa4State(paths.contextState);
|
|
283
|
+
const fetchedAt = isoOf(now());
|
|
284
|
+
const output = {
|
|
285
|
+
started_at: fetchedAt, state: 'ok', property: null, rows_written: 0, markers_written: 0,
|
|
286
|
+
key_events_configured: null, key_events_config_partial: false,
|
|
287
|
+
calls: { run_report: 0, list_key_events: 0, budget_per_property: cap.refreshReportsPerProperty, hard_cap_per_run: cap.hardCapReportsPerRun },
|
|
288
|
+
served_from_repo: 0, states: [], quota: null, notes: [],
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (!client) {
|
|
292
|
+
// Absent GA4 is a valid complete configuration. Nothing is written to ga4.jsonl, nothing
|
|
293
|
+
// throws, and the manual/GSC core path never consults this module.
|
|
294
|
+
state.ga4 = { ...(state.ga4 ?? {}), grant: 'none', checked_at: fetchedAt };
|
|
295
|
+
await writeJson(paths.contextState, state);
|
|
296
|
+
output.state = 'ga4_absent';
|
|
297
|
+
output.notes.push('no GA4 connection configured; GSC and manual context are unaffected — absent GA4 never blocks the core path');
|
|
298
|
+
return output;
|
|
299
|
+
}
|
|
300
|
+
if (!wantedProperty) throw new ContextError('ga4_property_required');
|
|
301
|
+
|
|
302
|
+
const property = parseGa4Property(wantedProperty);
|
|
303
|
+
output.property = property;
|
|
304
|
+
|
|
305
|
+
// 1. The configured key events (Admin API). Bounded pagination; a partial config read says so.
|
|
306
|
+
let keyEvents = [], pageToken = null, pages = 0, configPartial = false;
|
|
307
|
+
for (;;) {
|
|
308
|
+
output.calls.list_key_events++; pages++;
|
|
309
|
+
let body;
|
|
310
|
+
try {
|
|
311
|
+
body = await client.listKeyEvents(property, { pageToken });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
if (error instanceof Ga4ApiError && isGa4AuthorizationLoss(error.status)) {
|
|
314
|
+
state.ga4 = { ...(state.ga4 ?? {}), grant: 'refused', property, last_status: error.status, checked_at: fetchedAt };
|
|
315
|
+
await writeJson(paths.contextState, state);
|
|
316
|
+
output.state = 'ga4_not_authorized';
|
|
317
|
+
return output;
|
|
318
|
+
}
|
|
319
|
+
if (error instanceof Ga4ApiError && error.status === 404) {
|
|
320
|
+
state.ga4 = { ...(state.ga4 ?? {}), grant: 'not_found', property, checked_at: fetchedAt };
|
|
321
|
+
await writeJson(paths.contextState, state);
|
|
322
|
+
output.state = 'ga4_property_not_found';
|
|
323
|
+
output.notes.push(`the Admin API has no property ${property} visible to this grant`);
|
|
324
|
+
return output;
|
|
325
|
+
}
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
keyEvents = keyEvents.concat(body?.keyEvents ?? []);
|
|
329
|
+
pageToken = body?.nextPageToken ?? null;
|
|
330
|
+
if (!pageToken || pages >= cap.keyEventListPages) { configPartial = Boolean(pageToken); break; }
|
|
331
|
+
}
|
|
332
|
+
output.key_events_config_partial = configPartial;
|
|
333
|
+
await appendGa4Rows(paths.ga4, [keyEventsConfigRow({ property, keyEvents, fetchedAt })]);
|
|
334
|
+
state.ga4 = { ...(state.ga4 ?? {}), grant: 'ok', property, checked_at: fetchedAt,
|
|
335
|
+
key_events_config: keyEvents.map(event => ({ event_name: event.eventName ?? null, counting_method: event.countingMethod ?? null })) };
|
|
336
|
+
output.key_events_configured = state.ga4.key_events_config;
|
|
337
|
+
if (configPartial) output.notes.push(`key-events config read stopped at ${cap.keyEventListPages} page(s); the list is partial`);
|
|
338
|
+
if (!keyEvents.length) {
|
|
339
|
+
output.states.push('ga4_no_key_events_configured');
|
|
340
|
+
output.notes.push('no key events are configured on this property; keyEvents metrics will report 0 by configuration, not by observation');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 2. The report set: one window by default x (landing pages, landing pages x configured key events).
|
|
344
|
+
const windows = wantedWindows ?? recentWindows({ days: 28, count: 1, now });
|
|
345
|
+
const keyEventNames = keyEvents.map(event => event.eventName).filter(Boolean);
|
|
346
|
+
const shapes = [validateGa4Report({ dimensions: ['landingPagePlusQueryString'] })];
|
|
347
|
+
if (keyEventNames.length) shapes.push(validateGa4Report({ dimensions: ['landingPagePlusQueryString', 'eventName'] }));
|
|
348
|
+
const nowMs = Date.parse(now());
|
|
349
|
+
let revoked = false;
|
|
350
|
+
|
|
351
|
+
for (const window of windows) {
|
|
352
|
+
for (const shape of shapes) {
|
|
353
|
+
if (output.calls.run_report >= cap.refreshReportsPerProperty || output.calls.run_report >= cap.hardCapReportsPerRun) {
|
|
354
|
+
output.notes.push(`budget reached before ${window.start}..${window.end} ${shape.dimensions.join('+')}`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const request = { ...shape, window };
|
|
358
|
+
const key = ga4FetchKey({ property, request });
|
|
359
|
+
const lastFetch = state.ga4?.last_fetch?.[key];
|
|
360
|
+
if (freshEnough(lastFetch, nowMs, freshnessMs)) {
|
|
361
|
+
output.served_from_repo++;
|
|
362
|
+
output.states.push('served_from_repo');
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const body = {
|
|
366
|
+
dateRanges: [{ startDate: window.start, endDate: window.end }],
|
|
367
|
+
dimensions: shape.dimensions.map(name => ({ name })),
|
|
368
|
+
metrics: shape.metrics.map(name => ({ name })),
|
|
369
|
+
limit: String(shape.rowLimit),
|
|
370
|
+
keepEmptyRows: shape.keepEmptyRows,
|
|
371
|
+
returnPropertyQuota: true,
|
|
372
|
+
...(shape.dimensions.includes('eventName') && keyEventNames.length
|
|
373
|
+
? { dimensionFilter: { filter: { fieldName: 'eventName', inListFilter: { values: keyEventNames } } } }
|
|
374
|
+
: {}),
|
|
375
|
+
};
|
|
376
|
+
let response, attempt = 0;
|
|
377
|
+
for (;;) {
|
|
378
|
+
try {
|
|
379
|
+
output.calls.run_report++;
|
|
380
|
+
response = await client.runReport(property, body);
|
|
381
|
+
break;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error instanceof Ga4ApiError && error.status === 429) {
|
|
384
|
+
output.quota = { retries_used: attempt, wait_ms: cap.quotaRetryWaitMs };
|
|
385
|
+
if (attempt >= cap.quotaRetries) {
|
|
386
|
+
output.state = output.state === 'ok' ? 'ga4_quota_exceeded' : output.state;
|
|
387
|
+
output.states.push('ga4_quota_exceeded');
|
|
388
|
+
output.notes.push('GA4 quota refused the request; one retry was made and then the run stopped — a context refresh is never urgent enough to spend the customer\'s tokens');
|
|
389
|
+
await writeJson(paths.contextState, state);
|
|
390
|
+
return output;
|
|
391
|
+
}
|
|
392
|
+
attempt++;
|
|
393
|
+
await sleep(cap.quotaRetryWaitMs);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (error instanceof Ga4ApiError && isGa4AuthorizationLoss(error.status)) {
|
|
397
|
+
// Revocation mid-run: rows already appended are the customer's own data and are kept.
|
|
398
|
+
state.ga4 = { ...(state.ga4 ?? {}), grant: 'revoked', last_status: error.status, checked_at: fetchedAt };
|
|
399
|
+
await writeJson(paths.contextState, state);
|
|
400
|
+
output.state = 'ga4_revoked';
|
|
401
|
+
output.states.push('ga4_revoked');
|
|
402
|
+
output.notes.push('authorization was lost mid-run; rows already written are retained');
|
|
403
|
+
revoked = true;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (revoked) break;
|
|
410
|
+
const { marker, factRows } = rowsFromGa4Response({ property, request, response, window, fetchedAt });
|
|
411
|
+
await appendGa4Rows(paths.ga4, [...factRows, marker]);
|
|
412
|
+
output.rows_written += factRows.length; output.markers_written++;
|
|
413
|
+
state.ga4 = { ...(state.ga4 ?? {}), last_fetch: { ...(state.ga4?.last_fetch ?? {}), [key]: fetchedAt } };
|
|
414
|
+
const windowState = marker.state === 'ga4_empty_window' ? 'ga4_empty_window'
|
|
415
|
+
: marker.caveats.includes('thresholding') ? 'ga4_thresholded'
|
|
416
|
+
: marker.caveats.includes('sampling') ? 'ga4_sampled'
|
|
417
|
+
: marker.caveats.includes('other_row') ? 'ga4_data_loss_other_row'
|
|
418
|
+
: marker.caveats.includes('metric_restricted') ? 'ga4_metric_restricted'
|
|
419
|
+
: marker.truncated ? 'ga4_truncated' : 'ok';
|
|
420
|
+
output.states.push(windowState);
|
|
421
|
+
}
|
|
422
|
+
if (revoked) break;
|
|
423
|
+
}
|
|
424
|
+
if (state.ga4.grant !== 'revoked') state.ga4.grant = 'ok'; // a later write cannot relabel a refusal
|
|
425
|
+
await writeJson(paths.contextState, state);
|
|
426
|
+
return output;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Read-time outputs. Counts raw, rates derived, caveats attached to the number.
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
/** A full page URL becomes the GA4 landing-page key: path + query string, origin stripped. */
|
|
434
|
+
export function ga4LandingPageOf(pageUrl) {
|
|
435
|
+
try {
|
|
436
|
+
const url = new URL(pageUrl);
|
|
437
|
+
return url.pathname + url.search;
|
|
438
|
+
} catch { return null; }
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const landingPageMatches = (ga4LandingPage, wantedPath) => {
|
|
442
|
+
if (typeof ga4LandingPage !== 'string' || !wantedPath) return false;
|
|
443
|
+
if (ga4LandingPage === wantedPath) return true;
|
|
444
|
+
try { return decodeURIComponent(ga4LandingPage) === decodeURIComponent(wantedPath); } catch { return false; }
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const caveatNotes = codes => [...new Set(codes ?? [])].map(code => ({ code, note: GA4_CAVEATS[code] ?? code }));
|
|
448
|
+
const keyEventsPerSession = (keyEvents, sessions) => keyEvents === null || sessions === null ? null : sessions === 0 ? null : Math.round((keyEvents / sessions) * 10_000) / 10_000;
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Aggregate landing-page context for one page and window, from the repo's own rows. Resolves to
|
|
452
|
+
* exactly one state, carries property and window on every answer, and attaches the measurement
|
|
453
|
+
* caveats the contributing rows earned. A page with no returned row is named, not zeroed and not
|
|
454
|
+
* mystified: GA4 omits all-zero rows, so absence means "no reported activity".
|
|
455
|
+
*/
|
|
456
|
+
export function landingPageContext({ rows, page, window: wanted = null, connection = null, config = null } = {}) {
|
|
457
|
+
const latest = newestGa4Rows(rows ?? []);
|
|
458
|
+
const sameWindow = row => !wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end);
|
|
459
|
+
const wantedPath = ga4LandingPageOf(page);
|
|
460
|
+
if (wantedPath === null) return { state: 'ga4_no_row_for_page', page, property: null, window: wanted, totals: null, events: [], caveats: [], notes: ['the page is not a parseable URL; GA4 landing pages are page path + query string'] };
|
|
461
|
+
const pageRows = latest.filter(row => (row.kind === 'ga4.landing_page' || row.kind === 'ga4.landing_page_event')
|
|
462
|
+
&& sameWindow(row) && landingPageMatches(row.landing_page, wantedPath));
|
|
463
|
+
const property = pageRows[0]?.property ?? connection?.property ?? null;
|
|
464
|
+
const baseCaveats = ['property_dates', 'match_basis', 'attribution'];
|
|
465
|
+
if (!pageRows.length) {
|
|
466
|
+
if (!connection || connection.grant === 'none') {
|
|
467
|
+
return { state: 'ga4_absent', page, property, window: wanted, totals: null, events: [], caveats: caveatNotes(baseCaveats),
|
|
468
|
+
notes: ['no GA4 connection configured; this business layer is absent and the core path is unaffected'] };
|
|
469
|
+
}
|
|
470
|
+
const marker = latest.find(row => row.kind === 'ga4.window' && sameWindow(row));
|
|
471
|
+
if (marker?.state === 'ga4_empty_window') {
|
|
472
|
+
return { state: 'ga4_empty_window', page, property, window: wanted, totals: null, events: [], caveats: caveatNotes([...baseCaveats, 'zero_rows_omitted']),
|
|
473
|
+
notes: ['GA4 returned no rows at all for this window — a valid state, not an error'] };
|
|
474
|
+
}
|
|
475
|
+
if (connection.grant === 'revoked' || connection.grant === 'refused') {
|
|
476
|
+
return { state: connection.grant === 'revoked' ? 'ga4_revoked' : 'ga4_not_authorized', page, property, window: wanted, totals: null, events: [], caveats: caveatNotes(baseCaveats),
|
|
477
|
+
notes: ['existing rows retained; no new rows are written in this state'] };
|
|
478
|
+
}
|
|
479
|
+
return { state: 'ga4_no_row_for_page', page, property, window: wanted, totals: null, events: [],
|
|
480
|
+
caveats: caveatNotes([...baseCaveats, 'zero_rows_omitted']),
|
|
481
|
+
notes: ['GA4 returned no row for this landing page; the API omits rows where every metric is 0, so this is "no reported activity", not an unknown and not a zero invented by us'] };
|
|
482
|
+
}
|
|
483
|
+
const pageRow = pageRows.find(row => row.kind === 'ga4.landing_page') ?? null;
|
|
484
|
+
const eventRows = pageRows.filter(row => row.kind === 'ga4.landing_page_event');
|
|
485
|
+
const caveats = [...baseCaveats, ...new Set(pageRows.flatMap(row => row.caveats ?? []))];
|
|
486
|
+
const truncated = pageRows.some(row => row.truncated);
|
|
487
|
+
const state = caveats.includes('thresholding') ? 'ga4_thresholded'
|
|
488
|
+
: caveats.includes('sampling') ? 'ga4_sampled'
|
|
489
|
+
: caveats.includes('other_row') ? 'ga4_data_loss_other_row'
|
|
490
|
+
: caveats.includes('metric_restricted') ? 'ga4_metric_restricted'
|
|
491
|
+
: truncated ? 'ga4_truncated' : 'ok';
|
|
492
|
+
return {
|
|
493
|
+
state, page, property,
|
|
494
|
+
window: pageRow?.window ?? eventRows[0]?.window ?? wanted ?? null,
|
|
495
|
+
landing_page: pageRow?.landing_page ?? eventRows[0]?.landing_page ?? null,
|
|
496
|
+
match_basis: 'path_after_origin_strip',
|
|
497
|
+
currency_code: pageRow?.currency_code ?? eventRows[0]?.currency_code ?? null,
|
|
498
|
+
time_zone: pageRow?.time_zone ?? eventRows[0]?.time_zone ?? null,
|
|
499
|
+
totals: pageRow ? {
|
|
500
|
+
sessions: pageRow.sessions, key_events: pageRow.key_events,
|
|
501
|
+
event_count: pageRow.event_count, total_revenue: pageRow.total_revenue,
|
|
502
|
+
key_events_per_session: keyEventsPerSession(pageRow.key_events, pageRow.sessions), // derived at read time
|
|
503
|
+
} : null,
|
|
504
|
+
events: eventRows.map(row => ({
|
|
505
|
+
event_name: row.event_name, key_events: row.key_events, event_count: row.event_count,
|
|
506
|
+
total_revenue: row.total_revenue,
|
|
507
|
+
})),
|
|
508
|
+
rows_returned: pageRows.length, truncated,
|
|
509
|
+
caveats: caveatNotes(caveats),
|
|
510
|
+
notes: truncated ? ['the contributing report hit its row cap; these numbers are the returned rows, not necessarily every row'] : [],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The configured key events with their observed totals in a window. Config comes from the newest
|
|
516
|
+
* `ga4.key_events_config` fact row (or the state file); totals come from the landing-page x event
|
|
517
|
+
* report. Totals are summed only when no contributing report was capped — a capped sum would be a
|
|
518
|
+
* floor wearing a total's clothes.
|
|
519
|
+
*/
|
|
520
|
+
export function keyEventsContext({ rows, config = null, window: wanted = null } = {}) {
|
|
521
|
+
const latest = newestGa4Rows(rows ?? []);
|
|
522
|
+
const configRow = latest.find(row => row.kind === 'ga4.key_events_config');
|
|
523
|
+
const configured = config ?? configRow?.key_events ?? [];
|
|
524
|
+
const sameWindow = row => !wanted || (row.window?.start === wanted.start && row.window?.end === wanted.end);
|
|
525
|
+
const eventRows = latest.filter(row => row.kind === 'ga4.landing_page_event' && sameWindow(row));
|
|
526
|
+
const capped = eventRows.some(row => row.truncated) || latest.some(row => row.kind === 'ga4.window' && sameWindow(row) && row.truncated);
|
|
527
|
+
const property = eventRows[0]?.property ?? configRow?.property ?? null;
|
|
528
|
+
const baseCaveats = ['property_dates', 'counting', 'attribution'];
|
|
529
|
+
if (eventRows.some(row => (row.caveats ?? []).includes('currency'))) baseCaveats.push('currency');
|
|
530
|
+
const events = configured.map(entry => {
|
|
531
|
+
const mine = eventRows.filter(row => row.event_name === entry.event_name);
|
|
532
|
+
return {
|
|
533
|
+
event_name: entry.event_name, counting_method: entry.counting_method ?? null,
|
|
534
|
+
key_events: capped || !mine.length ? null : mine.reduce((sum, row) => sum + (row.key_events ?? 0), 0),
|
|
535
|
+
total_revenue: capped || !mine.length ? null : mine.reduce((sum, row) => sum + (row.total_revenue ?? 0), 0),
|
|
536
|
+
basis: capped ? 'rows_capped_totals_withheld' : !mine.length ? 'no_rows_for_event' : 'sum_of_returned_rows',
|
|
537
|
+
};
|
|
538
|
+
});
|
|
539
|
+
return {
|
|
540
|
+
state: configured.length ? (capped ? 'ga4_truncated' : 'ok') : 'ga4_no_key_events_configured',
|
|
541
|
+
property, window: wanted ?? eventRows[0]?.window ?? null,
|
|
542
|
+
key_events: events, totals_withheld_because_capped: capped,
|
|
543
|
+
caveats: caveatNotes(baseCaveats),
|
|
544
|
+
notes: capped ? ['a contributing report hit its row cap; per-event totals are withheld rather than summed from a floor'] : [],
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The side-by-side read the acceptance asks for: the GSC observation layer and the GA4 business
|
|
550
|
+
* layer for one page, carried as SEPARATE labeled layers. This function constructs no join
|
|
551
|
+
* between them, exposes no field that combines a query with a conversion or a revenue figure, and
|
|
552
|
+
* attaches the no-join caveat. The join the customer's agent writes in its own judgment file is
|
|
553
|
+
* its inference to make and label — never this tool's output.
|
|
554
|
+
*/
|
|
555
|
+
export function businessContextForPage({ gscRows = [], ga4Rows = [], page, window: wanted = null, properties = null, connection = null, ga4Connection = null, manual = null } = {}) {
|
|
556
|
+
const search = gscPageContext({ rows: gscRows, page, window: wanted, properties, connection, manual });
|
|
557
|
+
const business = landingPageContext({ rows: ga4Rows, page, window: wanted, connection: ga4Connection });
|
|
558
|
+
return {
|
|
559
|
+
state: search.state === 'ok' && business.state === 'ok' ? 'ok' : (search.state === 'ok' || business.state === 'ok' ? 'partial' : search.state === 'manual_only' ? 'manual_only' : `${search.state}+${business.state}`),
|
|
560
|
+
page, window: wanted,
|
|
561
|
+
relation: 'side_by_side_not_joined',
|
|
562
|
+
layers: { search, business },
|
|
563
|
+
caveats: caveatNotes(['no_join']),
|
|
564
|
+
notes: ['the search layer observes Google-reported search behavior; the business layer observes GA4-reported site behavior; they measure different populations and are never joined here'],
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Existing-agent GA4 handoff import (connector-supplied, no grant from us). The contract left
|
|
570
|
+
// whether GA4 handoffs share the GSC handoff shape to T04: they do, one decision recorded here.
|
|
571
|
+
// `retrieved_by` names the connector and is never re-labeled as us; `captured_at` stays the
|
|
572
|
+
// connector's; rows missing required fields are refused per file with what is missing; imports
|
|
573
|
+
// never overwrite rows we fetched. A GA4 handoff with no Google grant from us is a complete
|
|
574
|
+
// configuration, exactly like the GSC one.
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
function parseGa4HandoffText(text) {
|
|
578
|
+
const trimmed = String(text ?? '').trim();
|
|
579
|
+
if (!trimmed) return { rows: [], error: 'empty file' };
|
|
580
|
+
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
|
581
|
+
try {
|
|
582
|
+
const parsed = JSON.parse(trimmed);
|
|
583
|
+
return { rows: Array.isArray(parsed) ? parsed : [parsed] };
|
|
584
|
+
} catch {
|
|
585
|
+
const lines = trimmed.split('\n').map(line => line.trim()).filter(Boolean);
|
|
586
|
+
const rows = [];
|
|
587
|
+
for (const line of lines) {
|
|
588
|
+
try { rows.push(JSON.parse(line)); } catch { return { rows: [], error: 'not_json' }; }
|
|
589
|
+
}
|
|
590
|
+
return { rows };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return { rows: [], error: 'unrecognized_handoff_format' };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function importGa4Handoff({ paths, text = null, file = null, now = () => new Date() } = {}) {
|
|
597
|
+
const raw = text ?? (await readFile(file, 'utf8'));
|
|
598
|
+
const parsed = parseGa4HandoffText(raw);
|
|
599
|
+
if (parsed.error) return { error: parsed.error, accepted: 0, refused: [{ reason: parsed.error }] };
|
|
600
|
+
const existing = (await readGa4Rows(paths.ga4)).rows;
|
|
601
|
+
const ours = new Set(newestGa4Rows(existing).filter(row => row.retrieved_by === GA4_RETRIEVED_BY).map(ga4FactKey));
|
|
602
|
+
const accepted = [], refused = [];
|
|
603
|
+
parsed.rows.forEach((candidate, index) => {
|
|
604
|
+
const missing = [];
|
|
605
|
+
if (!candidate.kind || !String(candidate.kind).startsWith('ga4.')) missing.push('kind (must start "ga4.")');
|
|
606
|
+
if (!candidate.window || !isValidDay(candidate.window.start) || !isValidDay(candidate.window.end)) missing.push('window');
|
|
607
|
+
if (!candidate.property) missing.push('property');
|
|
608
|
+
if (!candidate.retrieved_by || candidate.retrieved_by === GA4_RETRIEVED_BY) missing.push('retrieved_by (the connector must be named, and never us)');
|
|
609
|
+
if (candidate.kind === 'ga4.key_events_config' && missing.length === 1 && missing[0] === 'window') {
|
|
610
|
+
missing.length = 0; // configuration rows are not windowed; the other required fields still apply
|
|
611
|
+
}
|
|
612
|
+
if (missing.length) { refused.push({ row: index + 1, reason: `missing ${missing.join(' and ')}` }); return; }
|
|
613
|
+
const row = {
|
|
614
|
+
...candidate,
|
|
615
|
+
window: candidate.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) } : null,
|
|
616
|
+
captured_at: candidate.captured_at ?? null,
|
|
617
|
+
};
|
|
618
|
+
if (ours.has(ga4FactKey(row))) { refused.push({ row: index + 1, reason: 'a row we fetched for this key exists; ours is kept' }); return; }
|
|
619
|
+
accepted.push(row);
|
|
620
|
+
});
|
|
621
|
+
await appendGa4Rows(paths.ga4, accepted);
|
|
622
|
+
const state = await readGa4State(paths.contextState);
|
|
623
|
+
const connectors = [...new Set([...(state.ga4?.handoff?.connectors ?? []), ...accepted.map(row => row.retrieved_by)])];
|
|
624
|
+
state.ga4 = { ...(state.ga4 ?? {}), handoff: { connectors, last_import_at: now(), rows_imported: (state.ga4?.handoff?.rows_imported ?? 0) + accepted.length } };
|
|
625
|
+
await writeJson(paths.contextState, state);
|
|
626
|
+
return { accepted: accepted.length, refused, connectors };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Forget. Deletion is a decision. `--source ga4` removes the GA4 fact rows and the GA4 half of the
|
|
631
|
+
// shared state file, and touches nothing else — not manual.md, not site-profile.md, not the GSC
|
|
632
|
+
// rows or the GSC connection state.
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
|
|
635
|
+
export async function forgetGa4Context({ paths } = {}) {
|
|
636
|
+
const removed = [{ name: 'ga4', path: paths.ga4 }];
|
|
637
|
+
await rm(paths.ga4, { force: true });
|
|
638
|
+
const state = await readGa4State(paths.contextState);
|
|
639
|
+
if (state.ga4) {
|
|
640
|
+
delete state.ga4;
|
|
641
|
+
await writeJson(paths.contextState, state);
|
|
642
|
+
}
|
|
643
|
+
return {
|
|
644
|
+
removed,
|
|
645
|
+
kept: [
|
|
646
|
+
{ name: 'gsc', path: paths.gsc, owner: 'tool' },
|
|
647
|
+
{ name: 'manual', path: paths.manual, owner: 'human/agent' },
|
|
648
|
+
{ name: 'siteProfile', path: paths.siteProfile, owner: 'human/agent' },
|
|
649
|
+
],
|
|
650
|
+
note: 'GA4 fact rows and the GA4 half of the connection state are removed; GSC rows, manual.md and site-profile.md are untouched',
|
|
651
|
+
};
|
|
652
|
+
}
|