seo 0.2.4 → 0.2.6

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/dist/mcp.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createRequire as __seoCreateRequire } from 'node:module'; const require = __seoCreateRequire(import.meta.url);
2
- import { n as load, t as version } from "./package-D6bCOqrD.js";
2
+ import { n as load, t as version } from "./package-B9-D646K.js";
3
3
  import { Headers, fetch as fetch$1 } from "undici";
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
- import { homedir } from "node:os";
5
+ import { arch, homedir, platform } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import { z } from "zod";
8
8
  import lockfile from "proper-lockfile";
@@ -149,6 +149,7 @@ function resolveSeoCliPaths(input = {}) {
149
149
  configFile: join(configDir, "config.json"),
150
150
  tokensFile: join(configDir, "tokens.json"),
151
151
  oauthClientFile: join(configDir, "oauth-client.json"),
152
+ telemetryStateFile: join(configDir, "telemetry.json"),
152
153
  cacheDbFile: join(cacheDir, "cache.db")
153
154
  };
154
155
  }
@@ -218,6 +219,7 @@ const siteSchema = z.object({
218
219
  isDefault: z.boolean().optional()
219
220
  });
220
221
  const providerPreferenceSchema = z.enum(["cheap", "authoritative"]);
222
+ const analyticsConnectionsSchema = z.object({ google: z.object({ propertyId: z.string() }).optional() }).default({});
221
223
  const clientProfileSchema = z.object({
222
224
  id: z.string(),
223
225
  name: z.string(),
@@ -225,7 +227,7 @@ const clientProfileSchema = z.object({
225
227
  startUrl: z.string().optional(),
226
228
  watchUrls: z.array(z.string()).default([]),
227
229
  brandTerms: z.array(z.string()).default([]),
228
- ga4PropertyId: z.string().optional(),
230
+ analytics: analyticsConnectionsSchema,
229
231
  reportDay: z.number().int().min(1).max(31).optional(),
230
232
  technicalWeekday: z.number().int().min(0).max(7).optional(),
231
233
  isDefault: z.boolean().optional(),
@@ -236,14 +238,14 @@ const configSchema = z.object({
236
238
  defaultSite: z.string().optional(),
237
239
  sites: z.array(siteSchema).default([]),
238
240
  clients: z.array(clientProfileSchema).default([]),
239
- google: z.object({
240
- defaultGa4PropertyId: z.string().optional(),
241
+ analytics: z.object({ google: z.object({
242
+ defaultPropertyId: z.string().optional(),
241
243
  propertyMappings: z.array(z.object({
242
244
  siteUrl: z.string(),
243
- ga4PropertyId: z.string(),
245
+ propertyId: z.string(),
244
246
  addedAt: z.number().int().optional()
245
247
  })).default([])
246
- }).default({ propertyMappings: [] }),
248
+ }).default({ propertyMappings: [] }) }).default({ google: { propertyMappings: [] } }),
247
249
  providers: z.object({
248
250
  semrushApiKey: z.string().optional(),
249
251
  dataForSeoLogin: z.string().optional(),
@@ -460,6 +462,14 @@ function missingOAuthClientMessage(source) {
460
462
  //#endregion
461
463
  //#region packages/core/dist/gsc/auth/types.js
462
464
  const GOOGLE_READONLY_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly", "https://www.googleapis.com/auth/analytics.readonly"];
465
+ const GOOGLE_READONLY_SCOPE_LABELS = {
466
+ "https://www.googleapis.com/auth/webmasters.readonly": "Search Console",
467
+ "https://www.googleapis.com/auth/analytics.readonly": "Google Analytics"
468
+ };
469
+ function missingGoogleReadonlyScopes(scope) {
470
+ const granted = new Set(scope.split(/\s+/u).filter(Boolean));
471
+ return GOOGLE_READONLY_SCOPES.filter((required) => !granted.has(required));
472
+ }
463
473
  [
464
474
  ...GOOGLE_READONLY_SCOPES,
465
475
  "openid",
@@ -657,6 +667,11 @@ function authRequired() {
657
667
  function authExpired() {
658
668
  return new SeoError("AUTH_EXPIRED", "Google refresh token is no longer valid. Run `seo auth login` again.");
659
669
  }
670
+ function requireReadonlyScopes(tokens) {
671
+ const missing = missingGoogleReadonlyScopes(tokens.scope);
672
+ if (missing.length === 0) return;
673
+ throw new SeoError("ACCESS_DENIED", `Google access is missing ${missing.map((scope) => GOOGLE_READONLY_SCOPE_LABELS[scope]).join(" and ")}. Run \`seo auth login\` again, choose Select all, and approve both permission boxes.`);
674
+ }
660
675
  function needsRefresh(tokens, now) {
661
676
  return !tokens.access_token || tokens.expires_at - now < REFRESH_BUFFER_MS;
662
677
  }
@@ -716,6 +731,7 @@ async function createAuthorizedClient() {
716
731
  let tokens = await readTokens();
717
732
  if (!tokens) throw authRequired();
718
733
  requireClientConfig(tokens);
734
+ requireReadonlyScopes(tokens);
719
735
  if (needsRefresh(tokens, Date.now())) tokens = await refreshStoredToken({ onlyIfExpiring: true });
720
736
  return {
721
737
  client: new LocalGoogleAccessTokenClient(tokens),
@@ -1068,8 +1084,8 @@ async function runGa4Report(propertyId, body, opts = {}) {
1068
1084
  body: JSON.stringify(body)
1069
1085
  });
1070
1086
  if (!response.ok) {
1071
- if (response.status === 403) throw new Error(`GA4 report failed with 403. Check access to property ${propertyId}.`);
1072
- throw new Error(`GA4 report failed with ${response.status}.`);
1087
+ if (response.status === 403) throw new Error(`Google Analytics report failed with 403. Check access to property ${propertyId}.`);
1088
+ throw new Error(`Google Analytics report failed with ${response.status}.`);
1073
1089
  }
1074
1090
  const result = await response.json();
1075
1091
  if (cacheable) db.prepare(`INSERT OR REPLACE INTO ga4_cache
@@ -1091,7 +1107,7 @@ function ga4RowsToObjects(result) {
1091
1107
  return output;
1092
1108
  });
1093
1109
  }
1094
- function ga4ReportQualityWarnings(result, label = "GA4 report") {
1110
+ function ga4ReportQualityWarnings(result, label = "Google Analytics report") {
1095
1111
  const warnings = [];
1096
1112
  if (result.metadata?.dataLossFromOtherRow) warnings.push(`${label} grouped high-cardinality data into (other).`);
1097
1113
  if (result.metadata?.subjectToThresholding) warnings.push(`${label} was subject to Google Analytics thresholding.`);
@@ -1197,11 +1213,11 @@ function compareText$16(left, right) {
1197
1213
  function metric(row, name) {
1198
1214
  const value = row[name];
1199
1215
  const parsed = Number(value);
1200
- if (value === void 0 || value.trim() === "" || !Number.isInteger(parsed) || parsed < 0) throw new SeoError("PROVIDER_UNAVAILABLE", `GA4 returned an invalid ${name} value.`);
1216
+ if (value === void 0 || value.trim() === "" || !Number.isInteger(parsed) || parsed < 0) throw new SeoError("PROVIDER_UNAVAILABLE", `Google Analytics returned an invalid ${name} value.`);
1201
1217
  return parsed;
1202
1218
  }
1203
1219
  function dateValue$1(value) {
1204
- if (!value || !/^\d{8}$/.test(value)) throw new SeoError("PROVIDER_UNAVAILABLE", "GA4 returned an invalid date value.");
1220
+ if (!value || !/^\d{8}$/.test(value)) throw new SeoError("PROVIDER_UNAVAILABLE", "Google Analytics returned an invalid date value.");
1205
1221
  return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
1206
1222
  }
1207
1223
  function addMetrics(target, source) {
@@ -1240,7 +1256,7 @@ function analyzeAiReferralRows(input) {
1240
1256
  const detailTotal = emptyMetrics();
1241
1257
  for (const row of input.detailRows) {
1242
1258
  const definition = aiReferralSourceForValue(row.sessionSource ?? "");
1243
- if (!definition || !sourceTotals.has(definition.id)) throw new SeoError("PROVIDER_UNAVAILABLE", "GA4 detail rows contained an unexpected session source.");
1259
+ if (!definition || !sourceTotals.has(definition.id)) throw new SeoError("PROVIDER_UNAVAILABLE", "Google Analytics detail rows contained an unexpected session source.");
1244
1260
  const metrics = {
1245
1261
  sessions: metric(row, "sessions"),
1246
1262
  eventCount: metric(row, "eventCount")
@@ -1273,11 +1289,11 @@ function analyzeAiReferralRows(input) {
1273
1289
  ...evidenceReasons("Source discovery", input.sourceDiscovery),
1274
1290
  ...evidenceReasons("Landing-page detail", input.detail),
1275
1291
  ...evidenceReasons("Total users", input.totalUsersEvidence),
1276
- ...detailMismatch ? ["GA4 detail totals differ from source-discovery totals; landing-page and daily breakdowns are incomplete."] : [],
1277
- ...timeZones.size > 1 ? ["GA4 queries returned inconsistent property time zones."] : []
1292
+ ...detailMismatch ? ["Google Analytics detail totals differ from source-discovery totals; landing-page and daily breakdowns are incomplete."] : [],
1293
+ ...timeZones.size > 1 ? ["Google Analytics queries returned inconsistent property time zones."] : []
1278
1294
  ];
1279
1295
  const dataStatus = partialReasons.length ? "partial" : "complete";
1280
- const generalCaveat = "GA4 only attributes visits that arrive with a recognized session source; some AI visits appear as direct, unassigned, or another source.";
1296
+ const generalCaveat = "Google Analytics only attributes visits that arrive with a recognized session source; some AI visits appear as direct, unassigned, or another source.";
1281
1297
  const possiblyTruncated = input.sourceDiscovery.truncated || input.detail.truncated || input.totalUsersEvidence.truncated;
1282
1298
  const allLandingPages = [...pageTotals.entries()].map(([landingPage, metrics]) => {
1283
1299
  const topSourceId = [...metrics.sources.entries()].sort((left, right) => right[1] - left[1] || compareText$16(left[0], right[0]))[0]?.[0];
@@ -1309,13 +1325,13 @@ function analyzeAiReferralRows(input) {
1309
1325
  dataStatus,
1310
1326
  range: input.range,
1311
1327
  methodology: {
1312
- id: "ga4-ai-referrals",
1328
+ id: "google-analytics-ai-referrals",
1313
1329
  version: 2,
1314
1330
  attributionDimension: "sessionSource",
1315
1331
  sourceRulesVersion: AI_REFERRAL_SOURCES_VERSION
1316
1332
  },
1317
1333
  dataSource: {
1318
- provider: "google-analytics-4",
1334
+ provider: "google-analytics",
1319
1335
  api: "analyticsdata.v1beta.runReport",
1320
1336
  maxRows: input.maxRows,
1321
1337
  calls: input.sourceDiscovery.calls + input.detail.calls + input.totalUsersEvidence.calls,
@@ -1332,7 +1348,7 @@ function analyzeAiReferralRows(input) {
1332
1348
  totalUsersStatus: input.totalUsers === null ? "not-reported" : "available",
1333
1349
  sources: sourceTotals.size,
1334
1350
  landingPages: pageTotals.size,
1335
- verdict: total.sessions ? dataStatus === "complete" ? "Detected AI referral traffic in GA4." : "Detected AI referral traffic; partial counts and breakdowns are lower bounds." : dataStatus === "complete" ? "No sessions attributed to the known AI session-source rules were detected." : "No AI-attributed sessions were found in retained GA4 data; this partial report cannot establish absence.",
1351
+ verdict: total.sessions ? dataStatus === "complete" ? "Detected AI referral traffic in Google Analytics." : "Detected AI referral traffic; partial counts and breakdowns are lower bounds." : dataStatus === "complete" ? "No sessions attributed to the known AI session-source rules were detected." : "No AI-attributed sessions were found in retained Google Analytics data; this partial report cannot establish absence.",
1336
1352
  caveat: generalCaveat
1337
1353
  },
1338
1354
  sources: [...sourceTotals.values()].map((source) => ({
@@ -1354,7 +1370,7 @@ function analyzeAiReferralRows(input) {
1354
1370
  })).sort((left, right) => compareText$16(left.date, right.date)),
1355
1371
  caveats: [
1356
1372
  generalCaveat,
1357
- "Attribution uses GA4 sessionSource only; landing-page text, medium text, and event referrers are never treated as referral sources.",
1373
+ "Attribution uses Google Analytics sessionSource only; landing-page text, medium text, and event referrers are never treated as referral sources.",
1358
1374
  "Source totals come from a low-cardinality source query; landing-page and daily detail is queried separately for the exact observed source values.",
1359
1375
  "Total users come from a separate filtered aggregate when AI sources are observed and are never summed across report rows; complete zero-session evidence implies zero users without another query.",
1360
1376
  ...selection.omittedRows > 0 ? [`Landing-page output returns the ${selection.returnedRows} highest-session pages from ${selection.retainedRows} retained detail rows; ${selection.omittedRows} lower-ranked pages are omitted. Increase resultLimit to inspect more.`] : [],
@@ -1456,7 +1472,7 @@ function reportRange(input) {
1456
1472
  const endDate = input.endDate ?? "yesterday";
1457
1473
  const absolute = validIsoDate$1(startDate) && validIsoDate$1(endDate);
1458
1474
  const relative = validRelativeDate(startDate) && validRelativeDate(endDate);
1459
- if (!absolute && !relative) throw new SeoError("INVALID_INPUT", "startDate and endDate must both be YYYY-MM-DD dates or GA4 relative dates such as 28daysAgo and yesterday.");
1475
+ if (!absolute && !relative) throw new SeoError("INVALID_INPUT", "startDate and endDate must both be YYYY-MM-DD dates or Google Analytics relative dates such as 28daysAgo and yesterday.");
1460
1476
  if (absolute && startDate > endDate) throw new SeoError("INVALID_INPUT", "startDate must be on or before endDate.");
1461
1477
  return {
1462
1478
  startDate,
@@ -1515,7 +1531,7 @@ async function aiReferralsReport(input, dependencies = {}) {
1515
1531
  const property = input.property.trim();
1516
1532
  if (!property) throw new SeoError("INVALID_INPUT", "property is required.");
1517
1533
  const range = reportRange(input);
1518
- const ga4Range = {
1534
+ const googleAnalyticsDateRange = {
1519
1535
  startDate: range.startDate,
1520
1536
  endDate: range.endDate
1521
1537
  };
@@ -1525,7 +1541,7 @@ async function aiReferralsReport(input, dependencies = {}) {
1525
1541
  const sourceResult = await fetchAiReferralRows({
1526
1542
  property,
1527
1543
  request: {
1528
- dateRanges: [ga4Range],
1544
+ dateRanges: [googleAnalyticsDateRange],
1529
1545
  dimensions: [{ name: "sessionSource" }],
1530
1546
  metrics: [{ name: "sessions" }, { name: "eventCount" }],
1531
1547
  orderBys: [{
@@ -1550,7 +1566,7 @@ async function aiReferralsReport(input, dependencies = {}) {
1550
1566
  detailResult = await fetchAiReferralRows({
1551
1567
  property,
1552
1568
  request: {
1553
- dateRanges: [ga4Range],
1569
+ dateRanges: [googleAnalyticsDateRange],
1554
1570
  dimensions: [
1555
1571
  { name: "date" },
1556
1572
  { name: "sessionSource" },
@@ -1586,7 +1602,7 @@ async function aiReferralsReport(input, dependencies = {}) {
1586
1602
  usersResult = await fetchAiReferralRows({
1587
1603
  property,
1588
1604
  request: {
1589
- dateRanges: [ga4Range],
1605
+ dateRanges: [googleAnalyticsDateRange],
1590
1606
  metrics: [{ name: "totalUsers" }],
1591
1607
  dimensionFilter
1592
1608
  },
@@ -9414,7 +9430,7 @@ function crawlDefinitionId(input) {
9414
9430
  const normalized = {
9415
9431
  config: normalizeCrawlConfig(input.config),
9416
9432
  site: input.site ?? null,
9417
- ga4PropertyId: input.ga4PropertyId ?? null
9433
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId ?? null
9418
9434
  };
9419
9435
  return `crawl_def_${createHash("sha256").update(JSON.stringify(normalized)).digest("hex").slice(0, 20)}`;
9420
9436
  }
@@ -9617,7 +9633,7 @@ function createCrawlReport(input) {
9617
9633
  const definitionId = crawlDefinitionId({
9618
9634
  config,
9619
9635
  site: input.site,
9620
- ga4PropertyId: input.ga4PropertyId
9636
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId
9621
9637
  });
9622
9638
  const safePagesWithLinks = sortPages(sanitizePages(deriveInternalLinkAuthority(input.pages ?? [], input.linkGraph)));
9623
9639
  const requests = sortRequests(sanitizeTenantValue(input.requests ?? []));
@@ -9630,7 +9646,7 @@ function createCrawlReport(input) {
9630
9646
  definitionId,
9631
9647
  projectId: input.projectId,
9632
9648
  site: input.site,
9633
- ga4PropertyId: input.ga4PropertyId,
9649
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
9634
9650
  generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
9635
9651
  status: input.status ?? "completed",
9636
9652
  configHash: crawlConfigHash(config),
@@ -9668,7 +9684,7 @@ function normalizeLoadedCrawlReport(report) {
9668
9684
  definitionId: report.definitionId ?? crawlDefinitionId({
9669
9685
  config: report.config,
9670
9686
  site: report.site,
9671
- ga4PropertyId: report.ga4PropertyId
9687
+ googleAnalyticsPropertyId: report.googleAnalyticsPropertyId
9672
9688
  }),
9673
9689
  summary: summarizeCrawlReport({
9674
9690
  pages,
@@ -10074,7 +10090,7 @@ async function joinAnalytics(input) {
10074
10090
  limit: input.limit
10075
10091
  });
10076
10092
  } catch (error) {
10077
- const warning = `GA4 metrics unavailable: ${error instanceof Error ? error.message : String(error)}`;
10093
+ const warning = `Google Analytics metrics unavailable: ${error instanceof Error ? error.message : String(error)}`;
10078
10094
  input.warnings.push(warning);
10079
10095
  return {
10080
10096
  status: "unavailable",
@@ -10087,7 +10103,7 @@ async function joinAnalytics(input) {
10087
10103
  };
10088
10104
  }
10089
10105
  if (analytics.warning) {
10090
- const warning = `GA4 metrics unavailable: ${analytics.warning}`;
10106
+ const warning = `Google Analytics metrics unavailable: ${analytics.warning}`;
10091
10107
  input.warnings.push(warning);
10092
10108
  return {
10093
10109
  status: "unavailable",
@@ -10112,7 +10128,7 @@ async function joinAnalytics(input) {
10112
10128
  const sourceQualityKnown = analytics.source?.dataStatus !== void 0;
10113
10129
  const retainedRowLimitReached = analytics.source?.retainedRowLimitReached;
10114
10130
  const qualityWarnings = analytics.source?.qualityWarnings ?? [];
10115
- const warning = !sourceQualityKnown ? `GA4 metrics joined for ${joinedPages} of ${input.pages.length} crawled pages, but the provider did not expose row completeness; missing page metrics are not reliable zero-traffic evidence.` : retainedRowLimitReached ? `GA4 retained-row limit reached; missing page metrics are not reliable zero-traffic evidence.` : qualityWarnings.length ? `${qualityWarnings.join(" ")} Missing page metrics and relative analytics value are partial evidence.` : input.pages.length && joinedPages === 0 ? "GA4 metrics joined for 0 crawled pages." : void 0;
10131
+ const warning = !sourceQualityKnown ? `Google Analytics metrics joined for ${joinedPages} of ${input.pages.length} crawled pages, but the provider did not expose row completeness; missing page metrics are not reliable zero-traffic evidence.` : retainedRowLimitReached ? `Google Analytics retained-row limit reached; missing page metrics are not reliable zero-traffic evidence.` : qualityWarnings.length ? `${qualityWarnings.join(" ")} Missing page metrics and relative analytics value are partial evidence.` : input.pages.length && joinedPages === 0 ? "Google Analytics metrics joined for 0 crawled pages." : void 0;
10116
10132
  if (warning) input.warnings.push(warning);
10117
10133
  return {
10118
10134
  status: !sourceQualityKnown || analytics.source?.dataStatus === "partial" ? "partial" : joinedPages ? "joined" : "none",
@@ -10272,7 +10288,7 @@ async function crawlDataSources(input) {
10272
10288
  joinedPages: 0,
10273
10289
  retainedRowLimit: input.analyticsLimit,
10274
10290
  retainedRowLimitReached: false,
10275
- warning: input.cancelled ? "GA4 join skipped because the crawl was cancelled." : "GA4 join skipped because no property was selected."
10291
+ warning: input.cancelled ? "Google Analytics join skipped because the crawl was cancelled." : "Google Analytics join skipped because no property was selected."
10276
10292
  }
10277
10293
  };
10278
10294
  if (!input.cancelled && input.site) dataSources.searchConsole = await joinSearchMetrics({
@@ -10288,8 +10304,8 @@ async function crawlDataSources(input) {
10288
10304
  queryPagesMetricsBatch: input.queryPagesMetricsBatch,
10289
10305
  queryPagesTopQueriesBatch: input.queryPagesTopQueriesBatch
10290
10306
  });
10291
- if (!input.cancelled && input.ga4PropertyId) dataSources.analytics = await joinAnalytics({
10292
- propertyId: input.ga4PropertyId,
10307
+ if (!input.cancelled && input.googleAnalyticsPropertyId) dataSources.analytics = await joinAnalytics({
10308
+ propertyId: input.googleAnalyticsPropertyId,
10293
10309
  pages: input.pages,
10294
10310
  warnings: input.warnings,
10295
10311
  limit: input.analyticsLimit,
@@ -11111,11 +11127,11 @@ function landingPageRankingPolicy(input) {
11111
11127
  };
11112
11128
  const warningSuffix = "Observed landing-page values remain visible but do not affect priority scores.";
11113
11129
  const warnings = [
11114
- ...input.warning ? [`GA4: ${input.warning}`] : [],
11115
- ...input.source?.retainedRowLimitReached ? [`GA4: the retained-row limit was reached. ${warningSuffix}`] : [],
11130
+ ...input.warning ? [`Google Analytics: ${input.warning}`] : [],
11131
+ ...input.source?.retainedRowLimitReached ? [`Google Analytics: the retained-row limit was reached. ${warningSuffix}`] : [],
11116
11132
  ...input.source?.qualityWarnings?.map((warning) => `${warning} ${warningSuffix}`) ?? []
11117
11133
  ];
11118
- if (!input.source?.dataStatus && !input.warning) warnings.push(`GA4: landing-page completeness was not reported. ${warningSuffix}`);
11134
+ if (!input.source?.dataStatus && !input.warning) warnings.push(`Google Analytics: landing-page completeness was not reported. ${warningSuffix}`);
11119
11135
  return {
11120
11136
  canRank: !input.warning && warnings.length === 0 && landingPageValuesCanRank(input.source),
11121
11137
  warnings
@@ -11155,7 +11171,7 @@ async function fetchLandingPageValues(input, dependencies = defaultDependencies$
11155
11171
  const values = landingPageValuesFromRows(rows);
11156
11172
  const availableRows = result.rowCount;
11157
11173
  const retainedRowLimitReached = availableRows !== void 0 && availableRows > rows.length || rows.length >= retainedRowLimit;
11158
- const qualityWarnings = ga4ReportQualityWarnings(result, "GA4 landing-page report");
11174
+ const qualityWarnings = ga4ReportQualityWarnings(result, "Google Analytics landing-page report");
11159
11175
  return {
11160
11176
  values,
11161
11177
  source: {
@@ -11909,7 +11925,7 @@ async function crawlSite(input, dependencies) {
11909
11925
  const dataSources = await crawlDataSources({
11910
11926
  cancelled,
11911
11927
  site,
11912
- ga4PropertyId: input.ga4PropertyId,
11928
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
11913
11929
  pages,
11914
11930
  warnings,
11915
11931
  searchMetricsLimit,
@@ -11964,7 +11980,7 @@ async function crawlSite(input, dependencies) {
11964
11980
  config,
11965
11981
  projectId: input.projectId,
11966
11982
  site,
11967
- ga4PropertyId: input.ga4PropertyId,
11983
+ googleAnalyticsPropertyId: input.googleAnalyticsPropertyId,
11968
11984
  dataSources,
11969
11985
  pages,
11970
11986
  requests,
@@ -12107,7 +12123,7 @@ function searchValueForGroup(report, urls) {
12107
12123
  };
12108
12124
  }
12109
12125
  function whyThisRanks(input, recommendation, searchStatus) {
12110
- return `${input.searchVisibleUrls ? `${input.searchVisibleUrls} affected URLs have GSC visibility (${input.clicks} clicks, ${input.impressions} impressions).` : searchStatus && !["joined", "none"].includes(searchStatus) ? `No joined GSC visibility is available for these affected URLs; Search Console evidence is ${searchStatus}.` : "No affected URL has joined GSC visibility yet."}${input.sessions ? ` GA4 adds ${input.sessions} sessions and ${input.conversions} conversions from affected landing pages.` : ""} ${recommendation === "review" ? "This is a review observation. Confirm it before scheduling implementation work." : `This ranks as a fix. Severity is ${input.severity}; ${input.affectedUrls} URL${input.affectedUrls === 1 ? " is" : "s are"} affected; estimated effort is ${input.effort}.`}`;
12126
+ return `${input.searchVisibleUrls ? `${input.searchVisibleUrls} affected URLs have GSC visibility (${input.clicks} clicks, ${input.impressions} impressions).` : searchStatus && !["joined", "none"].includes(searchStatus) ? `No joined GSC visibility is available for these affected URLs; Search Console evidence is ${searchStatus}.` : "No affected URL has joined GSC visibility yet."}${input.sessions ? ` Google Analytics adds ${input.sessions} sessions and ${input.conversions} conversions from affected landing pages.` : ""} ${recommendation === "review" ? "This is a review observation. Confirm it before scheduling implementation work." : `This ranks as a fix. Severity is ${input.severity}; ${input.affectedUrls} URL${input.affectedUrls === 1 ? " is" : "s are"} affected; estimated effort is ${input.effort}.`}`;
12111
12127
  }
12112
12128
  function verificationCommand(report, severity) {
12113
12129
  return `seo crawl ${report.config.url} --severity ${severity} --max-pages ${Math.min(report.config.maxPages, 100)}`;
@@ -12223,7 +12239,7 @@ function snapshotInput(report) {
12223
12239
  generatedAt: report.generatedAt,
12224
12240
  projectId: report.projectId,
12225
12241
  site: report.site,
12226
- ga4PropertyId: report.ga4PropertyId,
12242
+ googleAnalyticsPropertyId: report.googleAnalyticsPropertyId,
12227
12243
  url: report.config.url,
12228
12244
  status: report.status,
12229
12245
  configHash: report.configHash,
@@ -13861,7 +13877,7 @@ const crawlReportBaseSchema = z.object({
13861
13877
  definitionId: z.string(),
13862
13878
  projectId: z.string().optional(),
13863
13879
  site: z.string().optional(),
13864
- ga4PropertyId: z.string().optional(),
13880
+ googleAnalyticsPropertyId: z.string().optional(),
13865
13881
  generatedAt: z.string().datetime(),
13866
13882
  status: z.enum([
13867
13883
  "completed",
@@ -17727,7 +17743,7 @@ function stringMatchType(matchType) {
17727
17743
  if (matchType === "equals") return "EXACT";
17728
17744
  return "CONTAINS";
17729
17745
  }
17730
- function ga4LandingPageFilterForChange(change, group) {
17746
+ function googleAnalyticsLandingPageFilterForChange(change, group) {
17731
17747
  if (change.scope === "site") return void 0;
17732
17748
  if (change.scope === "query") return void 0;
17733
17749
  if (change.scope === "group") {
@@ -17750,7 +17766,7 @@ function ga4LandingPageFilterForChange(change, group) {
17750
17766
  }
17751
17767
  } };
17752
17768
  }
17753
- function summarizeGa4Rows(rows) {
17769
+ function summarizeGoogleAnalyticsRows(rows) {
17754
17770
  return rows.reduce((sum, row) => ({
17755
17771
  sessions: fixed(sum.sessions + (Number(row.sessions) || 0)),
17756
17772
  engagedSessions: fixed(sum.engagedSessions + (Number(row.engagedSessions) || 0)),
@@ -17817,8 +17833,8 @@ function classify(input) {
17817
17833
  }
17818
17834
  //#endregion
17819
17835
  //#region packages/core/dist/analyze/experiments/measurement/ga4-change-provider.js
17820
- async function queryGa4Metrics(input) {
17821
- const result = await input.ga4Report(input.propertyId, {
17836
+ async function queryGoogleAnalyticsMetrics(input) {
17837
+ const result = await input.googleAnalyticsReport(input.propertyId, {
17822
17838
  dateRanges: [input.window],
17823
17839
  dimensions: [{ name: "date" }],
17824
17840
  metrics: [
@@ -17832,7 +17848,7 @@ async function queryGa4Metrics(input) {
17832
17848
  }, { refresh: input.refresh });
17833
17849
  const rows = ga4RowsToObjects(result);
17834
17850
  return {
17835
- metrics: summarizeGa4Rows(rows),
17851
+ metrics: summarizeGoogleAnalyticsRows(rows),
17836
17852
  source: {
17837
17853
  rows: rows.length,
17838
17854
  rowCount: result.rowCount ?? rows.length,
@@ -17842,19 +17858,19 @@ async function queryGa4Metrics(input) {
17842
17858
  warnings: ga4ReportQualityWarnings(result, input.label)
17843
17859
  };
17844
17860
  }
17845
- async function queryGa4ChangeWindows(input) {
17846
- const [before, after] = await Promise.all([queryGa4Metrics({
17861
+ async function queryGoogleAnalyticsChangeWindows(input) {
17862
+ const [before, after] = await Promise.all([queryGoogleAnalyticsMetrics({
17847
17863
  ...input,
17848
17864
  window: input.before,
17849
- label: "GA4 before window"
17850
- }), queryGa4Metrics({
17865
+ label: "Google Analytics before window"
17866
+ }), queryGoogleAnalyticsMetrics({
17851
17867
  ...input,
17852
17868
  window: input.after,
17853
- label: "GA4 after window"
17869
+ label: "Google Analytics after window"
17854
17870
  })]);
17855
17871
  const metadataWarnings = [];
17856
- if (before.source.timeZone && after.source.timeZone && before.source.timeZone !== after.source.timeZone) metadataWarnings.push("GA4 returned different property timezones across the two windows.");
17857
- if (before.source.currencyCode && after.source.currencyCode && before.source.currencyCode !== after.source.currencyCode) metadataWarnings.push("GA4 returned different currency codes across the two windows.");
17872
+ if (before.source.timeZone && after.source.timeZone && before.source.timeZone !== after.source.timeZone) metadataWarnings.push("Google Analytics returned different property timezones across the two windows.");
17873
+ if (before.source.currencyCode && after.source.currencyCode && before.source.currencyCode !== after.source.currencyCode) metadataWarnings.push("Google Analytics returned different currency codes across the two windows.");
17858
17874
  return {
17859
17875
  report: {
17860
17876
  propertyId: input.propertyId,
@@ -17870,7 +17886,7 @@ async function queryGa4ChangeWindows(input) {
17870
17886
  before: before.metrics,
17871
17887
  after: after.metrics
17872
17888
  }),
17873
- note: "GA4 attribution is landing-page based. Query-level tests use GSC only."
17889
+ note: "Google Analytics attribution is landing-page based. Query-level tests use GSC only."
17874
17890
  },
17875
17891
  source: {
17876
17892
  before: before.source,
@@ -18108,7 +18124,7 @@ function measurementWindow(input) {
18108
18124
  //#region packages/core/dist/analyze/experiments/measure-change.js
18109
18125
  const defaultDependencies$5 = {
18110
18126
  searchAnalytics: querySearchAnalytics,
18111
- ga4Report: runGa4Report,
18127
+ googleAnalyticsReport: runGa4Report,
18112
18128
  contentGroup: getContentGroup,
18113
18129
  now: () => /* @__PURE__ */ new Date()
18114
18130
  };
@@ -18194,15 +18210,15 @@ async function measureChange(input, dependencies = defaultDependencies$5) {
18194
18210
  confidence: "medium",
18195
18211
  note: `${classified.note} Confidence is capped because Search Console evidence is partial.`
18196
18212
  } : classified;
18197
- const ga4Filter = ga4LandingPageFilterForChange(change, group);
18213
+ const googleAnalyticsFilter = googleAnalyticsLandingPageFilterForChange(change, group);
18198
18214
  const queryDimension = change.scope === "query" || group?.dimension === "query";
18199
- const analytics = input.ga4PropertyId && !queryDimension ? await queryGa4ChangeWindows({
18200
- propertyId: input.ga4PropertyId,
18215
+ const analytics = input.googleAnalyticsPropertyId && !queryDimension ? await queryGoogleAnalyticsChangeWindows({
18216
+ propertyId: input.googleAnalyticsPropertyId,
18201
18217
  before,
18202
18218
  after,
18203
- filter: ga4Filter,
18219
+ filter: googleAnalyticsFilter,
18204
18220
  refresh: input.refresh,
18205
- ga4Report: dependencies.ga4Report
18221
+ googleAnalyticsReport: dependencies.googleAnalyticsReport
18206
18222
  }) : void 0;
18207
18223
  const warnings = [
18208
18224
  ...measured.warnings,
@@ -18281,7 +18297,7 @@ async function measureChange(input, dependencies = defaultDependencies$5) {
18281
18297
  ...window.afterWindowTruncated ? [`The requested ${window.requestedDays}-day after window is incomplete, so this run is partial and has no directional verdict.`] : [],
18282
18298
  ...shortWindow ? ["Fewer than 7 finalized days per window is too short for a directional verdict."] : [],
18283
18299
  ...measured.completeness === "retained-query-date-aggregates" ? ["Query-scoped Search Console data can omit anonymized queries; an absent retained row is not zero traffic."] : [],
18284
- ...analytics ? [`GA4 attribution uses landing pages and the GA4 property timezone${analytics.source.before.timeZone ? ` (${analytics.source.before.timeZone})` : ""}; its day boundaries may differ from Search Console Pacific dates.`] : []
18300
+ ...analytics ? [`Google Analytics attribution uses landing pages and the Google Analytics property timezone${analytics.source.before.timeZone ? ` (${analytics.source.before.timeZone})` : ""}; its day boundaries may differ from Search Console Pacific dates.`] : []
18285
18301
  ]
18286
18302
  };
18287
18303
  }
@@ -23866,7 +23882,7 @@ function reportCaveats(input) {
23866
23882
  `Brand queries: ${input.includeBrand ? "included" : input.brandTerms?.length ? "excluded where saved brand terms matched" : "no saved brand terms, so no brand filter was applied"}.`,
23867
23883
  `Data freshness: ${input.refresh ? "fresh fetch requested; local cache bypassed where supported" : "local cache allowed; rerun with --refresh to bypass cached GSC/HTTP data"}.`,
23868
23884
  input.skipSearchData ? "Search Console: skipped because no property was selected. Run `seo start` to add performance evidence." : "Search Console: included where the selected property returned usable data.",
23869
- "GA4: not included in this narrative; use refresh-priorities when GA4 value should influence prioritisation.",
23885
+ "Google Analytics: not included in this narrative; use refresh-priorities when Google Analytics value should influence prioritisation.",
23870
23886
  input.skipSearchData ? "Content verification: not run because Search Console did not provide page candidates for this report." : input.verifyContent ? `Content verification: checked ${input.verified} of ${countLabel(input.quickWinCount, "quick-win candidate")}, limit ${input.verifyLimit ?? 3}.` : "Content verification: not run; recommendations are based on GSC/query data unless stated otherwise."
23871
23887
  ];
23872
23888
  }
@@ -24988,12 +25004,12 @@ async function refreshPrioritiesWorkflow(input) {
24988
25004
  });
24989
25005
  const range = defaultDateRange$1(input.days ?? 28);
24990
25006
  const analytics = await fetchLandingPageValues({
24991
- propertyId: input.ga4PropertyId,
25007
+ propertyId: input.googleAnalyticsPropertyId,
24992
25008
  startDate: range.startDate,
24993
25009
  endDate: range.endDate
24994
25010
  });
24995
25011
  const analyticsPolicy = landingPageRankingPolicy({
24996
- propertyId: input.ga4PropertyId,
25012
+ propertyId: input.googleAnalyticsPropertyId,
24997
25013
  source: analytics.source,
24998
25014
  warning: analytics.warning
24999
25015
  });
@@ -25590,7 +25606,7 @@ async function runDoctor() {
25590
25606
  const grantedScopes = new Set(tokens?.scope.split(/\s+/).filter(Boolean) ?? []);
25591
25607
  for (const scope of REQUIRED_SCOPES) checks.push({
25592
25608
  id: `scope:${scope}`,
25593
- label: scope.endsWith("webmasters.readonly") ? "GSC scope" : "GA4 scope",
25609
+ label: scope.endsWith("webmasters.readonly") ? "Search Console scope" : "Google Analytics scope",
25594
25610
  status: serviceAccount.configured || tokens && grantedScopes.has(scope) ? "pass" : tokens ? "fail" : "warn",
25595
25611
  detail: serviceAccount.configured ? `${scope} requested by the service account.` : tokens ? grantedScopes.has(scope) ? scope : `Missing ${scope}.` : "Cannot inspect scopes until login is complete.",
25596
25612
  fix: !serviceAccount.configured && tokens && !grantedScopes.has(scope) ? "Run `seo auth logout` then `seo auth login`." : void 0
@@ -25602,10 +25618,10 @@ async function runDoctor() {
25602
25618
  detail: config.defaultSite ?? "No saved default. Human CLI commands will prompt; agents can pass --site."
25603
25619
  });
25604
25620
  checks.push({
25605
- id: "default-ga4",
25606
- label: "Saved GA4 property",
25621
+ id: "default-google-analytics",
25622
+ label: "Saved Google Analytics property",
25607
25623
  status: "pass",
25608
- detail: config.google.defaultGa4PropertyId ?? "No saved default. Human CLI commands will prompt; agents can pass --property."
25624
+ detail: config.analytics.google.defaultPropertyId ?? "No saved default. Human CLI commands will prompt; agents can pass --property."
25609
25625
  });
25610
25626
  return {
25611
25627
  ok: checks.every((check) => check.status !== "fail"),
@@ -25867,6 +25883,317 @@ new LocalCredentialsProvider();
25867
25883
  //#endregion
25868
25884
  //#region packages/core/dist/version.js
25869
25885
  const SEO_VERSION = version;
25886
+ const TELEMETRY_TIMEOUT_MS = 2e3;
25887
+ const TELEMETRY_REPORTS = [
25888
+ "affected-urls",
25889
+ "agent-readiness",
25890
+ "ai-readiness",
25891
+ "ai-referrals",
25892
+ "ai-search-scorecard",
25893
+ "audit-page",
25894
+ "audit-urls",
25895
+ "cannibalisation",
25896
+ "community-intent",
25897
+ "compare-crawls",
25898
+ "content-optimization",
25899
+ "crawl-diff",
25900
+ "site-crawl",
25901
+ "ctr-underperformers",
25902
+ "decaying-pages",
25903
+ "setup-check",
25904
+ "entity-readiness",
25905
+ "explain-crawl-issue",
25906
+ "geo-gaps",
25907
+ "crawl-report",
25908
+ "index-coverage",
25909
+ "index-coverage-plan",
25910
+ "index-monitor",
25911
+ "index-watch",
25912
+ "internal-links",
25913
+ "link-recovery",
25914
+ "crawl-history",
25915
+ "crawler-rules",
25916
+ "llms-txt-audit",
25917
+ "generate-llms-txt",
25918
+ "measure-change",
25919
+ "monthly-report",
25920
+ "okf-build",
25921
+ "okf-validate",
25922
+ "page-opportunities",
25923
+ "performance-audit",
25924
+ "pseo-audit",
25925
+ "query-clusters",
25926
+ "quick-wins",
25927
+ "redirect-trace",
25928
+ "narrative-report",
25929
+ "second-page",
25930
+ "segment-impact",
25931
+ "striking-distance",
25932
+ "seo-to-ai-query",
25933
+ "top-fixes",
25934
+ "traffic-anomaly",
25935
+ "update-correlation",
25936
+ "search-performance-overview",
25937
+ "monthly-action-plan",
25938
+ "refresh-priorities",
25939
+ "technical-watch",
25940
+ "update-postmortem"
25941
+ ];
25942
+ const RETENTION_MILESTONES = [
25943
+ {
25944
+ days: 1,
25945
+ event: "active_d1"
25946
+ },
25947
+ {
25948
+ days: 7,
25949
+ event: "active_d7"
25950
+ },
25951
+ {
25952
+ days: 30,
25953
+ event: "active_d30"
25954
+ }
25955
+ ];
25956
+ const CI_ENVIRONMENT_KEYS = [
25957
+ "GITHUB_ACTIONS",
25958
+ "GITLAB_CI",
25959
+ "CIRCLECI",
25960
+ "TRAVIS",
25961
+ "BUILDKITE",
25962
+ "JENKINS_URL",
25963
+ "TEAMCITY_VERSION",
25964
+ "TF_BUILD",
25965
+ "BITBUCKET_BUILD_NUMBER",
25966
+ "CODEBUILD_BUILD_ID"
25967
+ ];
25968
+ const TELEMETRY_NOTICE = `SEO Skill collects anonymous usage data to improve the tool (event name,
25969
+ report id, version, agent, OS, architecture, Node major, and install week;
25970
+ never URLs, report data, or identifiers of any kind).
25971
+ Docs: https://seoskill.dev/telemetry
25972
+ Disable: seo telemetry disable (or set DO_NOT_TRACK=1)\n`;
25973
+ function optionEnv(options) {
25974
+ return options.env ?? process.env;
25975
+ }
25976
+ function optionNow(options) {
25977
+ return options.now ?? /* @__PURE__ */ new Date();
25978
+ }
25979
+ function optionStateFile(options) {
25980
+ return options.stateFile ?? getSeoCliPaths().telemetryStateFile;
25981
+ }
25982
+ function isEnvEnabled(value) {
25983
+ return value === "1" || value?.toLowerCase() === "true";
25984
+ }
25985
+ function isTelemetryCi(env = process.env) {
25986
+ return env.NODE_ENV === "test" || Boolean(env.NODE_TEST_CONTEXT) || isEnvEnabled(env.CI) || CI_ENVIRONMENT_KEYS.some((key) => Boolean(env[key]));
25987
+ }
25988
+ function environmentDisableReason(env) {
25989
+ if (isTelemetryCi(env)) return "ci";
25990
+ if (isEnvEnabled(env.DO_NOT_TRACK)) return "do_not_track";
25991
+ if (isEnvEnabled(env.SEOSKILL_TELEMETRY_DISABLED) || isEnvEnabled(env.SEO_TELEMETRY_DISABLED)) return "environment";
25992
+ }
25993
+ function isTelemetryMilestone(value) {
25994
+ return typeof value === "string" && [
25995
+ "first_run",
25996
+ "setup_complete",
25997
+ "first_audit_complete",
25998
+ "active_d1",
25999
+ "active_d7",
26000
+ "active_d30"
26001
+ ].includes(value);
26002
+ }
26003
+ function isTelemetryReport(value) {
26004
+ return typeof value === "string" && TELEMETRY_REPORTS.includes(value);
26005
+ }
26006
+ function isValidCohort(value) {
26007
+ return typeof value === "string" && /^\d{4}-W(?:0[1-9]|[1-4]\d|5[0-3])$/.test(value);
26008
+ }
26009
+ function isTelemetryState(value) {
26010
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
26011
+ const state = value;
26012
+ return typeof state.telemetryEnabled === "boolean" && typeof state.firstRunAt === "string" && Number.isFinite(Date.parse(state.firstRunAt)) && isValidCohort(state.cohort) && Array.isArray(state.sentMilestones) && state.sentMilestones.every(isTelemetryMilestone);
26013
+ }
26014
+ function readState(path) {
26015
+ if (!existsSync(path)) return { kind: "missing" };
26016
+ const value = readJsonFile(path);
26017
+ return isTelemetryState(value) ? {
26018
+ kind: "valid",
26019
+ state: value
26020
+ } : { kind: "invalid" };
26021
+ }
26022
+ function writeState(path, state) {
26023
+ writeJsonAtomic(path, state, 384);
26024
+ }
26025
+ function isoWeek(date) {
26026
+ const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
26027
+ const day = utc.getUTCDay() || 7;
26028
+ utc.setUTCDate(utc.getUTCDate() + 4 - day);
26029
+ const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
26030
+ const week = Math.ceil(((utc.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
26031
+ return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
26032
+ }
26033
+ function newState(options, enabled = true) {
26034
+ const now = optionNow(options);
26035
+ return {
26036
+ telemetryEnabled: enabled,
26037
+ firstRunAt: now.toISOString(),
26038
+ cohort: isoWeek(now),
26039
+ sentMilestones: []
26040
+ };
26041
+ }
26042
+ function detectTelemetryAgent(input = {}) {
26043
+ const env = input.env ?? process.env;
26044
+ const clientName = input.clientName?.toLowerCase() ?? "";
26045
+ if (clientName.includes("claude")) return "claude-code";
26046
+ if (clientName.includes("cursor")) return "cursor";
26047
+ if (clientName.includes("codex")) return "codex";
26048
+ if (input.clientName) return "unknown";
26049
+ if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude-code";
26050
+ if (env.CURSOR_AGENT || env.CURSOR_TRACE_ID || env.CURSOR_SESSION_ID) return "cursor";
26051
+ if (env.CODEX_HOME || env.CODEX_THREAD_ID || env.CODEX_SANDBOX) return "codex";
26052
+ return "cli";
26053
+ }
26054
+ function telemetryAgent(options) {
26055
+ return options.agent ?? detectTelemetryAgent({
26056
+ clientName: options.clientName,
26057
+ env: optionEnv(options)
26058
+ });
26059
+ }
26060
+ function nodeMajor(version) {
26061
+ return version.replace(/^v/, "").split(".")[0] || "unknown";
26062
+ }
26063
+ function buildTelemetryPayload(event, state, fields = {}, options = {}) {
26064
+ return {
26065
+ event,
26066
+ version: options.version ?? SEO_VERSION,
26067
+ agent: telemetryAgent(options),
26068
+ os: options.processPlatform ?? platform(),
26069
+ arch: options.processArch ?? arch(),
26070
+ node: nodeMajor(options.nodeVersion ?? process.version),
26071
+ cohort: state.cohort,
26072
+ schema: 1,
26073
+ ...fields.errorCategory ? { errorCategory: fields.errorCategory } : {},
26074
+ ...fields.report ? { report: fields.report } : {}
26075
+ };
26076
+ }
26077
+ function dispatchTelemetry(payload, options) {
26078
+ const fetchTelemetry = options.fetch ?? globalThis.fetch;
26079
+ try {
26080
+ fetchTelemetry(options.endpoint ?? "https://seoskill.dev/api/t", {
26081
+ method: "POST",
26082
+ headers: { "content-type": "application/json" },
26083
+ body: JSON.stringify(payload),
26084
+ signal: AbortSignal.timeout(TELEMETRY_TIMEOUT_MS)
26085
+ }).catch(() => void 0);
26086
+ } catch {}
26087
+ }
26088
+ function statusFromState(options) {
26089
+ const stateFile = optionStateFile(options);
26090
+ const envReason = environmentDisableReason(optionEnv(options));
26091
+ const stored = readState(stateFile);
26092
+ if (envReason) return {
26093
+ enabled: false,
26094
+ reason: envReason,
26095
+ stateFile,
26096
+ ...stored.kind === "valid" ? { state: stored.state } : {}
26097
+ };
26098
+ if (stored.kind === "invalid") return {
26099
+ enabled: false,
26100
+ reason: "invalid_state",
26101
+ stateFile
26102
+ };
26103
+ if (stored.kind === "valid" && !stored.state.telemetryEnabled) return {
26104
+ enabled: false,
26105
+ reason: "local_setting",
26106
+ stateFile,
26107
+ state: stored.state
26108
+ };
26109
+ return {
26110
+ enabled: true,
26111
+ stateFile,
26112
+ ...stored.kind === "valid" ? { state: stored.state } : {}
26113
+ };
26114
+ }
26115
+ function enabledState(options) {
26116
+ const status = statusFromState(options);
26117
+ return status.enabled ? status.state : void 0;
26118
+ }
26119
+ function initializeTelemetry(options = {}) {
26120
+ const initialStatus = statusFromState(options);
26121
+ if (!initialStatus.enabled) return initialStatus;
26122
+ const stateFile = initialStatus.stateFile;
26123
+ const state = initialStatus.state ?? newState(options);
26124
+ const events = [];
26125
+ if (!state.sentMilestones.includes("first_run")) {
26126
+ try {
26127
+ (options.notice ?? ((message) => process.stderr.write(message)))(TELEMETRY_NOTICE);
26128
+ } catch {
26129
+ return {
26130
+ enabled: false,
26131
+ reason: "invalid_state",
26132
+ stateFile
26133
+ };
26134
+ }
26135
+ state.sentMilestones.push("first_run");
26136
+ events.push("first_run");
26137
+ }
26138
+ const daysSinceFirstRun = (optionNow(options).getTime() - Date.parse(state.firstRunAt)) / 864e5;
26139
+ for (const milestone of RETENTION_MILESTONES) if (daysSinceFirstRun >= milestone.days && !state.sentMilestones.includes(milestone.event)) {
26140
+ state.sentMilestones.push(milestone.event);
26141
+ events.push(milestone.event);
26142
+ }
26143
+ try {
26144
+ writeState(stateFile, state);
26145
+ } catch {
26146
+ return {
26147
+ enabled: false,
26148
+ reason: "invalid_state",
26149
+ stateFile
26150
+ };
26151
+ }
26152
+ for (const event of events) dispatchTelemetry(buildTelemetryPayload(event, state, {}, options), options);
26153
+ return {
26154
+ enabled: true,
26155
+ stateFile,
26156
+ state
26157
+ };
26158
+ }
26159
+ function sendEvent(event, fields, options) {
26160
+ const state = enabledState(options);
26161
+ if (!state) return;
26162
+ dispatchTelemetry(buildTelemetryPayload(event, state, fields, options), options);
26163
+ }
26164
+ function trackTelemetryReportStart(report, options = {}) {
26165
+ if (!isTelemetryReport(report)) return;
26166
+ sendEvent("audit_start", { report }, options);
26167
+ }
26168
+ function trackTelemetryReportComplete(report, options = {}) {
26169
+ if (!isTelemetryReport(report)) return;
26170
+ const state = enabledState(options);
26171
+ if (!state) return;
26172
+ dispatchTelemetry(buildTelemetryPayload("audit_complete", state, { report }, options), options);
26173
+ if (state.sentMilestones.includes("first_audit_complete")) return;
26174
+ state.sentMilestones.push("first_audit_complete");
26175
+ try {
26176
+ writeState(optionStateFile(options), state);
26177
+ } catch {
26178
+ return;
26179
+ }
26180
+ dispatchTelemetry(buildTelemetryPayload("first_audit_complete", state, { report }, options), options);
26181
+ }
26182
+ function trackTelemetryReportFailed(report, errorCategory, options = {}) {
26183
+ if (!isTelemetryReport(report)) return;
26184
+ sendEvent("audit_failed", {
26185
+ errorCategory,
26186
+ report
26187
+ }, options);
26188
+ }
26189
+ function telemetryErrorCategory(error) {
26190
+ if (error instanceof Error && error.name === "AbortError") return "crawl_timeout";
26191
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : void 0;
26192
+ if (code === "ACCESS_DENIED" || code === "AUTH_CONFIG_REQUIRED" || code === "AUTH_EXPIRED" || code === "AUTH_REQUIRED") return "auth";
26193
+ if (code === "INVALID_INPUT" || code === "PROPERTY_NOT_FOUND") return "config";
26194
+ if (code === "OPTIONAL_PROVIDER_UNAVAILABLE" || code === "PROVIDER_UNAVAILABLE" || code === "RATE_LIMITED") return "network";
26195
+ return "unknown";
26196
+ }
25870
26197
  //#endregion
25871
26198
  //#region packages/mcp/src/input-schemas.ts
25872
26199
  const calendarDateSchema = z$1.iso.date("Use a YYYY-MM-DD date.");
@@ -25971,7 +26298,7 @@ function toolSuccess(summaryText, structuredContent, options = {}) {
25971
26298
  }
25972
26299
  //#endregion
25973
26300
  //#region packages/mcp/src/ai-opportunity-tools.ts
25974
- const ga4DateSchema = z$1.union([calendarDateSchema, z$1.string().regex(/^(?:today|yesterday|\d+daysAgo)$/)]).describe("Use YYYY-MM-DD or a GA4 relative date.");
26301
+ const googleAnalyticsDateSchema = z$1.union([calendarDateSchema, z$1.string().regex(/^(?:today|yesterday|\d+daysAgo)$/)]).describe("Use YYYY-MM-DD or a Google Analytics relative date.");
25975
26302
  const queryOpportunitySchema = {
25976
26303
  site: z$1.string().trim().min(1),
25977
26304
  days: z$1.number().int().min(1).max(548).optional(),
@@ -25986,12 +26313,12 @@ const queryOpportunitySchema = {
25986
26313
  };
25987
26314
  function registerAiOpportunityTools(server, dependencies = {}) {
25988
26315
  server.registerTool("seo_ai_referrals", {
25989
- description: "Find AI referral traffic detected in GA4. maxRows bounds retained provider rows; resultLimit bounds returned landing pages.",
26316
+ description: "Find AI referral traffic detected in Google Analytics. maxRows bounds retained provider rows; resultLimit bounds returned landing pages.",
25990
26317
  inputSchema: {
25991
26318
  property: z$1.string().trim().min(1),
25992
- startDate: ga4DateSchema.optional(),
25993
- endDate: ga4DateSchema.optional(),
25994
- maxRows: z$1.number().int().min(1).max(1e5).describe("Maximum retained GA4 rows. If limit is also supplied, both values must match.").optional(),
26319
+ startDate: googleAnalyticsDateSchema.optional(),
26320
+ endDate: googleAnalyticsDateSchema.optional(),
26321
+ maxRows: z$1.number().int().min(1).max(1e5).describe("Maximum retained Google Analytics rows. If limit is also supplied, both values must match.").optional(),
25995
26322
  limit: z$1.number().int().min(1).max(1e5).describe("Legacy alias for maxRows. If maxRows is also supplied, both values must match.").optional(),
25996
26323
  resultLimit: z$1.number().int().min(1).max(1e3).describe("Maximum ranked landing pages returned. Defaults to 25.").optional(),
25997
26324
  refresh: z$1.boolean().optional()
@@ -26209,7 +26536,7 @@ function registerCrawlerTools(server) {
26209
26536
  inputSchema: {
26210
26537
  url: z$1.string().url(),
26211
26538
  site: z$1.string().optional(),
26212
- ga4PropertyId: z$1.string().optional(),
26539
+ googleAnalyticsPropertyId: z$1.string().optional(),
26213
26540
  maxPages: z$1.number().int().positive().optional(),
26214
26541
  maxDepth: z$1.number().int().nonnegative().optional(),
26215
26542
  concurrency: z$1.number().int().positive().optional(),
@@ -26225,12 +26552,12 @@ function registerCrawlerTools(server) {
26225
26552
  includeIssues: z$1.boolean().optional(),
26226
26553
  saveReport: z$1.boolean().optional()
26227
26554
  }
26228
- }, async ({ url, site, ga4PropertyId, maxPages, maxDepth, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, include, exclude, useSitemap, respectRobots, js, includePages, includeIssues, saveReport }) => {
26555
+ }, async ({ url, site, googleAnalyticsPropertyId, maxPages, maxDepth, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, include, exclude, useSitemap, respectRobots, js, includePages, includeIssues, saveReport }) => {
26229
26556
  try {
26230
26557
  const report = await crawlSite({
26231
26558
  url,
26232
26559
  site,
26233
- ga4PropertyId,
26560
+ googleAnalyticsPropertyId,
26234
26561
  maxPages,
26235
26562
  maxDepth,
26236
26563
  concurrency,
@@ -26263,7 +26590,7 @@ function registerCrawlerTools(server) {
26263
26590
  inputSchema: {
26264
26591
  urls: z$1.array(z$1.string().url()).min(1),
26265
26592
  site: z$1.string().optional(),
26266
- ga4PropertyId: z$1.string().optional(),
26593
+ googleAnalyticsPropertyId: z$1.string().optional(),
26267
26594
  maxPages: z$1.number().int().positive().optional(),
26268
26595
  concurrency: z$1.number().int().positive().optional(),
26269
26596
  fetchIntervalCap: z$1.number().int().positive().optional(),
@@ -26273,14 +26600,14 @@ function registerCrawlerTools(server) {
26273
26600
  includeIssues: z$1.boolean().optional(),
26274
26601
  saveReport: z$1.boolean().optional()
26275
26602
  }
26276
- }, async ({ urls, site, ga4PropertyId, maxPages, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, includePages, includeIssues, saveReport }) => {
26603
+ }, async ({ urls, site, googleAnalyticsPropertyId, maxPages, concurrency, fetchIntervalCap, fetchIntervalMs, refresh, includePages, includeIssues, saveReport }) => {
26277
26604
  try {
26278
26605
  const report = await crawlSite({
26279
26606
  url: urls[0] ?? "",
26280
26607
  urls,
26281
26608
  mode: "list",
26282
26609
  site,
26283
- ga4PropertyId,
26610
+ googleAnalyticsPropertyId,
26284
26611
  maxPages: maxPages ?? urls.length,
26285
26612
  concurrency,
26286
26613
  refresh,
@@ -27048,7 +27375,7 @@ function registerExperimentTools(server) {
27048
27375
  }
27049
27376
  });
27050
27377
  server.registerTool("seo_measure_change", {
27051
- description: "Measure a saved or ad hoc SEO change with equal finalized GSC windows and optional GA4/control evidence",
27378
+ description: "Measure a saved or ad hoc SEO change with equal finalized GSC windows and optional Google Analytics/control evidence",
27052
27379
  inputSchema: {
27053
27380
  id: z$1.string().min(1).optional(),
27054
27381
  site: z$1.string().min(1).optional(),
@@ -27061,7 +27388,7 @@ function registerExperimentTools(server) {
27061
27388
  target: z$1.string().min(1).optional(),
27062
27389
  title: z$1.string().min(1).optional(),
27063
27390
  changedAt: calendarDateSchema.optional(),
27064
- ga4PropertyId: z$1.string().min(1).optional(),
27391
+ googleAnalyticsPropertyId: z$1.string().min(1).optional(),
27065
27392
  controlScope: z$1.enum([
27066
27393
  "site",
27067
27394
  "page",
@@ -27074,7 +27401,7 @@ function registerExperimentTools(server) {
27074
27401
  afterDays: z$1.number().int().min(1).max(548).optional(),
27075
27402
  refresh: z$1.boolean().optional()
27076
27403
  }
27077
- }, async ({ id, site, scope, target, title, changedAt, ga4PropertyId, controlScope, controlTarget, controlTitle, beforeDays, afterDays, refresh }) => {
27404
+ }, async ({ id, site, scope, target, title, changedAt, googleAnalyticsPropertyId, controlScope, controlTarget, controlTitle, beforeDays, afterDays, refresh }) => {
27078
27405
  try {
27079
27406
  const result = await measureChange({
27080
27407
  id,
@@ -27083,7 +27410,7 @@ function registerExperimentTools(server) {
27083
27410
  target,
27084
27411
  title,
27085
27412
  changedAt,
27086
- ga4PropertyId,
27413
+ googleAnalyticsPropertyId,
27087
27414
  controlScope,
27088
27415
  controlTarget,
27089
27416
  controlTitle,
@@ -27737,7 +28064,7 @@ const REPORT_DEPTH = {
27737
28064
  "Referral sessions did not necessarily cause conversions and are not all AI traffic.",
27738
28065
  "totalUsers is not additive and must not be reconstructed by summing rows."
27739
28066
  ],
27740
- verify: "Compare complete like-for-like GA4 periods and check whether high-session landing pages answer the referring context.",
28067
+ verify: "Compare complete like-for-like Google Analytics periods and check whether high-session landing pages answer the referring context.",
27741
28068
  related: [{
27742
28069
  id: "seo-to-ai-query",
27743
28070
  reason: "Builds monitoring prompts."
@@ -27892,7 +28219,7 @@ const REPORT_DEPTH = {
27892
28219
  ],
27893
28220
  doNotClaim: [
27894
28221
  "An absent page from a capped, skipped, failed, or differently scoped crawl is not proven removed.",
27895
- "Source row caps limit joined GSC or GA4 evidence even when the document crawl completed.",
28222
+ "Source row caps limit joined GSC or Google Analytics evidence even when the document crawl completed.",
27896
28223
  "A change between snapshots is not proof the release caused it."
27897
28224
  ],
27898
28225
  verify: "Re-run the same definition and request scope after deployment and confirm the delta.",
@@ -28398,7 +28725,7 @@ const REPORT_DEPTH = {
28398
28725
  "dataStatus, window, source, warnings, and caveats",
28399
28726
  "window.effectiveDays versus requestedDays and afterWindowTruncated",
28400
28727
  "GSC before and after metrics",
28401
- "GA4 source.analytics.status and timezone and any control adjusted deltas"
28728
+ "Google Analytics source.analytics.status and timezone and any control adjusted deltas"
28402
28729
  ],
28403
28730
  doNotClaim: [
28404
28731
  "Timing does not prove the recorded change caused the movement.",
@@ -28716,8 +29043,8 @@ const REPORT_DEPTH = {
28716
29043
  "the top few URLs manually"
28717
29044
  ],
28718
29045
  doNotClaim: [
28719
- "A click decline, low CTR, second-page position, and GA4 session signal have different semantics and are not interchangeable.",
28720
- "Missing GA4 or failed fetches stay unavailable, not zero-weight proof.",
29046
+ "A click decline, low CTR, second-page position, and Google Analytics session signal have different semantics and are not interchangeable.",
29047
+ "Missing Google Analytics or failed fetches stay unavailable, not zero-weight proof.",
28721
29048
  "The score is not expected clicks, revenue, ranking lift, or causal confidence."
28722
29049
  ],
28723
29050
  verify: "Confirm query intent, current content, and internal links for the top URLs before choosing a bounded edit.",
@@ -28835,7 +29162,7 @@ const REPORT_DEPTH = {
28835
29162
  related: [
28836
29163
  {
28837
29164
  id: "ai-referrals",
28838
- reason: "Adds GA4 referral evidence."
29165
+ reason: "Adds Google Analytics referral evidence."
28839
29166
  },
28840
29167
  {
28841
29168
  id: "community-intent",
@@ -29082,9 +29409,9 @@ const REPORT_GUIDANCE = {
29082
29409
  },
29083
29410
  "ai-referrals": {
29084
29411
  name: "AI referral traffic",
29085
- description: "Find visits from known AI products in GA4 and see a ranked, bounded set of landing pages that received them.",
29086
- useWhen: ["You want measured AI referral traffic from GA4.", "You need the landing pages and sources behind those visits."],
29087
- avoidWhen: ["GA4 is not connected or does not track the site.", "You want to measure citations or unclicked mentions inside AI answers."],
29412
+ description: "Find visits from known AI products in Google Analytics and see a ranked, bounded set of landing pages that received them.",
29413
+ useWhen: ["You want measured AI referral traffic from Google Analytics.", "You need the landing pages and sources behind those visits."],
29414
+ avoidWhen: ["Google Analytics is not connected or does not track the site.", "You want to measure citations or unclicked mentions inside AI answers."],
29088
29415
  outcome: "A source and landing-page breakdown of identifiable AI referrals."
29089
29416
  },
29090
29417
  "ai-search-scorecard": {
@@ -29763,9 +30090,9 @@ function registerWorkflowTools(server) {
29763
30090
  "verifyLimit",
29764
30091
  "refresh"
29765
30092
  ]),
29766
- ga4PropertyId: z$1.string().optional()
30093
+ googleAnalyticsPropertyId: z$1.string().optional()
29767
30094
  }
29768
- }, async ({ site, days, recentDays, limit, includeBrand, ga4PropertyId, verifyContent, verifyLimit, refresh }) => {
30095
+ }, async ({ site, days, recentDays, limit, includeBrand, googleAnalyticsPropertyId, verifyContent, verifyLimit, refresh }) => {
29769
30096
  try {
29770
30097
  return workflowSuccess(await refreshPrioritiesWorkflow({
29771
30098
  site,
@@ -29773,7 +30100,7 @@ function registerWorkflowTools(server) {
29773
30100
  recentDays,
29774
30101
  limit,
29775
30102
  includeBrand,
29776
- ga4PropertyId,
30103
+ googleAnalyticsPropertyId,
29777
30104
  verifyContent,
29778
30105
  verifyLimit,
29779
30106
  refresh
@@ -30038,7 +30365,8 @@ async function runReport(id, params = {}) {
30038
30365
  //#endregion
30039
30366
  //#region packages/mcp/src/discovery-tools.ts
30040
30367
  const openOutputSchema = z$1.looseObject({});
30041
- function registerDiscoveryTools(server) {
30368
+ const reportIds = new Set(listReports().map((report) => report.id));
30369
+ function registerDiscoveryTools(server, options = {}) {
30042
30370
  server.registerTool("seo_list_reports", {
30043
30371
  description: "List compact SEO report ids, names, and purposes, optionally by category",
30044
30372
  inputSchema: { category: z$1.enum(REPORT_CATEGORIES).optional() },
@@ -30082,17 +30410,31 @@ function registerDiscoveryTools(server) {
30082
30410
  outputSchema: openOutputSchema,
30083
30411
  annotations: { destructiveHint: false }
30084
30412
  }, async ({ id, params }) => {
30085
- return runReport(id, params);
30413
+ const telemetry = reportIds.has(id) ? options.telemetry?.() : void 0;
30414
+ if (telemetry) trackTelemetryReportStart(id, telemetry);
30415
+ try {
30416
+ const result = await runReport(id, params);
30417
+ if (telemetry) if (result.isError) trackTelemetryReportFailed(id, telemetryErrorCategory(result.structuredContent?.error), telemetry);
30418
+ else trackTelemetryReportComplete(id, telemetry);
30419
+ return result;
30420
+ } catch (error) {
30421
+ if (telemetry) trackTelemetryReportFailed(id, telemetryErrorCategory(error), telemetry);
30422
+ throw error;
30423
+ }
30086
30424
  });
30087
30425
  }
30088
30426
  //#endregion
30089
30427
  //#region packages/mcp/src/index.ts
30090
- function createServer() {
30428
+ function createServer(options = {}) {
30091
30429
  const server = new McpServer({
30092
30430
  name: "seo",
30093
30431
  version: SEO_VERSION
30094
30432
  }, { capabilities: { logging: {} } });
30095
- registerDiscoveryTools(server);
30433
+ const telemetryOptions = () => ({ clientName: server.server.getClientVersion()?.name });
30434
+ registerDiscoveryTools(server, { telemetry: options.telemetry === false ? void 0 : telemetryOptions });
30435
+ if (options.telemetry !== false) server.server.oninitialized = () => {
30436
+ initializeTelemetry(telemetryOptions());
30437
+ };
30096
30438
  return server;
30097
30439
  }
30098
30440
  async function startMcpServer(opts = {}) {