@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.
- package/assets/assets/index-BUh6m3HV.css +1 -0
- package/assets/assets/index-DOhW7c21.js +245 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-O4HLQBL7.js → chunk-JKIWFSYI.js} +836 -78
- package/dist/cli.js +384 -8
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/assets/assets/index-BEsueXzg.css +0 -1
- package/assets/assets/index-Bol7Z6qk.js +0 -243
|
@@ -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
|
|
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
|
|
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
|
|
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=" +
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
2058
|
-
const
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
3155
|
-
const webhookSecret =
|
|
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
|
|
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
|
|
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 (!
|
|
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 =
|
|
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 =
|
|
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
|
|
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: [
|
|
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
|
-
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
5431
|
-
import
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
5494
|
-
|
|
5495
|
-
or2(
|
|
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,
|
|
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(
|
|
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:
|
|
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 ??
|
|
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,
|