@ainyc/canonry 1.13.0 → 1.15.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.
@@ -63,6 +63,15 @@ Do not write config.yaml by hand; use "canonry init", "canonry settings", or "ca
63
63
  };
64
64
  }
65
65
  normalizeGoogleConfig(parsed);
66
+ const portOverride = process.env.CANONRY_PORT?.trim();
67
+ if (portOverride) {
68
+ try {
69
+ const url = new URL(parsed.apiUrl);
70
+ url.port = portOverride;
71
+ parsed.apiUrl = url.origin;
72
+ } catch {
73
+ }
74
+ }
66
75
  return parsed;
67
76
  }
68
77
  function saveConfig(config) {
@@ -151,7 +160,7 @@ function trackEvent(event, properties) {
151
160
 
152
161
  // src/server.ts
153
162
  import { createRequire as createRequire2 } from "module";
154
- import crypto17 from "crypto";
163
+ import crypto18 from "crypto";
155
164
  import fs2 from "fs";
156
165
  import path2 from "path";
157
166
  import { fileURLToPath } from "url";
@@ -174,6 +183,7 @@ __export(schema_exports, {
174
183
  auditLog: () => auditLog,
175
184
  competitors: () => competitors,
176
185
  googleConnections: () => googleConnections,
186
+ gscCoverageSnapshots: () => gscCoverageSnapshots,
177
187
  gscSearchData: () => gscSearchData,
178
188
  gscUrlInspections: () => gscUrlInspections,
179
189
  keywords: () => keywords,
@@ -196,6 +206,8 @@ var projects = sqliteTable("projects", {
196
206
  tags: text("tags").notNull().default("[]"),
197
207
  labels: text("labels").notNull().default("{}"),
198
208
  providers: text("providers").notNull().default("[]"),
209
+ locations: text("locations").notNull().default("[]"),
210
+ defaultLocation: text("default_location"),
199
211
  configSource: text("config_source").notNull().default("cli"),
200
212
  configRevision: integer("config_revision").notNull().default(1),
201
213
  createdAt: text("created_at").notNull(),
@@ -225,6 +237,7 @@ var runs = sqliteTable("runs", {
225
237
  kind: text("kind").notNull().default("answer-visibility"),
226
238
  status: text("status").notNull().default("queued"),
227
239
  trigger: text("trigger").notNull().default("manual"),
240
+ location: text("location"),
228
241
  startedAt: text("started_at"),
229
242
  finishedAt: text("finished_at"),
230
243
  error: text("error"),
@@ -243,6 +256,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
243
256
  answerText: text("answer_text"),
244
257
  citedDomains: text("cited_domains").notNull().default("[]"),
245
258
  competitorOverlap: text("competitor_overlap").notNull().default("[]"),
259
+ location: text("location"),
246
260
  rawResponse: text("raw_response"),
247
261
  createdAt: text("created_at").notNull()
248
262
  }, (table) => [
@@ -306,6 +320,7 @@ var googleConnections = sqliteTable("google_connections", {
306
320
  domain: text("domain").notNull(),
307
321
  connectionType: text("connection_type").notNull(),
308
322
  propertyId: text("property_id"),
323
+ sitemapUrl: text("sitemap_url"),
309
324
  accessToken: text("access_token"),
310
325
  refreshToken: text("refresh_token"),
311
326
  tokenExpiresAt: text("token_expires_at"),
@@ -356,6 +371,19 @@ var gscUrlInspections = sqliteTable("gsc_url_inspections", {
356
371
  index("idx_gsc_inspect_run").on(table.syncRunId),
357
372
  index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
358
373
  ]);
374
+ var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
375
+ id: text("id").primaryKey(),
376
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
377
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
378
+ date: text("date").notNull(),
379
+ indexed: integer("indexed").notNull().default(0),
380
+ notIndexed: integer("not_indexed").notNull().default(0),
381
+ reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
382
+ createdAt: text("created_at").notNull()
383
+ }, (table) => [
384
+ index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
385
+ index("idx_gsc_coverage_snap_run").on(table.syncRunId)
386
+ ]);
359
387
  var usageCounters = sqliteTable("usage_counters", {
360
388
  id: text("id").primaryKey(),
361
389
  scope: text("scope").notNull(),
@@ -573,7 +601,28 @@ var MIGRATIONS = [
573
601
  )`,
574
602
  `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
575
603
  `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
576
- `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`
604
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
605
+ // v7: GSC coverage snapshots for historical tracking
606
+ `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
607
+ id TEXT PRIMARY KEY,
608
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
609
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
610
+ date TEXT NOT NULL,
611
+ indexed INTEGER NOT NULL DEFAULT 0,
612
+ not_indexed INTEGER NOT NULL DEFAULT 0,
613
+ reason_breakdown TEXT NOT NULL DEFAULT '{}',
614
+ created_at TEXT NOT NULL
615
+ )`,
616
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
617
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
618
+ // v8: Location-aware sweeps — project locations + snapshot location tag
619
+ `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
620
+ `ALTER TABLE projects ADD COLUMN default_location TEXT`,
621
+ `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
622
+ // v9: Add location column to runs for per-location run tracking
623
+ `ALTER TABLE runs ADD COLUMN location TEXT`,
624
+ // v10: Add sitemapUrl to google_connections for persistent sitemap storage
625
+ `ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`
577
626
  ];
578
627
  function migrate(db) {
579
628
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -604,6 +653,13 @@ function parseProviderName(input) {
604
653
  const lower = input.trim().toLowerCase();
605
654
  return PROVIDER_NAMES.includes(lower) ? lower : void 0;
606
655
  }
656
+ var locationContextSchema = z.object({
657
+ label: z.string().min(1),
658
+ city: z.string().min(1),
659
+ region: z.string().min(1),
660
+ country: z.string().length(2),
661
+ timezone: z.string().optional()
662
+ });
607
663
 
608
664
  // ../contracts/src/notification.ts
609
665
  import { z as z2 } from "zod";
@@ -664,6 +720,8 @@ var configSpecSchema = z3.object({
664
720
  keywords: z3.array(z3.string().min(1)).optional().default([]),
665
721
  competitors: z3.array(z3.string().min(1)).optional().default([]),
666
722
  providers: z3.array(providerNameSchema).optional().default([]),
723
+ locations: z3.array(locationContextSchema).optional().default([]),
724
+ defaultLocation: z3.string().optional(),
667
725
  schedule: configScheduleSchema,
668
726
  notifications: z3.array(configNotificationSchema).optional().default([]),
669
727
  google: configGoogleSchema
@@ -776,6 +834,7 @@ var googleConnectionDtoSchema = z4.object({
776
834
  domain: z4.string(),
777
835
  connectionType: googleConnectionTypeSchema,
778
836
  propertyId: z4.string().nullable().optional(),
837
+ sitemapUrl: z4.string().nullable().optional(),
779
838
  scopes: z4.array(z4.string()).default([]),
780
839
  createdAt: z4.string(),
781
840
  updatedAt: z4.string()
@@ -812,6 +871,11 @@ var gscDeindexedRowSchema = z4.object({
812
871
  currentState: z4.string().nullable(),
813
872
  transitionDate: z4.string()
814
873
  });
874
+ var gscReasonGroupSchema = z4.object({
875
+ reason: z4.string(),
876
+ count: z4.number(),
877
+ urls: z4.array(gscUrlInspectionDtoSchema).default([])
878
+ });
815
879
  var gscCoverageSummaryDtoSchema = z4.object({
816
880
  summary: z4.object({
817
881
  total: z4.number(),
@@ -823,7 +887,14 @@ var gscCoverageSummaryDtoSchema = z4.object({
823
887
  lastInspectedAt: z4.string().nullable(),
824
888
  indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
825
889
  notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
826
- deindexed: z4.array(gscDeindexedRowSchema).default([])
890
+ deindexed: z4.array(gscDeindexedRowSchema).default([]),
891
+ reasonGroups: z4.array(gscReasonGroupSchema).default([])
892
+ });
893
+ var gscCoverageSnapshotDtoSchema = z4.object({
894
+ date: z4.string(),
895
+ indexed: z4.number(),
896
+ notIndexed: z4.number(),
897
+ reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
827
898
  });
828
899
 
829
900
  // ../contracts/src/project.ts
@@ -839,6 +910,8 @@ var projectDtoSchema = z5.object({
839
910
  language: z5.string().min(2),
840
911
  tags: z5.array(z5.string()).default([]),
841
912
  labels: z5.record(z5.string(), z5.string()).default({}),
913
+ locations: z5.array(locationContextSchema).default([]),
914
+ defaultLocation: z5.string().nullable().optional(),
842
915
  configSource: configSourceSchema.default("cli"),
843
916
  configRevision: z5.number().int().positive().default(1),
844
917
  createdAt: z5.string().optional(),
@@ -882,6 +955,7 @@ var runDtoSchema = z6.object({
882
955
  kind: runKindSchema,
883
956
  status: runStatusSchema,
884
957
  trigger: runTriggerSchema.default("manual"),
958
+ location: z6.string().nullable().optional(),
885
959
  startedAt: z6.string().nullable().optional(),
886
960
  finishedAt: z6.string().nullable().optional(),
887
961
  error: z6.string().nullable().optional(),
@@ -905,6 +979,7 @@ var querySnapshotDtoSchema = z6.object({
905
979
  groundingSources: z6.array(groundingSourceSchema).default([]),
906
980
  searchQueries: z6.array(z6.string()).default([]),
907
981
  model: z6.string().nullable().optional(),
982
+ location: z6.string().nullable().optional(),
908
983
  createdAt: z6.string()
909
984
  });
910
985
  var auditLogEntrySchema = z6.object({
@@ -1023,6 +1098,8 @@ async function projectRoutes(app, opts) {
1023
1098
  tags: JSON.stringify(body.tags ?? []),
1024
1099
  labels: JSON.stringify(body.labels ?? {}),
1025
1100
  providers: JSON.stringify(body.providers ?? []),
1101
+ locations: JSON.stringify(body.locations ?? JSON.parse(existing.locations || "[]")),
1102
+ defaultLocation: body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing.defaultLocation,
1026
1103
  configSource: body.configSource ?? "api",
1027
1104
  configRevision: existing.configRevision + 1,
1028
1105
  updatedAt: now
@@ -1049,6 +1126,8 @@ async function projectRoutes(app, opts) {
1049
1126
  tags: JSON.stringify(body.tags ?? []),
1050
1127
  labels: JSON.stringify(body.labels ?? {}),
1051
1128
  providers: JSON.stringify(body.providers ?? []),
1129
+ locations: JSON.stringify(body.locations ?? []),
1130
+ defaultLocation: body.defaultLocation ?? null,
1052
1131
  configSource: body.configSource ?? "api",
1053
1132
  configRevision: 1,
1054
1133
  createdAt: now,
@@ -1102,6 +1181,131 @@ async function projectRoutes(app, opts) {
1102
1181
  opts.onProjectDeleted?.(project.id);
1103
1182
  return reply.status(204).send();
1104
1183
  });
1184
+ app.post("/projects/:name/locations", async (request, reply) => {
1185
+ let project;
1186
+ try {
1187
+ project = resolveProject(app.db, request.params.name);
1188
+ } catch (e) {
1189
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1190
+ const err = e;
1191
+ return reply.status(err.statusCode).send(err.toJSON());
1192
+ }
1193
+ throw e;
1194
+ }
1195
+ const parsed = locationContextSchema.safeParse(request.body);
1196
+ if (!parsed.success) {
1197
+ const err = validationError(parsed.error.issues.map((i) => i.message).join(", "));
1198
+ return reply.status(err.statusCode).send(err.toJSON());
1199
+ }
1200
+ const location = parsed.data;
1201
+ const existing = JSON.parse(project.locations || "[]");
1202
+ if (existing.some((l) => l.label === location.label)) {
1203
+ const err = validationError(`Location "${location.label}" already exists`);
1204
+ return reply.status(err.statusCode).send(err.toJSON());
1205
+ }
1206
+ existing.push(location);
1207
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1208
+ app.db.update(projects).set({
1209
+ locations: JSON.stringify(existing),
1210
+ updatedAt: now
1211
+ }).where(eq3(projects.id, project.id)).run();
1212
+ writeAuditLog(app.db, {
1213
+ projectId: project.id,
1214
+ actor: "api",
1215
+ action: "location.added",
1216
+ entityType: "location",
1217
+ entityId: location.label
1218
+ });
1219
+ return reply.status(201).send(location);
1220
+ });
1221
+ app.get("/projects/:name/locations", async (request, reply) => {
1222
+ let project;
1223
+ try {
1224
+ project = resolveProject(app.db, request.params.name);
1225
+ } catch (e) {
1226
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1227
+ const err = e;
1228
+ return reply.status(err.statusCode).send(err.toJSON());
1229
+ }
1230
+ throw e;
1231
+ }
1232
+ const locations = JSON.parse(project.locations || "[]");
1233
+ return reply.send({
1234
+ locations,
1235
+ defaultLocation: project.defaultLocation
1236
+ });
1237
+ });
1238
+ app.delete("/projects/:name/locations/:label", async (request, reply) => {
1239
+ let project;
1240
+ try {
1241
+ project = resolveProject(app.db, request.params.name);
1242
+ } catch (e) {
1243
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1244
+ const err = e;
1245
+ return reply.status(err.statusCode).send(err.toJSON());
1246
+ }
1247
+ throw e;
1248
+ }
1249
+ const label = decodeURIComponent(request.params.label);
1250
+ const existing = JSON.parse(project.locations || "[]");
1251
+ const filtered = existing.filter((l) => l.label !== label);
1252
+ if (filtered.length === existing.length) {
1253
+ const err = validationError(`Location "${label}" not found`);
1254
+ return reply.status(err.statusCode).send(err.toJSON());
1255
+ }
1256
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1257
+ const updates = {
1258
+ locations: JSON.stringify(filtered),
1259
+ updatedAt: now
1260
+ };
1261
+ if (project.defaultLocation === label) {
1262
+ updates.defaultLocation = null;
1263
+ }
1264
+ app.db.update(projects).set(updates).where(eq3(projects.id, project.id)).run();
1265
+ writeAuditLog(app.db, {
1266
+ projectId: project.id,
1267
+ actor: "api",
1268
+ action: "location.removed",
1269
+ entityType: "location",
1270
+ entityId: label
1271
+ });
1272
+ return reply.status(204).send();
1273
+ });
1274
+ app.put("/projects/:name/locations/default", async (request, reply) => {
1275
+ let project;
1276
+ try {
1277
+ project = resolveProject(app.db, request.params.name);
1278
+ } catch (e) {
1279
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1280
+ const err = e;
1281
+ return reply.status(err.statusCode).send(err.toJSON());
1282
+ }
1283
+ throw e;
1284
+ }
1285
+ const label = request.body?.label;
1286
+ if (!label) {
1287
+ const err = validationError("label is required");
1288
+ return reply.status(err.statusCode).send(err.toJSON());
1289
+ }
1290
+ const existing = JSON.parse(project.locations || "[]");
1291
+ if (!existing.some((l) => l.label === label)) {
1292
+ const err = validationError(`Location "${label}" not found. Add it first.`);
1293
+ return reply.status(err.statusCode).send(err.toJSON());
1294
+ }
1295
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1296
+ app.db.update(projects).set({
1297
+ defaultLocation: label,
1298
+ updatedAt: now
1299
+ }).where(eq3(projects.id, project.id)).run();
1300
+ writeAuditLog(app.db, {
1301
+ projectId: project.id,
1302
+ actor: "api",
1303
+ action: "location.default-set",
1304
+ entityType: "location",
1305
+ entityId: label
1306
+ });
1307
+ return reply.send({ defaultLocation: label });
1308
+ });
1105
1309
  app.get("/projects/:name/export", async (request, reply) => {
1106
1310
  let project;
1107
1311
  try {
@@ -1133,6 +1337,8 @@ async function projectRoutes(app, opts) {
1133
1337
  keywords: kws.map((k) => k.keyword),
1134
1338
  competitors: comps.map((c) => c.domain),
1135
1339
  providers: JSON.parse(project.providers || "[]"),
1340
+ locations: JSON.parse(project.locations || "[]"),
1341
+ ...project.defaultLocation ? { defaultLocation: project.defaultLocation } : {},
1136
1342
  notifications: notificationRows.map((row) => {
1137
1343
  const cfg = JSON.parse(row.config);
1138
1344
  return {
@@ -1165,6 +1371,8 @@ function formatProject(row) {
1165
1371
  tags: JSON.parse(row.tags),
1166
1372
  labels: JSON.parse(row.labels),
1167
1373
  providers: JSON.parse(row.providers || "[]"),
1374
+ locations: JSON.parse(row.locations || "[]"),
1375
+ defaultLocation: row.defaultLocation,
1168
1376
  configSource: row.configSource,
1169
1377
  configRevision: row.configRevision,
1170
1378
  createdAt: row.createdAt,
@@ -1384,6 +1592,7 @@ function resolveProjectSafe2(app, name, reply) {
1384
1592
  }
1385
1593
 
1386
1594
  // ../api-routes/src/runs.ts
1595
+ import crypto8 from "crypto";
1387
1596
  import { eq as eq7, asc } from "drizzle-orm";
1388
1597
 
1389
1598
  // ../api-routes/src/run-queue.ts
@@ -1410,6 +1619,7 @@ function queueRunIfProjectIdle(db, params) {
1410
1619
  kind,
1411
1620
  status: "queued",
1412
1621
  trigger,
1622
+ location: params.location ?? null,
1413
1623
  createdAt
1414
1624
  }).run();
1415
1625
  return { conflict: false, runId };
@@ -1438,11 +1648,60 @@ async function runRoutes(app, opts) {
1438
1648
  rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
1439
1649
  }
1440
1650
  const providers = rawProviders?.length ? rawProviders : void 0;
1651
+ let resolvedLocation;
1652
+ const projectLocations = JSON.parse(project.locations || "[]");
1653
+ if (request.body?.noLocation) {
1654
+ resolvedLocation = null;
1655
+ } else if (request.body?.allLocations) {
1656
+ } else if (request.body?.location) {
1657
+ const loc = projectLocations.find((l) => l.label === request.body.location);
1658
+ if (!loc) {
1659
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Location "${request.body.location}" not found. Configure it first.` } });
1660
+ }
1661
+ resolvedLocation = loc;
1662
+ }
1663
+ if (request.body?.allLocations) {
1664
+ if (projectLocations.length === 0) {
1665
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "No locations configured for this project" } });
1666
+ }
1667
+ const newRuns = [];
1668
+ for (const loc of projectLocations) {
1669
+ const runId2 = crypto8.randomUUID();
1670
+ app.db.insert(runs).values({
1671
+ id: runId2,
1672
+ projectId: project.id,
1673
+ kind,
1674
+ status: "queued",
1675
+ trigger,
1676
+ location: loc.label,
1677
+ createdAt: now
1678
+ }).run();
1679
+ newRuns.push({ runId: runId2, loc });
1680
+ }
1681
+ const results = [];
1682
+ for (const { runId: runId2, loc } of newRuns) {
1683
+ writeAuditLog(app.db, {
1684
+ projectId: project.id,
1685
+ actor: "api",
1686
+ action: "run.created",
1687
+ entityType: "run",
1688
+ entityId: runId2
1689
+ });
1690
+ const r = app.db.select().from(runs).where(eq7(runs.id, runId2)).get();
1691
+ if (opts.onRunCreated) {
1692
+ opts.onRunCreated(runId2, project.id, providers, loc);
1693
+ }
1694
+ results.push({ ...formatRun(r), location: loc.label });
1695
+ }
1696
+ return reply.status(207).send(results);
1697
+ }
1698
+ const locationLabel = resolvedLocation?.label ?? null;
1441
1699
  const queueResult = queueRunIfProjectIdle(app.db, {
1442
1700
  createdAt: now,
1443
1701
  kind,
1444
1702
  projectId: project.id,
1445
- trigger
1703
+ trigger,
1704
+ location: locationLabel
1446
1705
  });
1447
1706
  if (queueResult.conflict) {
1448
1707
  const err = runInProgress(project.name);
@@ -1458,7 +1717,7 @@ async function runRoutes(app, opts) {
1458
1717
  });
1459
1718
  const run = app.db.select().from(runs).where(eq7(runs.id, runId)).get();
1460
1719
  if (opts.onRunCreated) {
1461
- opts.onRunCreated(runId, project.id, providers);
1720
+ opts.onRunCreated(runId, project.id, providers, resolvedLocation);
1462
1721
  }
1463
1722
  return reply.status(201).send(formatRun(run));
1464
1723
  });
@@ -1537,6 +1796,7 @@ async function runRoutes(app, opts) {
1537
1796
  answerText: querySnapshots.answerText,
1538
1797
  citedDomains: querySnapshots.citedDomains,
1539
1798
  competitorOverlap: querySnapshots.competitorOverlap,
1799
+ location: querySnapshots.location,
1540
1800
  rawResponse: querySnapshots.rawResponse,
1541
1801
  createdAt: querySnapshots.createdAt
1542
1802
  }).from(querySnapshots).leftJoin(keywords, eq7(querySnapshots.keywordId, keywords.id)).where(eq7(querySnapshots.runId, run.id)).all();
@@ -1555,6 +1815,7 @@ async function runRoutes(app, opts) {
1555
1815
  citedDomains: tryParseJson(s.citedDomains, []),
1556
1816
  competitorOverlap: tryParseJson(s.competitorOverlap, []),
1557
1817
  model: s.model ?? rawParsed.model,
1818
+ location: s.location,
1558
1819
  groundingSources: rawParsed.groundingSources,
1559
1820
  searchQueries: rawParsed.searchQueries,
1560
1821
  createdAt: s.createdAt
@@ -1570,6 +1831,7 @@ function formatRun(row) {
1570
1831
  kind: row.kind,
1571
1832
  status: row.status,
1572
1833
  trigger: row.trigger,
1834
+ location: row.location,
1573
1835
  startedAt: row.startedAt,
1574
1836
  finishedAt: row.finishedAt,
1575
1837
  error: row.error,
@@ -1605,7 +1867,7 @@ function resolveProjectSafe3(app, name, reply) {
1605
1867
  }
1606
1868
 
1607
1869
  // ../api-routes/src/apply.ts
1608
- import crypto9 from "crypto";
1870
+ import crypto10 from "crypto";
1609
1871
  import { eq as eq8 } from "drizzle-orm";
1610
1872
 
1611
1873
  // ../api-routes/src/schedule-utils.ts
@@ -1697,7 +1959,7 @@ function isValidTimezone(tz) {
1697
1959
  }
1698
1960
 
1699
1961
  // ../api-routes/src/webhooks.ts
1700
- import crypto8 from "crypto";
1962
+ import crypto9 from "crypto";
1701
1963
  import dns from "dns/promises";
1702
1964
  import http from "http";
1703
1965
  import https from "https";
@@ -1749,7 +2011,7 @@ async function deliverWebhook(target, payload, webhookSecret) {
1749
2011
  "User-Agent": "Canonry/0.1.0"
1750
2012
  };
1751
2013
  if (webhookSecret) {
1752
- headers["X-Canonry-Signature"] = "sha256=" + crypto8.createHmac("sha256", webhookSecret).update(body).digest("hex");
2014
+ headers["X-Canonry-Signature"] = "sha256=" + crypto9.createHmac("sha256", webhookSecret).update(body).digest("hex");
1753
2015
  }
1754
2016
  return await new Promise((resolve) => {
1755
2017
  const requestOptions = {
@@ -1880,6 +2142,8 @@ async function applyRoutes(app, opts) {
1880
2142
  language: config.spec.language,
1881
2143
  labels: JSON.stringify(config.metadata.labels),
1882
2144
  providers: JSON.stringify(config.spec.providers ?? []),
2145
+ locations: JSON.stringify(config.spec.locations ?? []),
2146
+ defaultLocation: config.spec.defaultLocation ?? null,
1883
2147
  configSource: "config-file",
1884
2148
  configRevision: existing.configRevision + 1,
1885
2149
  updatedAt: now
@@ -1892,7 +2156,7 @@ async function applyRoutes(app, opts) {
1892
2156
  entityId: projectId
1893
2157
  });
1894
2158
  } else {
1895
- projectId = crypto9.randomUUID();
2159
+ projectId = crypto10.randomUUID();
1896
2160
  app.db.insert(projects).values({
1897
2161
  id: projectId,
1898
2162
  name,
@@ -1904,6 +2168,8 @@ async function applyRoutes(app, opts) {
1904
2168
  tags: "[]",
1905
2169
  labels: JSON.stringify(config.metadata.labels),
1906
2170
  providers: JSON.stringify(config.spec.providers ?? []),
2171
+ locations: JSON.stringify(config.spec.locations ?? []),
2172
+ defaultLocation: config.spec.defaultLocation ?? null,
1907
2173
  configSource: "config-file",
1908
2174
  configRevision: 1,
1909
2175
  createdAt: now,
@@ -1921,7 +2187,7 @@ async function applyRoutes(app, opts) {
1921
2187
  tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
1922
2188
  for (const kw of config.spec.keywords) {
1923
2189
  tx.insert(keywords).values({
1924
- id: crypto9.randomUUID(),
2190
+ id: crypto10.randomUUID(),
1925
2191
  projectId,
1926
2192
  keyword: kw,
1927
2193
  createdAt: now
@@ -1937,7 +2203,7 @@ async function applyRoutes(app, opts) {
1937
2203
  tx.delete(competitors).where(eq8(competitors.projectId, projectId)).run();
1938
2204
  for (const domain of config.spec.competitors) {
1939
2205
  tx.insert(competitors).values({
1940
- id: crypto9.randomUUID(),
2206
+ id: crypto10.randomUUID(),
1941
2207
  projectId,
1942
2208
  domain,
1943
2209
  createdAt: now
@@ -1993,7 +2259,7 @@ async function applyRoutes(app, opts) {
1993
2259
  }).where(eq8(schedules.id, existingSched.id)).run();
1994
2260
  } else {
1995
2261
  app.db.insert(schedules).values({
1996
- id: crypto9.randomUUID(),
2262
+ id: crypto10.randomUUID(),
1997
2263
  projectId,
1998
2264
  cronExpr,
1999
2265
  preset,
@@ -2025,11 +2291,11 @@ async function applyRoutes(app, opts) {
2025
2291
  app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
2026
2292
  for (const notif of config.spec.notifications) {
2027
2293
  app.db.insert(notifications).values({
2028
- id: crypto9.randomUUID(),
2294
+ id: crypto10.randomUUID(),
2029
2295
  projectId,
2030
2296
  channel: notif.channel,
2031
2297
  config: JSON.stringify({ url: notif.url, events: notif.events }),
2032
- webhookSecret: crypto9.randomBytes(32).toString("hex"),
2298
+ webhookSecret: crypto10.randomBytes(32).toString("hex"),
2033
2299
  enabled: 1,
2034
2300
  createdAt: now,
2035
2301
  updatedAt: now
@@ -2058,6 +2324,8 @@ async function applyRoutes(app, opts) {
2058
2324
  tags: JSON.parse(project.tags),
2059
2325
  labels: JSON.parse(project.labels),
2060
2326
  providers: JSON.parse(project.providers || "[]"),
2327
+ locations: JSON.parse(project.locations || "[]"),
2328
+ defaultLocation: project.defaultLocation,
2061
2329
  configSource: project.configSource,
2062
2330
  configRevision: project.configRevision,
2063
2331
  createdAt: project.createdAt,
@@ -2099,10 +2367,13 @@ async function historyRoutes(app) {
2099
2367
  answerText: querySnapshots.answerText,
2100
2368
  citedDomains: querySnapshots.citedDomains,
2101
2369
  competitorOverlap: querySnapshots.competitorOverlap,
2370
+ location: querySnapshots.location,
2102
2371
  createdAt: querySnapshots.createdAt
2103
2372
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc(querySnapshots.createdAt)).all();
2104
- const total = allSnapshots.length;
2105
- const paged = allSnapshots.slice(offset, offset + limit);
2373
+ const locationFilter = request.query.location;
2374
+ const filtered = locationFilter !== void 0 ? allSnapshots.filter((s) => s.location === (locationFilter || null)) : allSnapshots;
2375
+ const total = filtered.length;
2376
+ const paged = filtered.slice(offset, offset + limit);
2106
2377
  return reply.send({
2107
2378
  snapshots: paged.map((s) => ({
2108
2379
  id: s.id,
@@ -2115,6 +2386,7 @@ async function historyRoutes(app) {
2115
2386
  answerText: s.answerText,
2116
2387
  citedDomains: tryParseJson2(s.citedDomains, []),
2117
2388
  competitorOverlap: tryParseJson2(s.competitorOverlap, []),
2389
+ location: s.location,
2118
2390
  createdAt: s.createdAt
2119
2391
  })),
2120
2392
  total
@@ -2129,7 +2401,9 @@ async function historyRoutes(app) {
2129
2401
  return reply.send([]);
2130
2402
  }
2131
2403
  const runIds = new Set(projectRuns.map((r) => r.id));
2132
- const allSnapshots = app.db.select().from(querySnapshots).where(inArray(querySnapshots.runId, [...runIds])).all();
2404
+ const rawSnapshots = app.db.select().from(querySnapshots).where(inArray(querySnapshots.runId, [...runIds])).all();
2405
+ const timelineLocationFilter = request.query.location;
2406
+ const allSnapshots = timelineLocationFilter !== void 0 ? rawSnapshots.filter((s) => s.location === (timelineLocationFilter || null)) : rawSnapshots;
2133
2407
  const deduped = /* @__PURE__ */ new Map();
2134
2408
  for (const snap of allSnapshots) {
2135
2409
  const key = `${snap.runId}:${snap.keywordId}`;
@@ -3038,7 +3312,7 @@ async function telemetryRoutes(app, opts) {
3038
3312
  }
3039
3313
 
3040
3314
  // ../api-routes/src/schedules.ts
3041
- import crypto10 from "crypto";
3315
+ import crypto11 from "crypto";
3042
3316
  import { eq as eq10 } from "drizzle-orm";
3043
3317
  async function scheduleRoutes(app, opts) {
3044
3318
  app.put("/projects/:name/schedule", async (request, reply) => {
@@ -3085,7 +3359,7 @@ async function scheduleRoutes(app, opts) {
3085
3359
  }).where(eq10(schedules.id, existing.id)).run();
3086
3360
  } else {
3087
3361
  app.db.insert(schedules).values({
3088
- id: crypto10.randomUUID(),
3362
+ id: crypto11.randomUUID(),
3089
3363
  projectId: project.id,
3090
3364
  cronExpr,
3091
3365
  preset: preset ?? null,
@@ -3164,7 +3438,7 @@ function resolveProjectSafe5(app, name, reply) {
3164
3438
  }
3165
3439
 
3166
3440
  // ../api-routes/src/notifications.ts
3167
- import crypto11 from "crypto";
3441
+ import crypto12 from "crypto";
3168
3442
  import { eq as eq11 } from "drizzle-orm";
3169
3443
  var VALID_EVENTS = ["citation.lost", "citation.gained", "run.completed", "run.failed"];
3170
3444
  async function notificationRoutes(app) {
@@ -3198,8 +3472,8 @@ async function notificationRoutes(app) {
3198
3472
  });
3199
3473
  }
3200
3474
  const now = (/* @__PURE__ */ new Date()).toISOString();
3201
- const id = crypto11.randomUUID();
3202
- const webhookSecret = crypto11.randomBytes(32).toString("hex");
3475
+ const id = crypto12.randomUUID();
3476
+ const webhookSecret = crypto12.randomBytes(32).toString("hex");
3203
3477
  app.db.insert(notifications).values({
3204
3478
  id,
3205
3479
  projectId: project.id,
@@ -3318,7 +3592,7 @@ function resolveProjectSafe6(app, name, reply) {
3318
3592
  }
3319
3593
 
3320
3594
  // ../api-routes/src/google.ts
3321
- import crypto12 from "crypto";
3595
+ import crypto13 from "crypto";
3322
3596
  import { eq as eq12, and as and3, desc as desc2, sql as sql2 } from "drizzle-orm";
3323
3597
 
3324
3598
  // ../integration-google/src/constants.ts
@@ -3426,6 +3700,14 @@ async function listSites(accessToken) {
3426
3700
  );
3427
3701
  return data.siteEntry ?? [];
3428
3702
  }
3703
+ async function listSitemaps(accessToken, siteUrl) {
3704
+ const encodedSiteUrl = encodeURIComponent(siteUrl);
3705
+ const data = await gscFetch(
3706
+ accessToken,
3707
+ `${GSC_API_BASE}/sites/${encodedSiteUrl}/sitemaps`
3708
+ );
3709
+ return data.sitemap ?? [];
3710
+ }
3429
3711
  async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
3430
3712
  const allRows = [];
3431
3713
  let startRow = 0;
@@ -3477,7 +3759,7 @@ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
3477
3759
 
3478
3760
  // ../api-routes/src/google.ts
3479
3761
  function signState(payload, secret) {
3480
- return crypto12.createHmac("sha256", secret).update(payload).digest("hex");
3762
+ return crypto13.createHmac("sha256", secret).update(payload).digest("hex");
3481
3763
  }
3482
3764
  function buildSignedState(data, secret) {
3483
3765
  const payload = JSON.stringify(data);
@@ -3488,7 +3770,7 @@ function verifySignedState(encoded, secret) {
3488
3770
  try {
3489
3771
  const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
3490
3772
  const expected = signState(payload, secret);
3491
- if (!crypto12.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
3773
+ if (!crypto13.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
3492
3774
  return JSON.parse(payload);
3493
3775
  } catch {
3494
3776
  return null;
@@ -3543,6 +3825,7 @@ async function googleRoutes(app, opts) {
3543
3825
  domain: connection.domain,
3544
3826
  connectionType: connection.connectionType,
3545
3827
  propertyId: connection.propertyId ?? null,
3828
+ sitemapUrl: connection.sitemapUrl ?? null,
3546
3829
  scopes: connection.scopes ?? [],
3547
3830
  createdAt: connection.createdAt,
3548
3831
  updatedAt: connection.updatedAt
@@ -3714,7 +3997,7 @@ async function googleRoutes(app, opts) {
3714
3997
  return reply.status(err.statusCode).send(err.toJSON());
3715
3998
  }
3716
3999
  const now = (/* @__PURE__ */ new Date()).toISOString();
3717
- const runId = crypto12.randomUUID();
4000
+ const runId = crypto13.randomUUID();
3718
4001
  app.db.insert(runs).values({
3719
4002
  id: runId,
3720
4003
  projectId: project.id,
@@ -3776,7 +4059,7 @@ async function googleRoutes(app, opts) {
3776
4059
  const mob = ir.mobileUsabilityResult;
3777
4060
  const rich = ir.richResultsResult;
3778
4061
  const now = (/* @__PURE__ */ new Date()).toISOString();
3779
- const id = crypto12.randomUUID();
4062
+ const id = crypto13.randomUUID();
3780
4063
  app.db.insert(gscUrlInspections).values({
3781
4064
  id,
3782
4065
  projectId: project.id,
@@ -3921,6 +4204,21 @@ async function googleRoutes(app, opts) {
3921
4204
  richResults: JSON.parse(r.richResults),
3922
4205
  inspectedAt: r.inspectedAt
3923
4206
  });
4207
+ const reasonMap = /* @__PURE__ */ new Map();
4208
+ for (const row of notIndexedUrls) {
4209
+ const reason = row.coverageState ?? "Unknown";
4210
+ const existing = reasonMap.get(reason);
4211
+ if (existing) {
4212
+ existing.push(row);
4213
+ } else {
4214
+ reasonMap.set(reason, [row]);
4215
+ }
4216
+ }
4217
+ const reasonGroups = Array.from(reasonMap.entries()).map(([reason, urls]) => ({
4218
+ reason,
4219
+ count: urls.length,
4220
+ urls: urls.map(formatRow)
4221
+ })).sort((a, b) => b.count - a.count);
3924
4222
  return {
3925
4223
  summary: {
3926
4224
  total,
@@ -3932,9 +4230,85 @@ async function googleRoutes(app, opts) {
3932
4230
  lastInspectedAt,
3933
4231
  indexed: indexedUrls.map(formatRow),
3934
4232
  notIndexed: notIndexedUrls.map(formatRow),
3935
- deindexed: deindexedUrls
4233
+ deindexed: deindexedUrls,
4234
+ reasonGroups
3936
4235
  };
3937
4236
  });
4237
+ app.get("/projects/:name/google/gsc/coverage/history", async (request) => {
4238
+ const project = resolveProject(app.db, request.params.name);
4239
+ const parsed = parseInt(request.query.limit ?? "90", 10);
4240
+ const limit = Number.isNaN(parsed) || parsed <= 0 ? 90 : parsed;
4241
+ const rows = app.db.select().from(gscCoverageSnapshots).where(eq12(gscCoverageSnapshots.projectId, project.id)).orderBy(desc2(gscCoverageSnapshots.date)).limit(limit).all();
4242
+ return rows.map((r) => ({
4243
+ date: r.date,
4244
+ indexed: r.indexed,
4245
+ notIndexed: r.notIndexed,
4246
+ reasonBreakdown: JSON.parse(r.reasonBreakdown)
4247
+ })).reverse();
4248
+ });
4249
+ app.get("/projects/:name/google/gsc/sitemaps", async (request, reply) => {
4250
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
4251
+ if (!googleClientId || !googleClientSecret) {
4252
+ const err = validationError("Google OAuth is not configured");
4253
+ return reply.status(err.statusCode).send(err.toJSON());
4254
+ }
4255
+ const store = requireConnectionStore(reply);
4256
+ if (!store) return;
4257
+ const project = resolveProject(app.db, request.params.name);
4258
+ const { accessToken, propertyId } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
4259
+ if (!propertyId) {
4260
+ const err = validationError('No GSC property configured for this connection. Set one with "canonry google set-property".');
4261
+ return reply.status(err.statusCode).send(err.toJSON());
4262
+ }
4263
+ const sitemaps = await listSitemaps(accessToken, propertyId);
4264
+ return { sitemaps };
4265
+ });
4266
+ app.post("/projects/:name/google/gsc/discover-sitemaps", async (request, reply) => {
4267
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
4268
+ if (!googleClientId || !googleClientSecret) {
4269
+ const err = validationError("Google OAuth is not configured");
4270
+ return reply.status(err.statusCode).send(err.toJSON());
4271
+ }
4272
+ const store = requireConnectionStore(reply);
4273
+ if (!store) return;
4274
+ const project = resolveProject(app.db, request.params.name);
4275
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
4276
+ if (!conn) {
4277
+ const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
4278
+ return reply.status(err.statusCode).send(err.toJSON());
4279
+ }
4280
+ if (!conn.propertyId) {
4281
+ const err = validationError("No GSC property configured for this connection");
4282
+ return reply.status(err.statusCode).send(err.toJSON());
4283
+ }
4284
+ const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
4285
+ const sitemaps = await listSitemaps(accessToken, conn.propertyId);
4286
+ if (sitemaps.length === 0) {
4287
+ const err = validationError("No sitemaps found for this GSC property. Submit a sitemap in Google Search Console first.");
4288
+ return reply.status(err.statusCode).send(err.toJSON());
4289
+ }
4290
+ const primary = sitemaps.find((s) => !s.isSitemapsIndex) ?? sitemaps[0];
4291
+ const sitemapUrl = primary.path;
4292
+ store.updateConnection(project.canonicalDomain, "gsc", {
4293
+ sitemapUrl,
4294
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4295
+ });
4296
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4297
+ const runId = crypto13.randomUUID();
4298
+ app.db.insert(runs).values({
4299
+ id: runId,
4300
+ projectId: project.id,
4301
+ kind: "inspect-sitemap",
4302
+ status: "queued",
4303
+ trigger: "manual",
4304
+ createdAt: now
4305
+ }).run();
4306
+ if (opts.onInspectSitemapRequested) {
4307
+ opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl });
4308
+ }
4309
+ const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
4310
+ return { sitemaps, primarySitemapUrl: sitemapUrl, run };
4311
+ });
3938
4312
  app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
3939
4313
  const store = requireConnectionStore(reply);
3940
4314
  if (!store) return;
@@ -3949,7 +4323,7 @@ async function googleRoutes(app, opts) {
3949
4323
  return reply.status(err.statusCode).send(err.toJSON());
3950
4324
  }
3951
4325
  const now = (/* @__PURE__ */ new Date()).toISOString();
3952
- const runId = crypto12.randomUUID();
4326
+ const runId = crypto13.randomUUID();
3953
4327
  app.db.insert(runs).values({
3954
4328
  id: runId,
3955
4329
  projectId: project.id,
@@ -3965,6 +4339,26 @@ async function googleRoutes(app, opts) {
3965
4339
  const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
3966
4340
  return run;
3967
4341
  });
4342
+ app.put("/projects/:name/google/connections/:type/sitemap", async (request, reply) => {
4343
+ const store = requireConnectionStore(reply);
4344
+ if (!store) return;
4345
+ const project = resolveProject(app.db, request.params.name);
4346
+ const { sitemapUrl } = request.body ?? {};
4347
+ if (!sitemapUrl || !sitemapUrl.trim()) {
4348
+ const err = validationError("sitemapUrl is required");
4349
+ return reply.status(err.statusCode).send(err.toJSON());
4350
+ }
4351
+ const conn = store.updateConnection(
4352
+ project.canonicalDomain,
4353
+ request.params.type,
4354
+ { sitemapUrl: sitemapUrl.trim(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
4355
+ );
4356
+ if (!conn) {
4357
+ const err = notFound("Google connection", request.params.type);
4358
+ return reply.status(err.statusCode).send(err.toJSON());
4359
+ }
4360
+ return { sitemapUrl: sitemapUrl.trim() };
4361
+ });
3968
4362
  app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
3969
4363
  const store = requireConnectionStore(reply);
3970
4364
  if (!store) return;
@@ -4093,7 +4487,7 @@ async function executeTrackedQuery(input) {
4093
4487
  model,
4094
4488
  tools: [{ googleSearch: {} }]
4095
4489
  });
4096
- const prompt = buildPrompt(input.keyword);
4490
+ const prompt = buildPrompt(input.keyword, input.location);
4097
4491
  const result = await generativeModel.generateContent(prompt);
4098
4492
  const response = result.response;
4099
4493
  const groundingMetadata = extractGroundingMetadata(response);
@@ -4117,7 +4511,10 @@ function normalizeResult(raw) {
4117
4511
  searchQueries: raw.searchQueries
4118
4512
  };
4119
4513
  }
4120
- function buildPrompt(keyword) {
4514
+ function buildPrompt(keyword, location) {
4515
+ if (location) {
4516
+ return `${keyword} (searching from ${location.city}, ${location.region}, ${location.country})`;
4517
+ }
4121
4518
  return keyword;
4122
4519
  }
4123
4520
  function extractAnswerText(rawResponse) {
@@ -4259,7 +4656,8 @@ var geminiAdapter = {
4259
4656
  keyword: input.keyword,
4260
4657
  canonicalDomains: input.canonicalDomains,
4261
4658
  competitorDomains: input.competitorDomains,
4262
- config: toGeminiConfig(config)
4659
+ config: toGeminiConfig(config),
4660
+ location: input.location
4263
4661
  });
4264
4662
  return {
4265
4663
  provider: "gemini",
@@ -4333,9 +4731,19 @@ async function healthcheck2(config) {
4333
4731
  async function executeTrackedQuery2(input) {
4334
4732
  const model = input.config.model ?? DEFAULT_MODEL2;
4335
4733
  const client = new OpenAI({ apiKey: input.config.apiKey });
4734
+ const webSearchTool = { type: "web_search_preview" };
4735
+ if (input.location) {
4736
+ webSearchTool.user_location = {
4737
+ type: "approximate",
4738
+ city: input.location.city,
4739
+ region: input.location.region,
4740
+ country: input.location.country,
4741
+ ...input.location.timezone ? { timezone: input.location.timezone } : {}
4742
+ };
4743
+ }
4336
4744
  const response = await client.responses.create({
4337
4745
  model,
4338
- tools: [{ type: "web_search_preview" }],
4746
+ tools: [webSearchTool],
4339
4747
  tool_choice: "required",
4340
4748
  input: buildPrompt2(input.keyword)
4341
4749
  });
@@ -4500,7 +4908,8 @@ var openaiAdapter = {
4500
4908
  keyword: input.keyword,
4501
4909
  canonicalDomains: input.canonicalDomains,
4502
4910
  competitorDomains: input.competitorDomains,
4503
- config: toOpenAIConfig(config)
4911
+ config: toOpenAIConfig(config),
4912
+ location: input.location
4504
4913
  });
4505
4914
  return {
4506
4915
  provider: "openai",
@@ -4575,16 +4984,24 @@ async function healthcheck3(config) {
4575
4984
  async function executeTrackedQuery3(input) {
4576
4985
  const model = input.config.model ?? DEFAULT_MODEL3;
4577
4986
  const client = new Anthropic({ apiKey: input.config.apiKey });
4987
+ const webSearchTool = {
4988
+ type: "web_search_20250305",
4989
+ name: "web_search",
4990
+ max_uses: 5
4991
+ };
4992
+ if (input.location) {
4993
+ webSearchTool.user_location = {
4994
+ type: "approximate",
4995
+ city: input.location.city,
4996
+ region: input.location.region,
4997
+ country: input.location.country,
4998
+ ...input.location.timezone ? { timezone: input.location.timezone } : {}
4999
+ };
5000
+ }
4578
5001
  const response = await client.messages.create({
4579
5002
  model,
4580
5003
  max_tokens: 4096,
4581
- tools: [
4582
- {
4583
- type: "web_search_20250305",
4584
- name: "web_search",
4585
- max_uses: 5
4586
- }
4587
- ],
5004
+ tools: [webSearchTool],
4588
5005
  messages: [{ role: "user", content: input.keyword }]
4589
5006
  });
4590
5007
  const groundingSources = extractGroundingSources2(response);
@@ -4737,7 +5154,8 @@ var claudeAdapter = {
4737
5154
  keyword: input.keyword,
4738
5155
  canonicalDomains: input.canonicalDomains,
4739
5156
  competitorDomains: input.competitorDomains,
4740
- config: toClaudeConfig(config)
5157
+ config: toClaudeConfig(config),
5158
+ location: input.location
4741
5159
  });
4742
5160
  return {
4743
5161
  provider: "claude",
@@ -4827,7 +5245,7 @@ async function executeTrackedQuery4(input) {
4827
5245
  },
4828
5246
  {
4829
5247
  role: "user",
4830
- content: buildPrompt3(input.keyword)
5248
+ content: buildPrompt3(input.keyword, input.location)
4831
5249
  }
4832
5250
  ]
4833
5251
  });
@@ -4850,8 +5268,9 @@ function normalizeResult4(raw) {
4850
5268
  searchQueries: raw.searchQueries
4851
5269
  };
4852
5270
  }
4853
- function buildPrompt3(keyword) {
4854
- return `Based on your training knowledge, what websites, services, or organizations are commonly associated with "${keyword}"? List the most relevant ones and include their domain names (e.g. example.com) where you know them.`;
5271
+ function buildPrompt3(keyword, location) {
5272
+ const locationContext = location ? ` The user is searching from ${location.city}, ${location.region}, ${location.country}.` : "";
5273
+ return `Based on your training knowledge, what websites, services, or organizations are commonly associated with "${keyword}"?${locationContext} List the most relevant ones and include their domain names (e.g. example.com) where you know them.`;
4855
5274
  }
4856
5275
  function extractAnswerText2(rawResponse) {
4857
5276
  try {
@@ -4922,7 +5341,8 @@ var localAdapter = {
4922
5341
  keyword: input.keyword,
4923
5342
  canonicalDomains: input.canonicalDomains,
4924
5343
  competitorDomains: input.competitorDomains,
4925
- config: toLocalConfig(config)
5344
+ config: toLocalConfig(config),
5345
+ location: input.location
4926
5346
  });
4927
5347
  return {
4928
5348
  provider: "local",
@@ -4989,6 +5409,7 @@ function upsertGoogleConnection(config, connection) {
4989
5409
  const normalized = {
4990
5410
  ...connection,
4991
5411
  propertyId: connection.propertyId ?? null,
5412
+ sitemapUrl: connection.sitemapUrl ?? null,
4992
5413
  refreshToken: connection.refreshToken ?? null,
4993
5414
  tokenExpiresAt: connection.tokenExpiresAt ?? null,
4994
5415
  scopes: connection.scopes ?? []
@@ -5007,6 +5428,7 @@ function patchGoogleConnection(config, domain, connectionType, patch) {
5007
5428
  ...existing,
5008
5429
  ...patch,
5009
5430
  propertyId: Object.prototype.hasOwnProperty.call(patch, "propertyId") ? patch.propertyId ?? null : existing.propertyId ?? null,
5431
+ sitemapUrl: Object.prototype.hasOwnProperty.call(patch, "sitemapUrl") ? patch.sitemapUrl ?? null : existing.sitemapUrl ?? null,
5010
5432
  refreshToken: Object.prototype.hasOwnProperty.call(patch, "refreshToken") ? patch.refreshToken ?? null : existing.refreshToken ?? null,
5011
5433
  tokenExpiresAt: Object.prototype.hasOwnProperty.call(patch, "tokenExpiresAt") ? patch.tokenExpiresAt ?? null : existing.tokenExpiresAt ?? null,
5012
5434
  scopes: patch.scopes ?? existing.scopes ?? []
@@ -5026,7 +5448,7 @@ function removeGoogleConnection(config, domain, connectionType) {
5026
5448
  }
5027
5449
 
5028
5450
  // src/job-runner.ts
5029
- import crypto13 from "crypto";
5451
+ import crypto14 from "crypto";
5030
5452
  import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
5031
5453
  var JobRunner = class {
5032
5454
  db;
@@ -5045,7 +5467,7 @@ var JobRunner = class {
5045
5467
  console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
5046
5468
  }
5047
5469
  }
5048
- async executeRun(runId, projectId, providerOverride) {
5470
+ async executeRun(runId, projectId, providerOverride, locationOverride) {
5049
5471
  const now = (/* @__PURE__ */ new Date()).toISOString();
5050
5472
  const startTime = Date.now();
5051
5473
  try {
@@ -5054,6 +5476,17 @@ var JobRunner = class {
5054
5476
  if (!project) {
5055
5477
  throw new Error(`Project ${projectId} not found`);
5056
5478
  }
5479
+ let runLocation;
5480
+ if (locationOverride === null) {
5481
+ runLocation = void 0;
5482
+ } else if (locationOverride) {
5483
+ runLocation = locationOverride;
5484
+ } else {
5485
+ const projectLocations = JSON.parse(project.locations || "[]");
5486
+ if (project.defaultLocation && projectLocations.length > 0) {
5487
+ runLocation = projectLocations.find((l) => l.label === project.defaultLocation);
5488
+ }
5489
+ }
5057
5490
  const projectProviders = providerOverride ?? JSON.parse(project.providers || "[]");
5058
5491
  const activeProviders = this.registry.getForProject(projectProviders);
5059
5492
  if (activeProviders.length === 0) {
@@ -5098,7 +5531,8 @@ var JobRunner = class {
5098
5531
  {
5099
5532
  keyword: kw.keyword,
5100
5533
  canonicalDomains: allDomains,
5101
- competitorDomains
5534
+ competitorDomains,
5535
+ location: runLocation
5102
5536
  },
5103
5537
  config
5104
5538
  );
@@ -5107,7 +5541,7 @@ var JobRunner = class {
5107
5541
  const citationState = determineCitationState(normalized, allDomains);
5108
5542
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
5109
5543
  this.db.insert(querySnapshots).values({
5110
- id: crypto13.randomUUID(),
5544
+ id: crypto14.randomUUID(),
5111
5545
  runId,
5112
5546
  keywordId: kw.id,
5113
5547
  provider: providerName,
@@ -5116,6 +5550,7 @@ var JobRunner = class {
5116
5550
  answerText: normalized.answerText,
5117
5551
  citedDomains: JSON.stringify(normalized.citedDomains),
5118
5552
  competitorOverlap: JSON.stringify(overlap),
5553
+ location: runLocation?.label ?? null,
5119
5554
  rawResponse: JSON.stringify({
5120
5555
  model: raw.model,
5121
5556
  groundingSources: normalized.groundingSources,
@@ -5151,7 +5586,8 @@ var JobRunner = class {
5151
5586
  providerCount: activeProviders.length,
5152
5587
  providers: activeProviders.map((p) => p.adapter.name),
5153
5588
  keywordCount: projectKeywords.length,
5154
- durationMs: Date.now() - startTime
5589
+ durationMs: Date.now() - startTime,
5590
+ ...runLocation ? { location: runLocation.label } : {}
5155
5591
  });
5156
5592
  for (const p of activeProviders) {
5157
5593
  this.incrementUsage(`${projectId}:${p.adapter.name}`, "queries", queriesPerProvider);
@@ -5204,7 +5640,7 @@ var JobRunner = class {
5204
5640
  incrementUsage(scope, metric, count) {
5205
5641
  const now = /* @__PURE__ */ new Date();
5206
5642
  const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
5207
- const id = crypto13.randomUUID();
5643
+ const id = crypto14.randomUUID();
5208
5644
  const existing = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
5209
5645
  if (existing) {
5210
5646
  this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq13(usageCounters.id, existing.id)).run();
@@ -5287,7 +5723,7 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
5287
5723
  }
5288
5724
 
5289
5725
  // src/gsc-sync.ts
5290
- import crypto14 from "crypto";
5726
+ import crypto15 from "crypto";
5291
5727
  import { eq as eq14, and as and4, sql as sql3 } from "drizzle-orm";
5292
5728
  function formatDate(d) {
5293
5729
  return d.toISOString().split("T")[0];
@@ -5352,7 +5788,7 @@ async function executeGscSync(db, runId, projectId, opts) {
5352
5788
  for (const row of batch) {
5353
5789
  const [query, page, country, device, date] = row.keys;
5354
5790
  db.insert(gscSearchData).values({
5355
- id: crypto14.randomUUID(),
5791
+ id: crypto15.randomUUID(),
5356
5792
  projectId,
5357
5793
  syncRunId: runId,
5358
5794
  date: date ?? "",
@@ -5386,7 +5822,7 @@ async function executeGscSync(db, runId, projectId, opts) {
5386
5822
  const rich = ir.richResultsResult;
5387
5823
  const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
5388
5824
  db.insert(gscUrlInspections).values({
5389
- id: crypto14.randomUUID(),
5825
+ id: crypto15.randomUUID(),
5390
5826
  projectId,
5391
5827
  syncRunId: runId,
5392
5828
  url: pageUrl,
@@ -5407,8 +5843,40 @@ async function executeGscSync(db, runId, projectId, opts) {
5407
5843
  console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
5408
5844
  }
5409
5845
  }
5846
+ const allInspections = db.select().from(gscUrlInspections).where(eq14(gscUrlInspections.projectId, projectId)).all();
5847
+ const latestByUrl = /* @__PURE__ */ new Map();
5848
+ for (const row of allInspections) {
5849
+ const existing = latestByUrl.get(row.url);
5850
+ if (!existing || row.inspectedAt > existing.inspectedAt) {
5851
+ latestByUrl.set(row.url, row);
5852
+ }
5853
+ }
5854
+ let snapIndexed = 0;
5855
+ let snapNotIndexed = 0;
5856
+ const reasonCounts = {};
5857
+ for (const [, row] of latestByUrl) {
5858
+ if (row.indexingState === "INDEXING_ALLOWED") {
5859
+ snapIndexed++;
5860
+ } else {
5861
+ snapNotIndexed++;
5862
+ const reason = row.coverageState ?? "Unknown";
5863
+ reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1;
5864
+ }
5865
+ }
5866
+ const snapshotDate = formatDate(/* @__PURE__ */ new Date());
5867
+ db.delete(gscCoverageSnapshots).where(and4(eq14(gscCoverageSnapshots.projectId, projectId), eq14(gscCoverageSnapshots.date, snapshotDate))).run();
5868
+ db.insert(gscCoverageSnapshots).values({
5869
+ id: crypto15.randomUUID(),
5870
+ projectId,
5871
+ syncRunId: runId,
5872
+ date: snapshotDate,
5873
+ indexed: snapIndexed,
5874
+ notIndexed: snapNotIndexed,
5875
+ reasonBreakdown: JSON.stringify(reasonCounts),
5876
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5877
+ }).run();
5410
5878
  db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
5411
- console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections.`);
5879
+ console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections, coverage snapshot: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
5412
5880
  } catch (err) {
5413
5881
  const errorMsg = err instanceof Error ? err.message : String(err);
5414
5882
  db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
@@ -5418,8 +5886,8 @@ async function executeGscSync(db, runId, projectId, opts) {
5418
5886
  }
5419
5887
 
5420
5888
  // src/gsc-inspect-sitemap.ts
5421
- import crypto15 from "crypto";
5422
- import { eq as eq15 } from "drizzle-orm";
5889
+ import crypto16 from "crypto";
5890
+ import { eq as eq15, and as and5 } from "drizzle-orm";
5423
5891
 
5424
5892
  // src/sitemap-parser.ts
5425
5893
  var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
@@ -5534,7 +6002,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
5534
6002
  const rich = ir.richResultsResult;
5535
6003
  const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
5536
6004
  db.insert(gscUrlInspections).values({
5537
- id: crypto15.randomUUID(),
6005
+ id: crypto16.randomUUID(),
5538
6006
  projectId,
5539
6007
  syncRunId: runId,
5540
6008
  url: pageUrl,
@@ -5561,9 +6029,41 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
5561
6029
  await new Promise((r) => setTimeout(r, 1e3));
5562
6030
  }
5563
6031
  }
6032
+ const allInspections = db.select().from(gscUrlInspections).where(eq15(gscUrlInspections.projectId, projectId)).all();
6033
+ const latestByUrl = /* @__PURE__ */ new Map();
6034
+ for (const row of allInspections) {
6035
+ const existing = latestByUrl.get(row.url);
6036
+ if (!existing || row.inspectedAt > existing.inspectedAt) {
6037
+ latestByUrl.set(row.url, row);
6038
+ }
6039
+ }
6040
+ let snapIndexed = 0;
6041
+ let snapNotIndexed = 0;
6042
+ const reasonCounts = {};
6043
+ for (const [, row] of latestByUrl) {
6044
+ if (row.indexingState === "INDEXING_ALLOWED") {
6045
+ snapIndexed++;
6046
+ } else {
6047
+ snapNotIndexed++;
6048
+ const reason = row.coverageState ?? "Unknown";
6049
+ reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1;
6050
+ }
6051
+ }
6052
+ const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
6053
+ db.delete(gscCoverageSnapshots).where(and5(eq15(gscCoverageSnapshots.projectId, projectId), eq15(gscCoverageSnapshots.date, snapshotDate))).run();
6054
+ db.insert(gscCoverageSnapshots).values({
6055
+ id: crypto16.randomUUID(),
6056
+ projectId,
6057
+ syncRunId: runId,
6058
+ date: snapshotDate,
6059
+ indexed: snapIndexed,
6060
+ notIndexed: snapNotIndexed,
6061
+ reasonBreakdown: JSON.stringify(reasonCounts),
6062
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
6063
+ }).run();
5564
6064
  const status = errors > 0 && inspected > 0 ? "partial" : errors === urls.length ? "failed" : "completed";
5565
6065
  db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
5566
- console.log(`[Inspect Sitemap] Done. ${inspected} inspected, ${errors} errors out of ${urls.length} URLs.`);
6066
+ console.log(`[Inspect Sitemap] Done. ${inspected} inspected, ${errors} errors out of ${urls.length} URLs. Coverage: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
5567
6067
  } catch (err) {
5568
6068
  const errorMsg = err instanceof Error ? err.message : String(err);
5569
6069
  db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
@@ -5734,8 +6234,8 @@ var Scheduler = class {
5734
6234
  };
5735
6235
 
5736
6236
  // src/notifier.ts
5737
- import { eq as eq17, desc as desc3, and as and5, or as or2 } from "drizzle-orm";
5738
- import crypto16 from "crypto";
6237
+ import { eq as eq17, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
6238
+ import crypto17 from "crypto";
5739
6239
  var Notifier = class {
5740
6240
  db;
5741
6241
  serverUrl;
@@ -5797,7 +6297,7 @@ var Notifier = class {
5797
6297
  }
5798
6298
  computeTransitions(runId, projectId) {
5799
6299
  const recentRuns = this.db.select().from(runs).where(
5800
- and5(
6300
+ and6(
5801
6301
  eq17(runs.projectId, projectId),
5802
6302
  or2(eq17(runs.status, "completed"), eq17(runs.status, "partial"))
5803
6303
  )
@@ -5873,7 +6373,7 @@ var Notifier = class {
5873
6373
  }
5874
6374
  logDelivery(projectId, notificationId, event, status, error) {
5875
6375
  this.db.insert(auditLog).values({
5876
- id: crypto16.randomUUID(),
6376
+ id: crypto17.randomUUID(),
5877
6377
  projectId,
5878
6378
  actor: "scheduler",
5879
6379
  action: `notification.${status}`,
@@ -6096,7 +6596,7 @@ async function createServer(opts) {
6096
6596
  configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
6097
6597
  };
6098
6598
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
6099
- const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto17.randomBytes(32).toString("hex");
6599
+ const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto18.randomBytes(32).toString("hex");
6100
6600
  const googleConnectionStore = {
6101
6601
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
6102
6602
  getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
@@ -6155,8 +6655,8 @@ async function createServer(opts) {
6155
6655
  },
6156
6656
  providerSummary,
6157
6657
  googleSettingsSummary,
6158
- onRunCreated: (runId, projectId, providers2) => {
6159
- jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
6658
+ onRunCreated: (runId, projectId, providers2, location) => {
6659
+ jobRunner.executeRun(runId, projectId, providers2, location).catch((err) => {
6160
6660
  app.log.error({ runId, err }, "Job runner failed");
6161
6661
  });
6162
6662
  },
@@ -6210,7 +6710,7 @@ async function createServer(opts) {
6210
6710
  const targetProjectIds = affectedProjectIds.length > 0 ? affectedProjectIds : [null];
6211
6711
  const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6212
6712
  opts.db.insert(auditLog).values(targetProjectIds.map((projectId) => ({
6213
- id: crypto17.randomUUID(),
6713
+ id: crypto18.randomUUID(),
6214
6714
  projectId,
6215
6715
  actor: "api",
6216
6716
  action: existing ? "provider.updated" : "provider.created",