@ainyc/canonry 1.12.0 → 1.13.3

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 crypto16 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) => [
@@ -356,6 +370,19 @@ var gscUrlInspections = sqliteTable("gsc_url_inspections", {
356
370
  index("idx_gsc_inspect_run").on(table.syncRunId),
357
371
  index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
358
372
  ]);
373
+ var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
374
+ id: text("id").primaryKey(),
375
+ projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
376
+ syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
377
+ date: text("date").notNull(),
378
+ indexed: integer("indexed").notNull().default(0),
379
+ notIndexed: integer("not_indexed").notNull().default(0),
380
+ reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
381
+ createdAt: text("created_at").notNull()
382
+ }, (table) => [
383
+ index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
384
+ index("idx_gsc_coverage_snap_run").on(table.syncRunId)
385
+ ]);
359
386
  var usageCounters = sqliteTable("usage_counters", {
360
387
  id: text("id").primaryKey(),
361
388
  scope: text("scope").notNull(),
@@ -573,7 +600,26 @@ var MIGRATIONS = [
573
600
  )`,
574
601
  `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
575
602
  `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)`
603
+ `CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
604
+ // v7: GSC coverage snapshots for historical tracking
605
+ `CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
606
+ id TEXT PRIMARY KEY,
607
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
608
+ sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
609
+ date TEXT NOT NULL,
610
+ indexed INTEGER NOT NULL DEFAULT 0,
611
+ not_indexed INTEGER NOT NULL DEFAULT 0,
612
+ reason_breakdown TEXT NOT NULL DEFAULT '{}',
613
+ created_at TEXT NOT NULL
614
+ )`,
615
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
616
+ `CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
617
+ // v8: Location-aware sweeps — project locations + snapshot location tag
618
+ `ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
619
+ `ALTER TABLE projects ADD COLUMN default_location TEXT`,
620
+ `ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
621
+ // v9: Add location column to runs for per-location run tracking
622
+ `ALTER TABLE runs ADD COLUMN location TEXT`
577
623
  ];
578
624
  function migrate(db) {
579
625
  const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -604,6 +650,13 @@ function parseProviderName(input) {
604
650
  const lower = input.trim().toLowerCase();
605
651
  return PROVIDER_NAMES.includes(lower) ? lower : void 0;
606
652
  }
653
+ var locationContextSchema = z.object({
654
+ label: z.string().min(1),
655
+ city: z.string().min(1),
656
+ region: z.string().min(1),
657
+ country: z.string().length(2),
658
+ timezone: z.string().optional()
659
+ });
607
660
 
608
661
  // ../contracts/src/notification.ts
609
662
  import { z as z2 } from "zod";
@@ -664,6 +717,8 @@ var configSpecSchema = z3.object({
664
717
  keywords: z3.array(z3.string().min(1)).optional().default([]),
665
718
  competitors: z3.array(z3.string().min(1)).optional().default([]),
666
719
  providers: z3.array(providerNameSchema).optional().default([]),
720
+ locations: z3.array(locationContextSchema).optional().default([]),
721
+ defaultLocation: z3.string().optional(),
667
722
  schedule: configScheduleSchema,
668
723
  notifications: z3.array(configNotificationSchema).optional().default([]),
669
724
  google: configGoogleSchema
@@ -806,6 +861,37 @@ var gscUrlInspectionDtoSchema = z4.object({
806
861
  inspectedAt: z4.string()
807
862
  });
808
863
  var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
864
+ var gscDeindexedRowSchema = z4.object({
865
+ url: z4.string(),
866
+ previousState: z4.string().nullable(),
867
+ currentState: z4.string().nullable(),
868
+ transitionDate: z4.string()
869
+ });
870
+ var gscReasonGroupSchema = z4.object({
871
+ reason: z4.string(),
872
+ count: z4.number(),
873
+ urls: z4.array(gscUrlInspectionDtoSchema).default([])
874
+ });
875
+ var gscCoverageSummaryDtoSchema = z4.object({
876
+ summary: z4.object({
877
+ total: z4.number(),
878
+ indexed: z4.number(),
879
+ notIndexed: z4.number(),
880
+ deindexed: z4.number(),
881
+ percentage: z4.number()
882
+ }),
883
+ lastInspectedAt: z4.string().nullable(),
884
+ indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
885
+ notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
886
+ deindexed: z4.array(gscDeindexedRowSchema).default([]),
887
+ reasonGroups: z4.array(gscReasonGroupSchema).default([])
888
+ });
889
+ var gscCoverageSnapshotDtoSchema = z4.object({
890
+ date: z4.string(),
891
+ indexed: z4.number(),
892
+ notIndexed: z4.number(),
893
+ reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
894
+ });
809
895
 
810
896
  // ../contracts/src/project.ts
811
897
  import { z as z5 } from "zod";
@@ -820,6 +906,8 @@ var projectDtoSchema = z5.object({
820
906
  language: z5.string().min(2),
821
907
  tags: z5.array(z5.string()).default([]),
822
908
  labels: z5.record(z5.string(), z5.string()).default({}),
909
+ locations: z5.array(locationContextSchema).default([]),
910
+ defaultLocation: z5.string().nullable().optional(),
823
911
  configSource: configSourceSchema.default("cli"),
824
912
  configRevision: z5.number().int().positive().default(1),
825
913
  createdAt: z5.string().optional(),
@@ -853,7 +941,7 @@ function effectiveDomains(project) {
853
941
  // ../contracts/src/run.ts
854
942
  import { z as z6 } from "zod";
855
943
  var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
856
- var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync"]);
944
+ var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
857
945
  var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
858
946
  var citationStateSchema = z6.enum(["cited", "not-cited"]);
859
947
  var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
@@ -863,6 +951,7 @@ var runDtoSchema = z6.object({
863
951
  kind: runKindSchema,
864
952
  status: runStatusSchema,
865
953
  trigger: runTriggerSchema.default("manual"),
954
+ location: z6.string().nullable().optional(),
866
955
  startedAt: z6.string().nullable().optional(),
867
956
  finishedAt: z6.string().nullable().optional(),
868
957
  error: z6.string().nullable().optional(),
@@ -886,6 +975,7 @@ var querySnapshotDtoSchema = z6.object({
886
975
  groundingSources: z6.array(groundingSourceSchema).default([]),
887
976
  searchQueries: z6.array(z6.string()).default([]),
888
977
  model: z6.string().nullable().optional(),
978
+ location: z6.string().nullable().optional(),
889
979
  createdAt: z6.string()
890
980
  });
891
981
  var auditLogEntrySchema = z6.object({
@@ -1004,6 +1094,8 @@ async function projectRoutes(app, opts) {
1004
1094
  tags: JSON.stringify(body.tags ?? []),
1005
1095
  labels: JSON.stringify(body.labels ?? {}),
1006
1096
  providers: JSON.stringify(body.providers ?? []),
1097
+ locations: JSON.stringify(body.locations ?? JSON.parse(existing.locations || "[]")),
1098
+ defaultLocation: body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing.defaultLocation,
1007
1099
  configSource: body.configSource ?? "api",
1008
1100
  configRevision: existing.configRevision + 1,
1009
1101
  updatedAt: now
@@ -1030,6 +1122,8 @@ async function projectRoutes(app, opts) {
1030
1122
  tags: JSON.stringify(body.tags ?? []),
1031
1123
  labels: JSON.stringify(body.labels ?? {}),
1032
1124
  providers: JSON.stringify(body.providers ?? []),
1125
+ locations: JSON.stringify(body.locations ?? []),
1126
+ defaultLocation: body.defaultLocation ?? null,
1033
1127
  configSource: body.configSource ?? "api",
1034
1128
  configRevision: 1,
1035
1129
  createdAt: now,
@@ -1083,6 +1177,131 @@ async function projectRoutes(app, opts) {
1083
1177
  opts.onProjectDeleted?.(project.id);
1084
1178
  return reply.status(204).send();
1085
1179
  });
1180
+ app.post("/projects/:name/locations", async (request, reply) => {
1181
+ let project;
1182
+ try {
1183
+ project = resolveProject(app.db, request.params.name);
1184
+ } catch (e) {
1185
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1186
+ const err = e;
1187
+ return reply.status(err.statusCode).send(err.toJSON());
1188
+ }
1189
+ throw e;
1190
+ }
1191
+ const parsed = locationContextSchema.safeParse(request.body);
1192
+ if (!parsed.success) {
1193
+ const err = validationError(parsed.error.issues.map((i) => i.message).join(", "));
1194
+ return reply.status(err.statusCode).send(err.toJSON());
1195
+ }
1196
+ const location = parsed.data;
1197
+ const existing = JSON.parse(project.locations || "[]");
1198
+ if (existing.some((l) => l.label === location.label)) {
1199
+ const err = validationError(`Location "${location.label}" already exists`);
1200
+ return reply.status(err.statusCode).send(err.toJSON());
1201
+ }
1202
+ existing.push(location);
1203
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1204
+ app.db.update(projects).set({
1205
+ locations: JSON.stringify(existing),
1206
+ updatedAt: now
1207
+ }).where(eq3(projects.id, project.id)).run();
1208
+ writeAuditLog(app.db, {
1209
+ projectId: project.id,
1210
+ actor: "api",
1211
+ action: "location.added",
1212
+ entityType: "location",
1213
+ entityId: location.label
1214
+ });
1215
+ return reply.status(201).send(location);
1216
+ });
1217
+ app.get("/projects/:name/locations", async (request, reply) => {
1218
+ let project;
1219
+ try {
1220
+ project = resolveProject(app.db, request.params.name);
1221
+ } catch (e) {
1222
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1223
+ const err = e;
1224
+ return reply.status(err.statusCode).send(err.toJSON());
1225
+ }
1226
+ throw e;
1227
+ }
1228
+ const locations = JSON.parse(project.locations || "[]");
1229
+ return reply.send({
1230
+ locations,
1231
+ defaultLocation: project.defaultLocation
1232
+ });
1233
+ });
1234
+ app.delete("/projects/:name/locations/:label", async (request, reply) => {
1235
+ let project;
1236
+ try {
1237
+ project = resolveProject(app.db, request.params.name);
1238
+ } catch (e) {
1239
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1240
+ const err = e;
1241
+ return reply.status(err.statusCode).send(err.toJSON());
1242
+ }
1243
+ throw e;
1244
+ }
1245
+ const label = decodeURIComponent(request.params.label);
1246
+ const existing = JSON.parse(project.locations || "[]");
1247
+ const filtered = existing.filter((l) => l.label !== label);
1248
+ if (filtered.length === existing.length) {
1249
+ const err = validationError(`Location "${label}" not found`);
1250
+ return reply.status(err.statusCode).send(err.toJSON());
1251
+ }
1252
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1253
+ const updates = {
1254
+ locations: JSON.stringify(filtered),
1255
+ updatedAt: now
1256
+ };
1257
+ if (project.defaultLocation === label) {
1258
+ updates.defaultLocation = null;
1259
+ }
1260
+ app.db.update(projects).set(updates).where(eq3(projects.id, project.id)).run();
1261
+ writeAuditLog(app.db, {
1262
+ projectId: project.id,
1263
+ actor: "api",
1264
+ action: "location.removed",
1265
+ entityType: "location",
1266
+ entityId: label
1267
+ });
1268
+ return reply.status(204).send();
1269
+ });
1270
+ app.put("/projects/:name/locations/default", async (request, reply) => {
1271
+ let project;
1272
+ try {
1273
+ project = resolveProject(app.db, request.params.name);
1274
+ } catch (e) {
1275
+ if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
1276
+ const err = e;
1277
+ return reply.status(err.statusCode).send(err.toJSON());
1278
+ }
1279
+ throw e;
1280
+ }
1281
+ const label = request.body?.label;
1282
+ if (!label) {
1283
+ const err = validationError("label is required");
1284
+ return reply.status(err.statusCode).send(err.toJSON());
1285
+ }
1286
+ const existing = JSON.parse(project.locations || "[]");
1287
+ if (!existing.some((l) => l.label === label)) {
1288
+ const err = validationError(`Location "${label}" not found. Add it first.`);
1289
+ return reply.status(err.statusCode).send(err.toJSON());
1290
+ }
1291
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1292
+ app.db.update(projects).set({
1293
+ defaultLocation: label,
1294
+ updatedAt: now
1295
+ }).where(eq3(projects.id, project.id)).run();
1296
+ writeAuditLog(app.db, {
1297
+ projectId: project.id,
1298
+ actor: "api",
1299
+ action: "location.default-set",
1300
+ entityType: "location",
1301
+ entityId: label
1302
+ });
1303
+ return reply.send({ defaultLocation: label });
1304
+ });
1086
1305
  app.get("/projects/:name/export", async (request, reply) => {
1087
1306
  let project;
1088
1307
  try {
@@ -1114,6 +1333,8 @@ async function projectRoutes(app, opts) {
1114
1333
  keywords: kws.map((k) => k.keyword),
1115
1334
  competitors: comps.map((c) => c.domain),
1116
1335
  providers: JSON.parse(project.providers || "[]"),
1336
+ locations: JSON.parse(project.locations || "[]"),
1337
+ ...project.defaultLocation ? { defaultLocation: project.defaultLocation } : {},
1117
1338
  notifications: notificationRows.map((row) => {
1118
1339
  const cfg = JSON.parse(row.config);
1119
1340
  return {
@@ -1146,6 +1367,8 @@ function formatProject(row) {
1146
1367
  tags: JSON.parse(row.tags),
1147
1368
  labels: JSON.parse(row.labels),
1148
1369
  providers: JSON.parse(row.providers || "[]"),
1370
+ locations: JSON.parse(row.locations || "[]"),
1371
+ defaultLocation: row.defaultLocation,
1149
1372
  configSource: row.configSource,
1150
1373
  configRevision: row.configRevision,
1151
1374
  createdAt: row.createdAt,
@@ -1193,6 +1416,34 @@ async function keywordRoutes(app, opts) {
1193
1416
  const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1194
1417
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
1195
1418
  });
1419
+ app.delete("/projects/:name/keywords", async (request, reply) => {
1420
+ const project = resolveProjectSafe(app, request.params.name, reply);
1421
+ if (!project) return;
1422
+ const body = request.body;
1423
+ if (!body || !Array.isArray(body.keywords) || body.keywords.length === 0) {
1424
+ const err = validationError('Body must contain a non-empty "keywords" array');
1425
+ return reply.status(err.statusCode).send(err.toJSON());
1426
+ }
1427
+ const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1428
+ const toDelete = new Set(body.keywords);
1429
+ const idsToDelete = existing.filter((k) => toDelete.has(k.keyword)).map((k) => k.id);
1430
+ if (idsToDelete.length > 0) {
1431
+ app.db.transaction((tx) => {
1432
+ for (const id of idsToDelete) {
1433
+ tx.delete(keywords).where(eq4(keywords.id, id)).run();
1434
+ }
1435
+ writeAuditLog(tx, {
1436
+ projectId: project.id,
1437
+ actor: "api",
1438
+ action: "keywords.deleted",
1439
+ entityType: "keyword",
1440
+ diff: { deleted: body.keywords.filter((kw) => existing.some((e) => e.keyword === kw)) }
1441
+ });
1442
+ });
1443
+ }
1444
+ const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
1445
+ return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
1446
+ });
1196
1447
  app.post("/projects/:name/keywords", async (request, reply) => {
1197
1448
  const project = resolveProjectSafe(app, request.params.name, reply);
1198
1449
  if (!project) return;
@@ -1337,6 +1588,7 @@ function resolveProjectSafe2(app, name, reply) {
1337
1588
  }
1338
1589
 
1339
1590
  // ../api-routes/src/runs.ts
1591
+ import crypto8 from "crypto";
1340
1592
  import { eq as eq7, asc } from "drizzle-orm";
1341
1593
 
1342
1594
  // ../api-routes/src/run-queue.ts
@@ -1363,6 +1615,7 @@ function queueRunIfProjectIdle(db, params) {
1363
1615
  kind,
1364
1616
  status: "queued",
1365
1617
  trigger,
1618
+ location: params.location ?? null,
1366
1619
  createdAt
1367
1620
  }).run();
1368
1621
  return { conflict: false, runId };
@@ -1391,11 +1644,60 @@ async function runRoutes(app, opts) {
1391
1644
  rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
1392
1645
  }
1393
1646
  const providers = rawProviders?.length ? rawProviders : void 0;
1647
+ let resolvedLocation;
1648
+ const projectLocations = JSON.parse(project.locations || "[]");
1649
+ if (request.body?.noLocation) {
1650
+ resolvedLocation = null;
1651
+ } else if (request.body?.allLocations) {
1652
+ } else if (request.body?.location) {
1653
+ const loc = projectLocations.find((l) => l.label === request.body.location);
1654
+ if (!loc) {
1655
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Location "${request.body.location}" not found. Configure it first.` } });
1656
+ }
1657
+ resolvedLocation = loc;
1658
+ }
1659
+ if (request.body?.allLocations) {
1660
+ if (projectLocations.length === 0) {
1661
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: "No locations configured for this project" } });
1662
+ }
1663
+ const newRuns = [];
1664
+ for (const loc of projectLocations) {
1665
+ const runId2 = crypto8.randomUUID();
1666
+ app.db.insert(runs).values({
1667
+ id: runId2,
1668
+ projectId: project.id,
1669
+ kind,
1670
+ status: "queued",
1671
+ trigger,
1672
+ location: loc.label,
1673
+ createdAt: now
1674
+ }).run();
1675
+ newRuns.push({ runId: runId2, loc });
1676
+ }
1677
+ const results = [];
1678
+ for (const { runId: runId2, loc } of newRuns) {
1679
+ writeAuditLog(app.db, {
1680
+ projectId: project.id,
1681
+ actor: "api",
1682
+ action: "run.created",
1683
+ entityType: "run",
1684
+ entityId: runId2
1685
+ });
1686
+ const r = app.db.select().from(runs).where(eq7(runs.id, runId2)).get();
1687
+ if (opts.onRunCreated) {
1688
+ opts.onRunCreated(runId2, project.id, providers, loc);
1689
+ }
1690
+ results.push({ ...formatRun(r), location: loc.label });
1691
+ }
1692
+ return reply.status(207).send(results);
1693
+ }
1694
+ const locationLabel = resolvedLocation?.label ?? null;
1394
1695
  const queueResult = queueRunIfProjectIdle(app.db, {
1395
1696
  createdAt: now,
1396
1697
  kind,
1397
1698
  projectId: project.id,
1398
- trigger
1699
+ trigger,
1700
+ location: locationLabel
1399
1701
  });
1400
1702
  if (queueResult.conflict) {
1401
1703
  const err = runInProgress(project.name);
@@ -1411,7 +1713,7 @@ async function runRoutes(app, opts) {
1411
1713
  });
1412
1714
  const run = app.db.select().from(runs).where(eq7(runs.id, runId)).get();
1413
1715
  if (opts.onRunCreated) {
1414
- opts.onRunCreated(runId, project.id, providers);
1716
+ opts.onRunCreated(runId, project.id, providers, resolvedLocation);
1415
1717
  }
1416
1718
  return reply.status(201).send(formatRun(run));
1417
1719
  });
@@ -1490,6 +1792,7 @@ async function runRoutes(app, opts) {
1490
1792
  answerText: querySnapshots.answerText,
1491
1793
  citedDomains: querySnapshots.citedDomains,
1492
1794
  competitorOverlap: querySnapshots.competitorOverlap,
1795
+ location: querySnapshots.location,
1493
1796
  rawResponse: querySnapshots.rawResponse,
1494
1797
  createdAt: querySnapshots.createdAt
1495
1798
  }).from(querySnapshots).leftJoin(keywords, eq7(querySnapshots.keywordId, keywords.id)).where(eq7(querySnapshots.runId, run.id)).all();
@@ -1508,6 +1811,7 @@ async function runRoutes(app, opts) {
1508
1811
  citedDomains: tryParseJson(s.citedDomains, []),
1509
1812
  competitorOverlap: tryParseJson(s.competitorOverlap, []),
1510
1813
  model: s.model ?? rawParsed.model,
1814
+ location: s.location,
1511
1815
  groundingSources: rawParsed.groundingSources,
1512
1816
  searchQueries: rawParsed.searchQueries,
1513
1817
  createdAt: s.createdAt
@@ -1523,6 +1827,7 @@ function formatRun(row) {
1523
1827
  kind: row.kind,
1524
1828
  status: row.status,
1525
1829
  trigger: row.trigger,
1830
+ location: row.location,
1526
1831
  startedAt: row.startedAt,
1527
1832
  finishedAt: row.finishedAt,
1528
1833
  error: row.error,
@@ -1558,7 +1863,7 @@ function resolveProjectSafe3(app, name, reply) {
1558
1863
  }
1559
1864
 
1560
1865
  // ../api-routes/src/apply.ts
1561
- import crypto9 from "crypto";
1866
+ import crypto10 from "crypto";
1562
1867
  import { eq as eq8 } from "drizzle-orm";
1563
1868
 
1564
1869
  // ../api-routes/src/schedule-utils.ts
@@ -1650,7 +1955,7 @@ function isValidTimezone(tz) {
1650
1955
  }
1651
1956
 
1652
1957
  // ../api-routes/src/webhooks.ts
1653
- import crypto8 from "crypto";
1958
+ import crypto9 from "crypto";
1654
1959
  import dns from "dns/promises";
1655
1960
  import http from "http";
1656
1961
  import https from "https";
@@ -1702,7 +2007,7 @@ async function deliverWebhook(target, payload, webhookSecret) {
1702
2007
  "User-Agent": "Canonry/0.1.0"
1703
2008
  };
1704
2009
  if (webhookSecret) {
1705
- headers["X-Canonry-Signature"] = "sha256=" + crypto8.createHmac("sha256", webhookSecret).update(body).digest("hex");
2010
+ headers["X-Canonry-Signature"] = "sha256=" + crypto9.createHmac("sha256", webhookSecret).update(body).digest("hex");
1706
2011
  }
1707
2012
  return await new Promise((resolve) => {
1708
2013
  const requestOptions = {
@@ -1833,6 +2138,8 @@ async function applyRoutes(app, opts) {
1833
2138
  language: config.spec.language,
1834
2139
  labels: JSON.stringify(config.metadata.labels),
1835
2140
  providers: JSON.stringify(config.spec.providers ?? []),
2141
+ locations: JSON.stringify(config.spec.locations ?? []),
2142
+ defaultLocation: config.spec.defaultLocation ?? null,
1836
2143
  configSource: "config-file",
1837
2144
  configRevision: existing.configRevision + 1,
1838
2145
  updatedAt: now
@@ -1845,7 +2152,7 @@ async function applyRoutes(app, opts) {
1845
2152
  entityId: projectId
1846
2153
  });
1847
2154
  } else {
1848
- projectId = crypto9.randomUUID();
2155
+ projectId = crypto10.randomUUID();
1849
2156
  app.db.insert(projects).values({
1850
2157
  id: projectId,
1851
2158
  name,
@@ -1857,6 +2164,8 @@ async function applyRoutes(app, opts) {
1857
2164
  tags: "[]",
1858
2165
  labels: JSON.stringify(config.metadata.labels),
1859
2166
  providers: JSON.stringify(config.spec.providers ?? []),
2167
+ locations: JSON.stringify(config.spec.locations ?? []),
2168
+ defaultLocation: config.spec.defaultLocation ?? null,
1860
2169
  configSource: "config-file",
1861
2170
  configRevision: 1,
1862
2171
  createdAt: now,
@@ -1874,7 +2183,7 @@ async function applyRoutes(app, opts) {
1874
2183
  tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
1875
2184
  for (const kw of config.spec.keywords) {
1876
2185
  tx.insert(keywords).values({
1877
- id: crypto9.randomUUID(),
2186
+ id: crypto10.randomUUID(),
1878
2187
  projectId,
1879
2188
  keyword: kw,
1880
2189
  createdAt: now
@@ -1890,7 +2199,7 @@ async function applyRoutes(app, opts) {
1890
2199
  tx.delete(competitors).where(eq8(competitors.projectId, projectId)).run();
1891
2200
  for (const domain of config.spec.competitors) {
1892
2201
  tx.insert(competitors).values({
1893
- id: crypto9.randomUUID(),
2202
+ id: crypto10.randomUUID(),
1894
2203
  projectId,
1895
2204
  domain,
1896
2205
  createdAt: now
@@ -1946,7 +2255,7 @@ async function applyRoutes(app, opts) {
1946
2255
  }).where(eq8(schedules.id, existingSched.id)).run();
1947
2256
  } else {
1948
2257
  app.db.insert(schedules).values({
1949
- id: crypto9.randomUUID(),
2258
+ id: crypto10.randomUUID(),
1950
2259
  projectId,
1951
2260
  cronExpr,
1952
2261
  preset,
@@ -1978,11 +2287,11 @@ async function applyRoutes(app, opts) {
1978
2287
  app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
1979
2288
  for (const notif of config.spec.notifications) {
1980
2289
  app.db.insert(notifications).values({
1981
- id: crypto9.randomUUID(),
2290
+ id: crypto10.randomUUID(),
1982
2291
  projectId,
1983
2292
  channel: notif.channel,
1984
2293
  config: JSON.stringify({ url: notif.url, events: notif.events }),
1985
- webhookSecret: crypto9.randomBytes(32).toString("hex"),
2294
+ webhookSecret: crypto10.randomBytes(32).toString("hex"),
1986
2295
  enabled: 1,
1987
2296
  createdAt: now,
1988
2297
  updatedAt: now
@@ -2011,6 +2320,8 @@ async function applyRoutes(app, opts) {
2011
2320
  tags: JSON.parse(project.tags),
2012
2321
  labels: JSON.parse(project.labels),
2013
2322
  providers: JSON.parse(project.providers || "[]"),
2323
+ locations: JSON.parse(project.locations || "[]"),
2324
+ defaultLocation: project.defaultLocation,
2014
2325
  configSource: project.configSource,
2015
2326
  configRevision: project.configRevision,
2016
2327
  createdAt: project.createdAt,
@@ -2052,10 +2363,13 @@ async function historyRoutes(app) {
2052
2363
  answerText: querySnapshots.answerText,
2053
2364
  citedDomains: querySnapshots.citedDomains,
2054
2365
  competitorOverlap: querySnapshots.competitorOverlap,
2366
+ location: querySnapshots.location,
2055
2367
  createdAt: querySnapshots.createdAt
2056
2368
  }).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc(querySnapshots.createdAt)).all();
2057
- const total = allSnapshots.length;
2058
- const paged = allSnapshots.slice(offset, offset + limit);
2369
+ const locationFilter = request.query.location;
2370
+ const filtered = locationFilter !== void 0 ? allSnapshots.filter((s) => s.location === (locationFilter || null)) : allSnapshots;
2371
+ const total = filtered.length;
2372
+ const paged = filtered.slice(offset, offset + limit);
2059
2373
  return reply.send({
2060
2374
  snapshots: paged.map((s) => ({
2061
2375
  id: s.id,
@@ -2068,6 +2382,7 @@ async function historyRoutes(app) {
2068
2382
  answerText: s.answerText,
2069
2383
  citedDomains: tryParseJson2(s.citedDomains, []),
2070
2384
  competitorOverlap: tryParseJson2(s.competitorOverlap, []),
2385
+ location: s.location,
2071
2386
  createdAt: s.createdAt
2072
2387
  })),
2073
2388
  total
@@ -2082,7 +2397,9 @@ async function historyRoutes(app) {
2082
2397
  return reply.send([]);
2083
2398
  }
2084
2399
  const runIds = new Set(projectRuns.map((r) => r.id));
2085
- const allSnapshots = app.db.select().from(querySnapshots).where(inArray(querySnapshots.runId, [...runIds])).all();
2400
+ const rawSnapshots = app.db.select().from(querySnapshots).where(inArray(querySnapshots.runId, [...runIds])).all();
2401
+ const timelineLocationFilter = request.query.location;
2402
+ const allSnapshots = timelineLocationFilter !== void 0 ? rawSnapshots.filter((s) => s.location === (timelineLocationFilter || null)) : rawSnapshots;
2086
2403
  const deduped = /* @__PURE__ */ new Map();
2087
2404
  for (const snap of allSnapshots) {
2088
2405
  const key = `${snap.runId}:${snap.keywordId}`;
@@ -2991,7 +3308,7 @@ async function telemetryRoutes(app, opts) {
2991
3308
  }
2992
3309
 
2993
3310
  // ../api-routes/src/schedules.ts
2994
- import crypto10 from "crypto";
3311
+ import crypto11 from "crypto";
2995
3312
  import { eq as eq10 } from "drizzle-orm";
2996
3313
  async function scheduleRoutes(app, opts) {
2997
3314
  app.put("/projects/:name/schedule", async (request, reply) => {
@@ -3038,7 +3355,7 @@ async function scheduleRoutes(app, opts) {
3038
3355
  }).where(eq10(schedules.id, existing.id)).run();
3039
3356
  } else {
3040
3357
  app.db.insert(schedules).values({
3041
- id: crypto10.randomUUID(),
3358
+ id: crypto11.randomUUID(),
3042
3359
  projectId: project.id,
3043
3360
  cronExpr,
3044
3361
  preset: preset ?? null,
@@ -3117,7 +3434,7 @@ function resolveProjectSafe5(app, name, reply) {
3117
3434
  }
3118
3435
 
3119
3436
  // ../api-routes/src/notifications.ts
3120
- import crypto11 from "crypto";
3437
+ import crypto12 from "crypto";
3121
3438
  import { eq as eq11 } from "drizzle-orm";
3122
3439
  var VALID_EVENTS = ["citation.lost", "citation.gained", "run.completed", "run.failed"];
3123
3440
  async function notificationRoutes(app) {
@@ -3151,8 +3468,8 @@ async function notificationRoutes(app) {
3151
3468
  });
3152
3469
  }
3153
3470
  const now = (/* @__PURE__ */ new Date()).toISOString();
3154
- const id = crypto11.randomUUID();
3155
- const webhookSecret = crypto11.randomBytes(32).toString("hex");
3471
+ const id = crypto12.randomUUID();
3472
+ const webhookSecret = crypto12.randomBytes(32).toString("hex");
3156
3473
  app.db.insert(notifications).values({
3157
3474
  id,
3158
3475
  projectId: project.id,
@@ -3271,7 +3588,7 @@ function resolveProjectSafe6(app, name, reply) {
3271
3588
  }
3272
3589
 
3273
3590
  // ../api-routes/src/google.ts
3274
- import crypto12 from "crypto";
3591
+ import crypto13 from "crypto";
3275
3592
  import { eq as eq12, and as and3, desc as desc2, sql as sql2 } from "drizzle-orm";
3276
3593
 
3277
3594
  // ../integration-google/src/constants.ts
@@ -3430,7 +3747,7 @@ async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
3430
3747
 
3431
3748
  // ../api-routes/src/google.ts
3432
3749
  function signState(payload, secret) {
3433
- return crypto12.createHmac("sha256", secret).update(payload).digest("hex");
3750
+ return crypto13.createHmac("sha256", secret).update(payload).digest("hex");
3434
3751
  }
3435
3752
  function buildSignedState(data, secret) {
3436
3753
  const payload = JSON.stringify(data);
@@ -3441,7 +3758,7 @@ function verifySignedState(encoded, secret) {
3441
3758
  try {
3442
3759
  const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
3443
3760
  const expected = signState(payload, secret);
3444
- if (!crypto12.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
3761
+ if (!crypto13.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
3445
3762
  return JSON.parse(payload);
3446
3763
  } catch {
3447
3764
  return null;
@@ -3667,7 +3984,7 @@ async function googleRoutes(app, opts) {
3667
3984
  return reply.status(err.statusCode).send(err.toJSON());
3668
3985
  }
3669
3986
  const now = (/* @__PURE__ */ new Date()).toISOString();
3670
- const runId = crypto12.randomUUID();
3987
+ const runId = crypto13.randomUUID();
3671
3988
  app.db.insert(runs).values({
3672
3989
  id: runId,
3673
3990
  projectId: project.id,
@@ -3729,7 +4046,7 @@ async function googleRoutes(app, opts) {
3729
4046
  const mob = ir.mobileUsabilityResult;
3730
4047
  const rich = ir.richResultsResult;
3731
4048
  const now = (/* @__PURE__ */ new Date()).toISOString();
3732
- const id = crypto12.randomUUID();
4049
+ const id = crypto13.randomUUID();
3733
4050
  app.db.insert(gscUrlInspections).values({
3734
4051
  id,
3735
4052
  projectId: project.id,
@@ -3803,7 +4120,7 @@ async function googleRoutes(app, opts) {
3803
4120
  if (inspections.length < 2) continue;
3804
4121
  const latest = inspections[0];
3805
4122
  const previous = inspections[1];
3806
- if (previous.indexingState?.toUpperCase() === "INDEXED" && latest.indexingState?.toUpperCase() !== "INDEXED") {
4123
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
3807
4124
  deindexed.push({
3808
4125
  url,
3809
4126
  previousState: previous.indexingState,
@@ -3814,6 +4131,138 @@ async function googleRoutes(app, opts) {
3814
4131
  }
3815
4132
  return deindexed;
3816
4133
  });
4134
+ app.get("/projects/:name/google/gsc/coverage", async (request) => {
4135
+ const project = resolveProject(app.db, request.params.name);
4136
+ const allInspections = app.db.select().from(gscUrlInspections).where(eq12(gscUrlInspections.projectId, project.id)).orderBy(desc2(gscUrlInspections.inspectedAt)).all();
4137
+ const latestByUrl = /* @__PURE__ */ new Map();
4138
+ const historyByUrl = /* @__PURE__ */ new Map();
4139
+ for (const row of allInspections) {
4140
+ if (!latestByUrl.has(row.url)) {
4141
+ latestByUrl.set(row.url, row);
4142
+ }
4143
+ const history = historyByUrl.get(row.url);
4144
+ if (history) {
4145
+ history.push(row);
4146
+ } else {
4147
+ historyByUrl.set(row.url, [row]);
4148
+ }
4149
+ }
4150
+ const indexedUrls = [];
4151
+ const notIndexedUrls = [];
4152
+ let lastInspectedAt = null;
4153
+ for (const [, row] of latestByUrl) {
4154
+ if (row.indexingState === "INDEXING_ALLOWED") {
4155
+ indexedUrls.push(row);
4156
+ } else {
4157
+ notIndexedUrls.push(row);
4158
+ }
4159
+ if (!lastInspectedAt || row.inspectedAt > lastInspectedAt) {
4160
+ lastInspectedAt = row.inspectedAt;
4161
+ }
4162
+ }
4163
+ const deindexedUrls = [];
4164
+ for (const [url, history] of historyByUrl) {
4165
+ if (history.length < 2) continue;
4166
+ const latest = history[0];
4167
+ const previous = history[1];
4168
+ if (previous.indexingState === "INDEXING_ALLOWED" && latest.indexingState !== "INDEXING_ALLOWED") {
4169
+ deindexedUrls.push({
4170
+ url,
4171
+ previousState: previous.indexingState,
4172
+ currentState: latest.indexingState,
4173
+ transitionDate: latest.inspectedAt
4174
+ });
4175
+ }
4176
+ }
4177
+ const total = latestByUrl.size;
4178
+ const indexed = indexedUrls.length;
4179
+ const notIndexed = notIndexedUrls.length;
4180
+ const formatRow = (r) => ({
4181
+ id: r.id,
4182
+ url: r.url,
4183
+ indexingState: r.indexingState,
4184
+ verdict: r.verdict,
4185
+ coverageState: r.coverageState,
4186
+ pageFetchState: r.pageFetchState,
4187
+ robotsTxtState: r.robotsTxtState,
4188
+ crawlTime: r.crawlTime,
4189
+ lastCrawlResult: r.lastCrawlResult,
4190
+ isMobileFriendly: r.isMobileFriendly === 1 ? true : r.isMobileFriendly === 0 ? false : null,
4191
+ richResults: JSON.parse(r.richResults),
4192
+ inspectedAt: r.inspectedAt
4193
+ });
4194
+ const reasonMap = /* @__PURE__ */ new Map();
4195
+ for (const row of notIndexedUrls) {
4196
+ const reason = row.coverageState ?? "Unknown";
4197
+ const existing = reasonMap.get(reason);
4198
+ if (existing) {
4199
+ existing.push(row);
4200
+ } else {
4201
+ reasonMap.set(reason, [row]);
4202
+ }
4203
+ }
4204
+ const reasonGroups = Array.from(reasonMap.entries()).map(([reason, urls]) => ({
4205
+ reason,
4206
+ count: urls.length,
4207
+ urls: urls.map(formatRow)
4208
+ })).sort((a, b) => b.count - a.count);
4209
+ return {
4210
+ summary: {
4211
+ total,
4212
+ indexed,
4213
+ notIndexed,
4214
+ deindexed: deindexedUrls.length,
4215
+ percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
4216
+ },
4217
+ lastInspectedAt,
4218
+ indexed: indexedUrls.map(formatRow),
4219
+ notIndexed: notIndexedUrls.map(formatRow),
4220
+ deindexed: deindexedUrls,
4221
+ reasonGroups
4222
+ };
4223
+ });
4224
+ app.get("/projects/:name/google/gsc/coverage/history", async (request) => {
4225
+ const project = resolveProject(app.db, request.params.name);
4226
+ const parsed = parseInt(request.query.limit ?? "90", 10);
4227
+ const limit = Number.isNaN(parsed) || parsed <= 0 ? 90 : parsed;
4228
+ const rows = app.db.select().from(gscCoverageSnapshots).where(eq12(gscCoverageSnapshots.projectId, project.id)).orderBy(desc2(gscCoverageSnapshots.date)).limit(limit).all();
4229
+ return rows.map((r) => ({
4230
+ date: r.date,
4231
+ indexed: r.indexed,
4232
+ notIndexed: r.notIndexed,
4233
+ reasonBreakdown: JSON.parse(r.reasonBreakdown)
4234
+ })).reverse();
4235
+ });
4236
+ app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
4237
+ const store = requireConnectionStore(reply);
4238
+ if (!store) return;
4239
+ const project = resolveProject(app.db, request.params.name);
4240
+ const conn = store.getConnection(project.canonicalDomain, "gsc");
4241
+ if (!conn) {
4242
+ const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
4243
+ return reply.status(err.statusCode).send(err.toJSON());
4244
+ }
4245
+ if (!conn.propertyId) {
4246
+ const err = validationError("No GSC property configured for this connection");
4247
+ return reply.status(err.statusCode).send(err.toJSON());
4248
+ }
4249
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4250
+ const runId = crypto13.randomUUID();
4251
+ app.db.insert(runs).values({
4252
+ id: runId,
4253
+ projectId: project.id,
4254
+ kind: "inspect-sitemap",
4255
+ status: "queued",
4256
+ trigger: "manual",
4257
+ createdAt: now
4258
+ }).run();
4259
+ const { sitemapUrl } = request.body ?? {};
4260
+ if (opts.onInspectSitemapRequested) {
4261
+ opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl: sitemapUrl ?? void 0 });
4262
+ }
4263
+ const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
4264
+ return run;
4265
+ });
3817
4266
  app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
3818
4267
  const store = requireConnectionStore(reply);
3819
4268
  if (!store) return;
@@ -3881,7 +4330,8 @@ async function apiRoutes(app, opts) {
3881
4330
  googleConnectionStore: opts.googleConnectionStore,
3882
4331
  googleStateSecret: opts.googleStateSecret,
3883
4332
  publicUrl: opts.publicUrl,
3884
- onGscSyncRequested: opts.onGscSyncRequested
4333
+ onGscSyncRequested: opts.onGscSyncRequested,
4334
+ onInspectSitemapRequested: opts.onInspectSitemapRequested
3885
4335
  });
3886
4336
  }, { prefix: "/api/v1" });
3887
4337
  }
@@ -3941,7 +4391,7 @@ async function executeTrackedQuery(input) {
3941
4391
  model,
3942
4392
  tools: [{ googleSearch: {} }]
3943
4393
  });
3944
- const prompt = buildPrompt(input.keyword);
4394
+ const prompt = buildPrompt(input.keyword, input.location);
3945
4395
  const result = await generativeModel.generateContent(prompt);
3946
4396
  const response = result.response;
3947
4397
  const groundingMetadata = extractGroundingMetadata(response);
@@ -3965,7 +4415,10 @@ function normalizeResult(raw) {
3965
4415
  searchQueries: raw.searchQueries
3966
4416
  };
3967
4417
  }
3968
- function buildPrompt(keyword) {
4418
+ function buildPrompt(keyword, location) {
4419
+ if (location) {
4420
+ return `${keyword} (searching from ${location.city}, ${location.region}, ${location.country})`;
4421
+ }
3969
4422
  return keyword;
3970
4423
  }
3971
4424
  function extractAnswerText(rawResponse) {
@@ -4107,7 +4560,8 @@ var geminiAdapter = {
4107
4560
  keyword: input.keyword,
4108
4561
  canonicalDomains: input.canonicalDomains,
4109
4562
  competitorDomains: input.competitorDomains,
4110
- config: toGeminiConfig(config)
4563
+ config: toGeminiConfig(config),
4564
+ location: input.location
4111
4565
  });
4112
4566
  return {
4113
4567
  provider: "gemini",
@@ -4181,9 +4635,19 @@ async function healthcheck2(config) {
4181
4635
  async function executeTrackedQuery2(input) {
4182
4636
  const model = input.config.model ?? DEFAULT_MODEL2;
4183
4637
  const client = new OpenAI({ apiKey: input.config.apiKey });
4638
+ const webSearchTool = { type: "web_search_preview" };
4639
+ if (input.location) {
4640
+ webSearchTool.user_location = {
4641
+ type: "approximate",
4642
+ city: input.location.city,
4643
+ region: input.location.region,
4644
+ country: input.location.country,
4645
+ ...input.location.timezone ? { timezone: input.location.timezone } : {}
4646
+ };
4647
+ }
4184
4648
  const response = await client.responses.create({
4185
4649
  model,
4186
- tools: [{ type: "web_search_preview" }],
4650
+ tools: [webSearchTool],
4187
4651
  tool_choice: "required",
4188
4652
  input: buildPrompt2(input.keyword)
4189
4653
  });
@@ -4348,7 +4812,8 @@ var openaiAdapter = {
4348
4812
  keyword: input.keyword,
4349
4813
  canonicalDomains: input.canonicalDomains,
4350
4814
  competitorDomains: input.competitorDomains,
4351
- config: toOpenAIConfig(config)
4815
+ config: toOpenAIConfig(config),
4816
+ location: input.location
4352
4817
  });
4353
4818
  return {
4354
4819
  provider: "openai",
@@ -4423,16 +4888,24 @@ async function healthcheck3(config) {
4423
4888
  async function executeTrackedQuery3(input) {
4424
4889
  const model = input.config.model ?? DEFAULT_MODEL3;
4425
4890
  const client = new Anthropic({ apiKey: input.config.apiKey });
4891
+ const webSearchTool = {
4892
+ type: "web_search_20250305",
4893
+ name: "web_search",
4894
+ max_uses: 5
4895
+ };
4896
+ if (input.location) {
4897
+ webSearchTool.user_location = {
4898
+ type: "approximate",
4899
+ city: input.location.city,
4900
+ region: input.location.region,
4901
+ country: input.location.country,
4902
+ ...input.location.timezone ? { timezone: input.location.timezone } : {}
4903
+ };
4904
+ }
4426
4905
  const response = await client.messages.create({
4427
4906
  model,
4428
4907
  max_tokens: 4096,
4429
- tools: [
4430
- {
4431
- type: "web_search_20250305",
4432
- name: "web_search",
4433
- max_uses: 5
4434
- }
4435
- ],
4908
+ tools: [webSearchTool],
4436
4909
  messages: [{ role: "user", content: input.keyword }]
4437
4910
  });
4438
4911
  const groundingSources = extractGroundingSources2(response);
@@ -4585,7 +5058,8 @@ var claudeAdapter = {
4585
5058
  keyword: input.keyword,
4586
5059
  canonicalDomains: input.canonicalDomains,
4587
5060
  competitorDomains: input.competitorDomains,
4588
- config: toClaudeConfig(config)
5061
+ config: toClaudeConfig(config),
5062
+ location: input.location
4589
5063
  });
4590
5064
  return {
4591
5065
  provider: "claude",
@@ -4675,7 +5149,7 @@ async function executeTrackedQuery4(input) {
4675
5149
  },
4676
5150
  {
4677
5151
  role: "user",
4678
- content: buildPrompt3(input.keyword)
5152
+ content: buildPrompt3(input.keyword, input.location)
4679
5153
  }
4680
5154
  ]
4681
5155
  });
@@ -4698,8 +5172,9 @@ function normalizeResult4(raw) {
4698
5172
  searchQueries: raw.searchQueries
4699
5173
  };
4700
5174
  }
4701
- function buildPrompt3(keyword) {
4702
- 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.`;
5175
+ function buildPrompt3(keyword, location) {
5176
+ const locationContext = location ? ` The user is searching from ${location.city}, ${location.region}, ${location.country}.` : "";
5177
+ 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.`;
4703
5178
  }
4704
5179
  function extractAnswerText2(rawResponse) {
4705
5180
  try {
@@ -4770,7 +5245,8 @@ var localAdapter = {
4770
5245
  keyword: input.keyword,
4771
5246
  canonicalDomains: input.canonicalDomains,
4772
5247
  competitorDomains: input.competitorDomains,
4773
- config: toLocalConfig(config)
5248
+ config: toLocalConfig(config),
5249
+ location: input.location
4774
5250
  });
4775
5251
  return {
4776
5252
  provider: "local",
@@ -4874,7 +5350,7 @@ function removeGoogleConnection(config, domain, connectionType) {
4874
5350
  }
4875
5351
 
4876
5352
  // src/job-runner.ts
4877
- import crypto13 from "crypto";
5353
+ import crypto14 from "crypto";
4878
5354
  import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
4879
5355
  var JobRunner = class {
4880
5356
  db;
@@ -4893,7 +5369,7 @@ var JobRunner = class {
4893
5369
  console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
4894
5370
  }
4895
5371
  }
4896
- async executeRun(runId, projectId, providerOverride) {
5372
+ async executeRun(runId, projectId, providerOverride, locationOverride) {
4897
5373
  const now = (/* @__PURE__ */ new Date()).toISOString();
4898
5374
  const startTime = Date.now();
4899
5375
  try {
@@ -4902,6 +5378,17 @@ var JobRunner = class {
4902
5378
  if (!project) {
4903
5379
  throw new Error(`Project ${projectId} not found`);
4904
5380
  }
5381
+ let runLocation;
5382
+ if (locationOverride === null) {
5383
+ runLocation = void 0;
5384
+ } else if (locationOverride) {
5385
+ runLocation = locationOverride;
5386
+ } else {
5387
+ const projectLocations = JSON.parse(project.locations || "[]");
5388
+ if (project.defaultLocation && projectLocations.length > 0) {
5389
+ runLocation = projectLocations.find((l) => l.label === project.defaultLocation);
5390
+ }
5391
+ }
4905
5392
  const projectProviders = providerOverride ?? JSON.parse(project.providers || "[]");
4906
5393
  const activeProviders = this.registry.getForProject(projectProviders);
4907
5394
  if (activeProviders.length === 0) {
@@ -4946,7 +5433,8 @@ var JobRunner = class {
4946
5433
  {
4947
5434
  keyword: kw.keyword,
4948
5435
  canonicalDomains: allDomains,
4949
- competitorDomains
5436
+ competitorDomains,
5437
+ location: runLocation
4950
5438
  },
4951
5439
  config
4952
5440
  );
@@ -4955,7 +5443,7 @@ var JobRunner = class {
4955
5443
  const citationState = determineCitationState(normalized, allDomains);
4956
5444
  const overlap = computeCompetitorOverlap(normalized, competitorDomains);
4957
5445
  this.db.insert(querySnapshots).values({
4958
- id: crypto13.randomUUID(),
5446
+ id: crypto14.randomUUID(),
4959
5447
  runId,
4960
5448
  keywordId: kw.id,
4961
5449
  provider: providerName,
@@ -4964,6 +5452,7 @@ var JobRunner = class {
4964
5452
  answerText: normalized.answerText,
4965
5453
  citedDomains: JSON.stringify(normalized.citedDomains),
4966
5454
  competitorOverlap: JSON.stringify(overlap),
5455
+ location: runLocation?.label ?? null,
4967
5456
  rawResponse: JSON.stringify({
4968
5457
  model: raw.model,
4969
5458
  groundingSources: normalized.groundingSources,
@@ -4999,7 +5488,8 @@ var JobRunner = class {
4999
5488
  providerCount: activeProviders.length,
5000
5489
  providers: activeProviders.map((p) => p.adapter.name),
5001
5490
  keywordCount: projectKeywords.length,
5002
- durationMs: Date.now() - startTime
5491
+ durationMs: Date.now() - startTime,
5492
+ ...runLocation ? { location: runLocation.label } : {}
5003
5493
  });
5004
5494
  for (const p of activeProviders) {
5005
5495
  this.incrementUsage(`${projectId}:${p.adapter.name}`, "queries", queriesPerProvider);
@@ -5052,7 +5542,7 @@ var JobRunner = class {
5052
5542
  incrementUsage(scope, metric, count) {
5053
5543
  const now = /* @__PURE__ */ new Date();
5054
5544
  const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
5055
- const id = crypto13.randomUUID();
5545
+ const id = crypto14.randomUUID();
5056
5546
  const existing = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
5057
5547
  if (existing) {
5058
5548
  this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq13(usageCounters.id, existing.id)).run();
@@ -5135,7 +5625,7 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
5135
5625
  }
5136
5626
 
5137
5627
  // src/gsc-sync.ts
5138
- import crypto14 from "crypto";
5628
+ import crypto15 from "crypto";
5139
5629
  import { eq as eq14, and as and4, sql as sql3 } from "drizzle-orm";
5140
5630
  function formatDate(d) {
5141
5631
  return d.toISOString().split("T")[0];
@@ -5200,7 +5690,7 @@ async function executeGscSync(db, runId, projectId, opts) {
5200
5690
  for (const row of batch) {
5201
5691
  const [query, page, country, device, date] = row.keys;
5202
5692
  db.insert(gscSearchData).values({
5203
- id: crypto14.randomUUID(),
5693
+ id: crypto15.randomUUID(),
5204
5694
  projectId,
5205
5695
  syncRunId: runId,
5206
5696
  date: date ?? "",
@@ -5234,7 +5724,7 @@ async function executeGscSync(db, runId, projectId, opts) {
5234
5724
  const rich = ir.richResultsResult;
5235
5725
  const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
5236
5726
  db.insert(gscUrlInspections).values({
5237
- id: crypto14.randomUUID(),
5727
+ id: crypto15.randomUUID(),
5238
5728
  projectId,
5239
5729
  syncRunId: runId,
5240
5730
  url: pageUrl,
@@ -5255,8 +5745,40 @@ async function executeGscSync(db, runId, projectId, opts) {
5255
5745
  console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
5256
5746
  }
5257
5747
  }
5748
+ const allInspections = db.select().from(gscUrlInspections).where(eq14(gscUrlInspections.projectId, projectId)).all();
5749
+ const latestByUrl = /* @__PURE__ */ new Map();
5750
+ for (const row of allInspections) {
5751
+ const existing = latestByUrl.get(row.url);
5752
+ if (!existing || row.inspectedAt > existing.inspectedAt) {
5753
+ latestByUrl.set(row.url, row);
5754
+ }
5755
+ }
5756
+ let snapIndexed = 0;
5757
+ let snapNotIndexed = 0;
5758
+ const reasonCounts = {};
5759
+ for (const [, row] of latestByUrl) {
5760
+ if (row.indexingState === "INDEXING_ALLOWED") {
5761
+ snapIndexed++;
5762
+ } else {
5763
+ snapNotIndexed++;
5764
+ const reason = row.coverageState ?? "Unknown";
5765
+ reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1;
5766
+ }
5767
+ }
5768
+ const snapshotDate = formatDate(/* @__PURE__ */ new Date());
5769
+ db.delete(gscCoverageSnapshots).where(and4(eq14(gscCoverageSnapshots.projectId, projectId), eq14(gscCoverageSnapshots.date, snapshotDate))).run();
5770
+ db.insert(gscCoverageSnapshots).values({
5771
+ id: crypto15.randomUUID(),
5772
+ projectId,
5773
+ syncRunId: runId,
5774
+ date: snapshotDate,
5775
+ indexed: snapIndexed,
5776
+ notIndexed: snapNotIndexed,
5777
+ reasonBreakdown: JSON.stringify(reasonCounts),
5778
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5779
+ }).run();
5258
5780
  db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
5259
- console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections.`);
5781
+ console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections, coverage snapshot: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
5260
5782
  } catch (err) {
5261
5783
  const errorMsg = err instanceof Error ? err.message : String(err);
5262
5784
  db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
@@ -5265,6 +5787,193 @@ async function executeGscSync(db, runId, projectId, opts) {
5265
5787
  }
5266
5788
  }
5267
5789
 
5790
+ // src/gsc-inspect-sitemap.ts
5791
+ import crypto16 from "crypto";
5792
+ import { eq as eq15, and as and5 } from "drizzle-orm";
5793
+
5794
+ // src/sitemap-parser.ts
5795
+ var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
5796
+ var SITEMAP_TAG_REGEX = /<sitemap>[\s\S]*?<\/sitemap>/gi;
5797
+ var PRIVATE_IP_PATTERNS = [
5798
+ /^169\.254\./,
5799
+ // link-local (AWS metadata endpoint etc.)
5800
+ /^10\./,
5801
+ // private class A
5802
+ /^172\.(1[6-9]|2\d|3[01])\./,
5803
+ // private class B
5804
+ /^192\.168\./
5805
+ // private class C
5806
+ ];
5807
+ function validateSitemapUrl(url) {
5808
+ let parsed;
5809
+ try {
5810
+ parsed = new URL(url);
5811
+ } catch {
5812
+ throw new Error(`Invalid sitemap URL: ${url}`);
5813
+ }
5814
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
5815
+ throw new Error(`Sitemap URL must use http or https protocol: ${url}`);
5816
+ }
5817
+ const host = parsed.hostname.toLowerCase();
5818
+ for (const pattern of PRIVATE_IP_PATTERNS) {
5819
+ if (pattern.test(host)) {
5820
+ throw new Error(`Sitemap URL points to a private or reserved IP range: ${url}`);
5821
+ }
5822
+ }
5823
+ }
5824
+ async function fetchAndParseSitemap(sitemapUrl) {
5825
+ const urls = /* @__PURE__ */ new Set();
5826
+ await parseSitemapRecursive(sitemapUrl, urls, 0);
5827
+ return [...urls];
5828
+ }
5829
+ async function parseSitemapRecursive(url, urls, depth) {
5830
+ if (depth > 3) return;
5831
+ validateSitemapUrl(url);
5832
+ const res = await fetch(url);
5833
+ if (!res.ok) {
5834
+ throw new Error(`Failed to fetch sitemap at ${url}: ${res.status} ${res.statusText}`);
5835
+ }
5836
+ const xml = await res.text();
5837
+ const sitemapEntries = xml.match(SITEMAP_TAG_REGEX);
5838
+ if (sitemapEntries) {
5839
+ for (const entry of sitemapEntries) {
5840
+ const locMatch = LOC_REGEX.exec(entry);
5841
+ LOC_REGEX.lastIndex = 0;
5842
+ if (locMatch?.[1]) {
5843
+ await parseSitemapRecursive(locMatch[1], urls, depth + 1);
5844
+ }
5845
+ }
5846
+ return;
5847
+ }
5848
+ let match;
5849
+ while ((match = LOC_REGEX.exec(xml)) !== null) {
5850
+ if (match[1]) {
5851
+ urls.add(match[1]);
5852
+ }
5853
+ }
5854
+ LOC_REGEX.lastIndex = 0;
5855
+ }
5856
+
5857
+ // src/gsc-inspect-sitemap.ts
5858
+ async function executeInspectSitemap(db, runId, projectId, opts) {
5859
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5860
+ db.update(runs).set({ status: "running", startedAt: now }).where(eq15(runs.id, runId)).run();
5861
+ try {
5862
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
5863
+ if (!googleClientId || !googleClientSecret) {
5864
+ throw new Error("Google OAuth is not configured in the local Canonry config");
5865
+ }
5866
+ const project = db.select().from(projects).where(eq15(projects.id, projectId)).get();
5867
+ if (!project) {
5868
+ throw new Error(`Project not found: ${projectId}`);
5869
+ }
5870
+ const conn = getGoogleConnection(opts.config, project.canonicalDomain, "gsc");
5871
+ if (!conn || !conn.refreshToken) {
5872
+ throw new Error("No GSC connection found or connection is incomplete");
5873
+ }
5874
+ if (!conn.propertyId) {
5875
+ throw new Error('No GSC property selected. Use "canonry google properties" to list available sites, then set one.');
5876
+ }
5877
+ let accessToken = conn.accessToken;
5878
+ const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
5879
+ if (Date.now() > expiresAt - 5 * 60 * 1e3) {
5880
+ const tokens = await refreshAccessToken(googleClientId, googleClientSecret, conn.refreshToken);
5881
+ accessToken = tokens.access_token;
5882
+ patchGoogleConnection(opts.config, project.canonicalDomain, "gsc", {
5883
+ accessToken: tokens.access_token,
5884
+ tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
5885
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5886
+ });
5887
+ saveConfig(opts.config);
5888
+ }
5889
+ const sitemapUrl = opts.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
5890
+ console.log(`[Inspect Sitemap] Fetching sitemap from ${sitemapUrl}`);
5891
+ const urls = await fetchAndParseSitemap(sitemapUrl);
5892
+ console.log(`[Inspect Sitemap] Found ${urls.length} URLs in sitemap`);
5893
+ if (urls.length === 0) {
5894
+ throw new Error("No URLs found in sitemap");
5895
+ }
5896
+ let inspected = 0;
5897
+ let errors = 0;
5898
+ for (const pageUrl of urls) {
5899
+ try {
5900
+ const result = await inspectUrl(accessToken, pageUrl, conn.propertyId);
5901
+ const ir = result.inspectionResult;
5902
+ const idx = ir.indexStatusResult;
5903
+ const mob = ir.mobileUsabilityResult;
5904
+ const rich = ir.richResultsResult;
5905
+ const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
5906
+ db.insert(gscUrlInspections).values({
5907
+ id: crypto16.randomUUID(),
5908
+ projectId,
5909
+ syncRunId: runId,
5910
+ url: pageUrl,
5911
+ indexingState: idx?.indexingState ?? null,
5912
+ verdict: idx?.verdict ?? null,
5913
+ coverageState: idx?.coverageState ?? null,
5914
+ pageFetchState: idx?.pageFetchState ?? null,
5915
+ robotsTxtState: idx?.robotsTxtState ?? null,
5916
+ crawlTime: idx?.lastCrawlTime ?? null,
5917
+ lastCrawlResult: idx?.crawlResult ?? null,
5918
+ isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
5919
+ richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
5920
+ referringUrls: JSON.stringify(idx?.referringUrls ?? []),
5921
+ inspectedAt,
5922
+ createdAt: inspectedAt
5923
+ }).run();
5924
+ inspected++;
5925
+ console.log(`[Inspect Sitemap] ${inspected}/${urls.length} inspected: ${pageUrl}`);
5926
+ } catch (err) {
5927
+ errors++;
5928
+ console.error(`[Inspect Sitemap] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
5929
+ }
5930
+ if (inspected + errors < urls.length) {
5931
+ await new Promise((r) => setTimeout(r, 1e3));
5932
+ }
5933
+ }
5934
+ const allInspections = db.select().from(gscUrlInspections).where(eq15(gscUrlInspections.projectId, projectId)).all();
5935
+ const latestByUrl = /* @__PURE__ */ new Map();
5936
+ for (const row of allInspections) {
5937
+ const existing = latestByUrl.get(row.url);
5938
+ if (!existing || row.inspectedAt > existing.inspectedAt) {
5939
+ latestByUrl.set(row.url, row);
5940
+ }
5941
+ }
5942
+ let snapIndexed = 0;
5943
+ let snapNotIndexed = 0;
5944
+ const reasonCounts = {};
5945
+ for (const [, row] of latestByUrl) {
5946
+ if (row.indexingState === "INDEXING_ALLOWED") {
5947
+ snapIndexed++;
5948
+ } else {
5949
+ snapNotIndexed++;
5950
+ const reason = row.coverageState ?? "Unknown";
5951
+ reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1;
5952
+ }
5953
+ }
5954
+ const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
5955
+ db.delete(gscCoverageSnapshots).where(and5(eq15(gscCoverageSnapshots.projectId, projectId), eq15(gscCoverageSnapshots.date, snapshotDate))).run();
5956
+ db.insert(gscCoverageSnapshots).values({
5957
+ id: crypto16.randomUUID(),
5958
+ projectId,
5959
+ syncRunId: runId,
5960
+ date: snapshotDate,
5961
+ indexed: snapIndexed,
5962
+ notIndexed: snapNotIndexed,
5963
+ reasonBreakdown: JSON.stringify(reasonCounts),
5964
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
5965
+ }).run();
5966
+ const status = errors > 0 && inspected > 0 ? "partial" : errors === urls.length ? "failed" : "completed";
5967
+ db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
5968
+ console.log(`[Inspect Sitemap] Done. ${inspected} inspected, ${errors} errors out of ${urls.length} URLs. Coverage: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
5969
+ } catch (err) {
5970
+ const errorMsg = err instanceof Error ? err.message : String(err);
5971
+ db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq15(runs.id, runId)).run();
5972
+ console.error(`[Inspect Sitemap] Failed:`, errorMsg);
5973
+ throw err;
5974
+ }
5975
+ }
5976
+
5268
5977
  // src/provider-registry.ts
5269
5978
  var ProviderRegistry = class {
5270
5979
  providers = /* @__PURE__ */ new Map();
@@ -5310,7 +6019,7 @@ var ProviderRegistry = class {
5310
6019
 
5311
6020
  // src/scheduler.ts
5312
6021
  import cron from "node-cron";
5313
- import { eq as eq15 } from "drizzle-orm";
6022
+ import { eq as eq16 } from "drizzle-orm";
5314
6023
  var Scheduler = class {
5315
6024
  db;
5316
6025
  callbacks;
@@ -5321,7 +6030,7 @@ var Scheduler = class {
5321
6030
  }
5322
6031
  /** Load all enabled schedules from DB and register cron jobs. */
5323
6032
  start() {
5324
- const allSchedules = this.db.select().from(schedules).where(eq15(schedules.enabled, 1)).all();
6033
+ const allSchedules = this.db.select().from(schedules).where(eq16(schedules.enabled, 1)).all();
5325
6034
  for (const schedule of allSchedules) {
5326
6035
  const missedRunAt = schedule.nextRunAt;
5327
6036
  this.registerCronTask(schedule);
@@ -5346,7 +6055,7 @@ var Scheduler = class {
5346
6055
  this.stopTask(projectId, existing, "Stopped");
5347
6056
  this.tasks.delete(projectId);
5348
6057
  }
5349
- const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
6058
+ const schedule = this.db.select().from(schedules).where(eq16(schedules.projectId, projectId)).get();
5350
6059
  if (schedule && schedule.enabled === 1) {
5351
6060
  this.registerCronTask(schedule);
5352
6061
  }
@@ -5379,13 +6088,13 @@ var Scheduler = class {
5379
6088
  this.db.update(schedules).set({
5380
6089
  nextRunAt: task.getNextRun()?.toISOString() ?? null,
5381
6090
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
5382
- }).where(eq15(schedules.id, scheduleId)).run();
6091
+ }).where(eq16(schedules.id, scheduleId)).run();
5383
6092
  const label = schedule.preset ?? cronExpr;
5384
6093
  console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
5385
6094
  }
5386
6095
  triggerRun(scheduleId, projectId) {
5387
6096
  const now = (/* @__PURE__ */ new Date()).toISOString();
5388
- const currentSchedule = this.db.select().from(schedules).where(eq15(schedules.id, scheduleId)).get();
6097
+ const currentSchedule = this.db.select().from(schedules).where(eq16(schedules.id, scheduleId)).get();
5389
6098
  if (!currentSchedule || currentSchedule.enabled !== 1) {
5390
6099
  console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
5391
6100
  this.remove(projectId);
@@ -5393,7 +6102,7 @@ var Scheduler = class {
5393
6102
  }
5394
6103
  const task = this.tasks.get(projectId);
5395
6104
  const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
5396
- const project = this.db.select().from(projects).where(eq15(projects.id, projectId)).get();
6105
+ const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
5397
6106
  if (!project) {
5398
6107
  console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
5399
6108
  this.remove(projectId);
@@ -5410,7 +6119,7 @@ var Scheduler = class {
5410
6119
  this.db.update(schedules).set({
5411
6120
  nextRunAt,
5412
6121
  updatedAt: now
5413
- }).where(eq15(schedules.id, currentSchedule.id)).run();
6122
+ }).where(eq16(schedules.id, currentSchedule.id)).run();
5414
6123
  return;
5415
6124
  }
5416
6125
  const runId = queueResult.runId;
@@ -5418,7 +6127,7 @@ var Scheduler = class {
5418
6127
  lastRunAt: now,
5419
6128
  nextRunAt,
5420
6129
  updatedAt: now
5421
- }).where(eq15(schedules.id, currentSchedule.id)).run();
6130
+ }).where(eq16(schedules.id, currentSchedule.id)).run();
5422
6131
  const scheduleProviders = JSON.parse(currentSchedule.providers);
5423
6132
  const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
5424
6133
  console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
@@ -5427,8 +6136,8 @@ var Scheduler = class {
5427
6136
  };
5428
6137
 
5429
6138
  // src/notifier.ts
5430
- import { eq as eq16, desc as desc3, and as and5, or as or2 } from "drizzle-orm";
5431
- import crypto15 from "crypto";
6139
+ import { eq as eq17, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
6140
+ import crypto17 from "crypto";
5432
6141
  var Notifier = class {
5433
6142
  db;
5434
6143
  serverUrl;
@@ -5439,18 +6148,18 @@ var Notifier = class {
5439
6148
  /** Called after a run completes (success, partial, or failed). */
5440
6149
  async onRunCompleted(runId, projectId) {
5441
6150
  console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
5442
- const notifs = this.db.select().from(notifications).where(eq16(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
6151
+ const notifs = this.db.select().from(notifications).where(eq17(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
5443
6152
  if (notifs.length === 0) {
5444
6153
  console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
5445
6154
  return;
5446
6155
  }
5447
6156
  console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
5448
- const run = this.db.select().from(runs).where(eq16(runs.id, runId)).get();
6157
+ const run = this.db.select().from(runs).where(eq17(runs.id, runId)).get();
5449
6158
  if (!run) {
5450
6159
  console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
5451
6160
  return;
5452
6161
  }
5453
- const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
6162
+ const project = this.db.select().from(projects).where(eq17(projects.id, projectId)).get();
5454
6163
  if (!project) {
5455
6164
  console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
5456
6165
  return;
@@ -5490,9 +6199,9 @@ var Notifier = class {
5490
6199
  }
5491
6200
  computeTransitions(runId, projectId) {
5492
6201
  const recentRuns = this.db.select().from(runs).where(
5493
- and5(
5494
- eq16(runs.projectId, projectId),
5495
- or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
6202
+ and6(
6203
+ eq17(runs.projectId, projectId),
6204
+ or2(eq17(runs.status, "completed"), eq17(runs.status, "partial"))
5496
6205
  )
5497
6206
  ).orderBy(desc3(runs.createdAt)).limit(2).all();
5498
6207
  if (recentRuns.length < 2) return [];
@@ -5504,12 +6213,12 @@ var Notifier = class {
5504
6213
  keyword: keywords.keyword,
5505
6214
  provider: querySnapshots.provider,
5506
6215
  citationState: querySnapshots.citationState
5507
- }).from(querySnapshots).leftJoin(keywords, eq16(querySnapshots.keywordId, keywords.id)).where(eq16(querySnapshots.runId, currentRunId)).all();
6216
+ }).from(querySnapshots).leftJoin(keywords, eq17(querySnapshots.keywordId, keywords.id)).where(eq17(querySnapshots.runId, currentRunId)).all();
5508
6217
  const previousSnapshots = this.db.select({
5509
6218
  keywordId: querySnapshots.keywordId,
5510
6219
  provider: querySnapshots.provider,
5511
6220
  citationState: querySnapshots.citationState
5512
- }).from(querySnapshots).where(eq16(querySnapshots.runId, previousRunId)).all();
6221
+ }).from(querySnapshots).where(eq17(querySnapshots.runId, previousRunId)).all();
5513
6222
  const prevMap = /* @__PURE__ */ new Map();
5514
6223
  for (const s of previousSnapshots) {
5515
6224
  prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
@@ -5566,7 +6275,7 @@ var Notifier = class {
5566
6275
  }
5567
6276
  logDelivery(projectId, notificationId, event, status, error) {
5568
6277
  this.db.insert(auditLog).values({
5569
- id: crypto15.randomUUID(),
6278
+ id: crypto17.randomUUID(),
5570
6279
  projectId,
5571
6280
  actor: "scheduler",
5572
6281
  action: `notification.${status}`,
@@ -5697,6 +6406,14 @@ var DEFAULT_QUOTA = {
5697
6406
  maxRequestsPerMinute: 10,
5698
6407
  maxRequestsPerDay: 1e3
5699
6408
  };
6409
+ function summarizeProviderConfig(provider, config) {
6410
+ return {
6411
+ configured: Boolean(config?.apiKey || config?.baseUrl),
6412
+ model: config?.model ?? null,
6413
+ baseUrl: provider === "local" ? config?.baseUrl ?? null : null,
6414
+ quota: { ...config?.quota ?? DEFAULT_QUOTA }
6415
+ };
6416
+ }
5700
6417
  async function createServer(opts) {
5701
6418
  const logger = opts.logger === false ? false : process.stdout.isTTY ? {
5702
6419
  transport: {
@@ -5781,7 +6498,7 @@ async function createServer(opts) {
5781
6498
  configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
5782
6499
  };
5783
6500
  const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
5784
- const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
6501
+ const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto18.randomBytes(32).toString("hex");
5785
6502
  const googleConnectionStore = {
5786
6503
  listConnections: (domain) => listGoogleConnections(opts.config, domain),
5787
6504
  getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
@@ -5821,14 +6538,27 @@ async function createServer(opts) {
5821
6538
  app.log.error({ runId, err }, "GSC sync failed");
5822
6539
  });
5823
6540
  },
6541
+ onInspectSitemapRequested: (runId, projectId, inspectOpts) => {
6542
+ const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
6543
+ if (!googleClientId || !googleClientSecret) {
6544
+ app.log.error("Inspect sitemap requested but Google OAuth credentials are not configured");
6545
+ return;
6546
+ }
6547
+ executeInspectSitemap(opts.db, runId, projectId, {
6548
+ ...inspectOpts,
6549
+ config: opts.config
6550
+ }).catch((err) => {
6551
+ app.log.error({ runId, err }, "Inspect sitemap failed");
6552
+ });
6553
+ },
5824
6554
  openApiInfo: {
5825
6555
  title: "Canonry API",
5826
6556
  version: PKG_VERSION
5827
6557
  },
5828
6558
  providerSummary,
5829
6559
  googleSettingsSummary,
5830
- onRunCreated: (runId, projectId, providers2) => {
5831
- jobRunner.executeRun(runId, projectId, providers2).catch((err) => {
6560
+ onRunCreated: (runId, projectId, providers2, location) => {
6561
+ jobRunner.executeRun(runId, projectId, providers2, location).catch((err) => {
5832
6562
  app.log.error({ runId, err }, "Job runner failed");
5833
6563
  });
5834
6564
  },
@@ -5837,6 +6567,7 @@ async function createServer(opts) {
5837
6567
  if (!(name in adapterMap)) return null;
5838
6568
  if (!opts.config.providers) opts.config.providers = {};
5839
6569
  const existing = opts.config.providers[name];
6570
+ const beforeConfig = summarizeProviderConfig(name, existing);
5840
6571
  const mergedQuota = incomingQuota ? { ...existing?.quota ?? DEFAULT_QUOTA, ...incomingQuota } : existing?.quota;
5841
6572
  opts.config.providers[name] = {
5842
6573
  apiKey: apiKey || existing?.apiKey,
@@ -5864,6 +6595,33 @@ async function createServer(opts) {
5864
6595
  entry.model = model || registry.get(name)?.config.model;
5865
6596
  entry.quota = quota;
5866
6597
  }
6598
+ const afterConfig = summarizeProviderConfig(name, opts.config.providers[name]);
6599
+ if (JSON.stringify(beforeConfig) !== JSON.stringify(afterConfig)) {
6600
+ const diff = JSON.stringify({
6601
+ before: existing ? beforeConfig : null,
6602
+ after: afterConfig
6603
+ });
6604
+ const affectedProjectIds = opts.db.select({ id: projects.id, providers: projects.providers }).from(projects).all().filter((project) => {
6605
+ try {
6606
+ const configuredProviders = JSON.parse(project.providers || "[]");
6607
+ return configuredProviders.length === 0 || configuredProviders.includes(name);
6608
+ } catch {
6609
+ return false;
6610
+ }
6611
+ }).map((project) => project.id);
6612
+ const targetProjectIds = affectedProjectIds.length > 0 ? affectedProjectIds : [null];
6613
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
6614
+ opts.db.insert(auditLog).values(targetProjectIds.map((projectId) => ({
6615
+ id: crypto18.randomUUID(),
6616
+ projectId,
6617
+ actor: "api",
6618
+ action: existing ? "provider.updated" : "provider.created",
6619
+ entityType: "provider",
6620
+ entityId: name,
6621
+ diff,
6622
+ createdAt
6623
+ }))).run();
6624
+ }
5867
6625
  return {
5868
6626
  name,
5869
6627
  model: entry?.model,