@ainyc/canonry 1.19.3 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/assets/index-BAlQzE7t.js +246 -0
- package/assets/assets/index-D-2VeJdG.css +1 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-GX673NKZ.js → chunk-V7JJJDPL.js} +1712 -1122
- package/dist/cli.js +413 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1 -1
- package/package.json +6 -5
- package/assets/assets/index-53IVPMPi.js +0 -246
- package/assets/assets/index-DdQKI2_i.css +0 -1
|
@@ -160,987 +160,1070 @@ function trackEvent(event, properties) {
|
|
|
160
160
|
|
|
161
161
|
// src/server.ts
|
|
162
162
|
import { createRequire as createRequire2 } from "module";
|
|
163
|
-
import
|
|
163
|
+
import crypto19 from "crypto";
|
|
164
164
|
import fs5 from "fs";
|
|
165
165
|
import path6 from "path";
|
|
166
166
|
import { fileURLToPath } from "url";
|
|
167
167
|
import Fastify from "fastify";
|
|
168
168
|
|
|
169
|
-
// ../
|
|
170
|
-
import
|
|
171
|
-
import { eq } from "drizzle-orm";
|
|
172
|
-
|
|
173
|
-
// ../db/src/client.ts
|
|
174
|
-
import { mkdirSync } from "fs";
|
|
175
|
-
import { dirname } from "path";
|
|
176
|
-
import Database from "better-sqlite3";
|
|
177
|
-
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
169
|
+
// ../contracts/src/config-schema.ts
|
|
170
|
+
import { z as z3 } from "zod";
|
|
178
171
|
|
|
179
|
-
// ../
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
googleConnections: () => googleConnections,
|
|
186
|
-
gscCoverageSnapshots: () => gscCoverageSnapshots,
|
|
187
|
-
gscSearchData: () => gscSearchData,
|
|
188
|
-
gscUrlInspections: () => gscUrlInspections,
|
|
189
|
-
keywords: () => keywords,
|
|
190
|
-
notifications: () => notifications,
|
|
191
|
-
projects: () => projects,
|
|
192
|
-
querySnapshots: () => querySnapshots,
|
|
193
|
-
runs: () => runs,
|
|
194
|
-
schedules: () => schedules,
|
|
195
|
-
usageCounters: () => usageCounters
|
|
172
|
+
// ../contracts/src/provider.ts
|
|
173
|
+
import { z } from "zod";
|
|
174
|
+
var providerQuotaPolicySchema = z.object({
|
|
175
|
+
maxConcurrency: z.number().int().positive(),
|
|
176
|
+
maxRequestsPerMinute: z.number().int().positive(),
|
|
177
|
+
maxRequestsPerDay: z.number().int().positive()
|
|
196
178
|
});
|
|
197
|
-
|
|
198
|
-
var
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
179
|
+
var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
|
|
180
|
+
var providerNameSchema = z.enum(PROVIDER_NAMES);
|
|
181
|
+
var PROVIDER_MODE = {
|
|
182
|
+
gemini: "api",
|
|
183
|
+
openai: "api",
|
|
184
|
+
claude: "api",
|
|
185
|
+
local: "api",
|
|
186
|
+
"cdp:chatgpt": "browser"
|
|
187
|
+
};
|
|
188
|
+
function isBrowserProvider(name) {
|
|
189
|
+
return PROVIDER_MODE[name] === "browser";
|
|
190
|
+
}
|
|
191
|
+
var CDP_TARGETS = ["cdp:chatgpt"];
|
|
192
|
+
function parseProviderName(input) {
|
|
193
|
+
const lower = input.trim().toLowerCase();
|
|
194
|
+
return PROVIDER_NAMES.includes(lower) ? lower : void 0;
|
|
195
|
+
}
|
|
196
|
+
function resolveProviderInput(input) {
|
|
197
|
+
const lower = input.trim().toLowerCase();
|
|
198
|
+
if (lower === "cdp") {
|
|
199
|
+
return [...CDP_TARGETS];
|
|
200
|
+
}
|
|
201
|
+
const parsed = parseProviderName(lower);
|
|
202
|
+
return parsed ? [parsed] : [];
|
|
203
|
+
}
|
|
204
|
+
var locationContextSchema = z.object({
|
|
205
|
+
label: z.string().min(1),
|
|
206
|
+
city: z.string().min(1),
|
|
207
|
+
region: z.string().min(1),
|
|
208
|
+
country: z.string().length(2),
|
|
209
|
+
timezone: z.string().optional()
|
|
215
210
|
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
]);
|
|
225
|
-
var competitors = sqliteTable("competitors", {
|
|
226
|
-
id: text("id").primaryKey(),
|
|
227
|
-
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
228
|
-
domain: text("domain").notNull(),
|
|
229
|
-
createdAt: text("created_at").notNull()
|
|
230
|
-
}, (table) => [
|
|
231
|
-
index("idx_competitors_project").on(table.projectId),
|
|
232
|
-
uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
|
|
233
|
-
]);
|
|
234
|
-
var runs = sqliteTable("runs", {
|
|
235
|
-
id: text("id").primaryKey(),
|
|
236
|
-
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
237
|
-
kind: text("kind").notNull().default("answer-visibility"),
|
|
238
|
-
status: text("status").notNull().default("queued"),
|
|
239
|
-
trigger: text("trigger").notNull().default("manual"),
|
|
240
|
-
location: text("location"),
|
|
241
|
-
startedAt: text("started_at"),
|
|
242
|
-
finishedAt: text("finished_at"),
|
|
243
|
-
error: text("error"),
|
|
244
|
-
createdAt: text("created_at").notNull()
|
|
245
|
-
}, (table) => [
|
|
246
|
-
index("idx_runs_project").on(table.projectId),
|
|
247
|
-
index("idx_runs_status").on(table.status)
|
|
248
|
-
]);
|
|
249
|
-
var querySnapshots = sqliteTable("query_snapshots", {
|
|
250
|
-
id: text("id").primaryKey(),
|
|
251
|
-
runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
252
|
-
keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
|
|
253
|
-
provider: text("provider").notNull().default("gemini"),
|
|
254
|
-
model: text("model"),
|
|
255
|
-
citationState: text("citation_state").notNull(),
|
|
256
|
-
answerText: text("answer_text"),
|
|
257
|
-
citedDomains: text("cited_domains").notNull().default("[]"),
|
|
258
|
-
competitorOverlap: text("competitor_overlap").notNull().default("[]"),
|
|
259
|
-
location: text("location"),
|
|
260
|
-
screenshotPath: text("screenshot_path"),
|
|
261
|
-
rawResponse: text("raw_response"),
|
|
262
|
-
createdAt: text("created_at").notNull()
|
|
263
|
-
}, (table) => [
|
|
264
|
-
index("idx_snapshots_run").on(table.runId),
|
|
265
|
-
index("idx_snapshots_keyword").on(table.keywordId)
|
|
266
|
-
]);
|
|
267
|
-
var auditLog = sqliteTable("audit_log", {
|
|
268
|
-
id: text("id").primaryKey(),
|
|
269
|
-
projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
|
270
|
-
actor: text("actor").notNull(),
|
|
271
|
-
action: text("action").notNull(),
|
|
272
|
-
entityType: text("entity_type").notNull(),
|
|
273
|
-
entityId: text("entity_id"),
|
|
274
|
-
diff: text("diff"),
|
|
275
|
-
createdAt: text("created_at").notNull()
|
|
276
|
-
}, (table) => [
|
|
277
|
-
index("idx_audit_log_project").on(table.projectId),
|
|
278
|
-
index("idx_audit_log_created").on(table.createdAt)
|
|
279
|
-
]);
|
|
280
|
-
var apiKeys = sqliteTable("api_keys", {
|
|
281
|
-
id: text("id").primaryKey(),
|
|
282
|
-
name: text("name").notNull(),
|
|
283
|
-
keyHash: text("key_hash").notNull().unique(),
|
|
284
|
-
keyPrefix: text("key_prefix").notNull(),
|
|
285
|
-
scopes: text("scopes").notNull().default('["*"]'),
|
|
286
|
-
createdAt: text("created_at").notNull(),
|
|
287
|
-
lastUsedAt: text("last_used_at"),
|
|
288
|
-
revokedAt: text("revoked_at")
|
|
289
|
-
}, (table) => [
|
|
290
|
-
index("idx_api_keys_prefix").on(table.keyPrefix)
|
|
291
|
-
]);
|
|
292
|
-
var schedules = sqliteTable("schedules", {
|
|
293
|
-
id: text("id").primaryKey(),
|
|
294
|
-
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
295
|
-
cronExpr: text("cron_expr").notNull(),
|
|
296
|
-
preset: text("preset"),
|
|
297
|
-
timezone: text("timezone").notNull().default("UTC"),
|
|
298
|
-
enabled: integer("enabled").notNull().default(1),
|
|
299
|
-
providers: text("providers").notNull().default("[]"),
|
|
300
|
-
lastRunAt: text("last_run_at"),
|
|
301
|
-
nextRunAt: text("next_run_at"),
|
|
302
|
-
createdAt: text("created_at").notNull(),
|
|
303
|
-
updatedAt: text("updated_at").notNull()
|
|
304
|
-
}, (table) => [
|
|
305
|
-
uniqueIndex("idx_schedules_project").on(table.projectId)
|
|
306
|
-
]);
|
|
307
|
-
var notifications = sqliteTable("notifications", {
|
|
308
|
-
id: text("id").primaryKey(),
|
|
309
|
-
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
310
|
-
channel: text("channel").notNull(),
|
|
311
|
-
config: text("config").notNull(),
|
|
312
|
-
webhookSecret: text("webhook_secret"),
|
|
313
|
-
enabled: integer("enabled").notNull().default(1),
|
|
314
|
-
createdAt: text("created_at").notNull(),
|
|
315
|
-
updatedAt: text("updated_at").notNull()
|
|
316
|
-
}, (table) => [
|
|
317
|
-
index("idx_notifications_project").on(table.projectId)
|
|
318
|
-
]);
|
|
319
|
-
var googleConnections = sqliteTable("google_connections", {
|
|
320
|
-
id: text("id").primaryKey(),
|
|
321
|
-
domain: text("domain").notNull(),
|
|
322
|
-
connectionType: text("connection_type").notNull(),
|
|
323
|
-
propertyId: text("property_id"),
|
|
324
|
-
sitemapUrl: text("sitemap_url"),
|
|
325
|
-
accessToken: text("access_token"),
|
|
326
|
-
refreshToken: text("refresh_token"),
|
|
327
|
-
tokenExpiresAt: text("token_expires_at"),
|
|
328
|
-
scopes: text("scopes").notNull().default("[]"),
|
|
329
|
-
createdAt: text("created_at").notNull(),
|
|
330
|
-
updatedAt: text("updated_at").notNull()
|
|
331
|
-
}, (table) => [
|
|
332
|
-
uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
|
|
333
|
-
]);
|
|
334
|
-
var gscSearchData = sqliteTable("gsc_search_data", {
|
|
335
|
-
id: text("id").primaryKey(),
|
|
336
|
-
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
337
|
-
syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
338
|
-
date: text("date").notNull(),
|
|
339
|
-
query: text("query").notNull(),
|
|
340
|
-
page: text("page").notNull(),
|
|
341
|
-
country: text("country"),
|
|
342
|
-
device: text("device"),
|
|
343
|
-
clicks: integer("clicks").notNull().default(0),
|
|
344
|
-
impressions: integer("impressions").notNull().default(0),
|
|
345
|
-
ctr: text("ctr").notNull().default("0"),
|
|
346
|
-
position: text("position").notNull().default("0"),
|
|
347
|
-
createdAt: text("created_at").notNull()
|
|
348
|
-
}, (table) => [
|
|
349
|
-
index("idx_gsc_search_project_date").on(table.projectId, table.date),
|
|
350
|
-
index("idx_gsc_search_query").on(table.query),
|
|
351
|
-
index("idx_gsc_search_run").on(table.syncRunId)
|
|
352
|
-
]);
|
|
353
|
-
var gscUrlInspections = sqliteTable("gsc_url_inspections", {
|
|
354
|
-
id: text("id").primaryKey(),
|
|
355
|
-
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
356
|
-
syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
|
|
357
|
-
url: text("url").notNull(),
|
|
358
|
-
indexingState: text("indexing_state"),
|
|
359
|
-
verdict: text("verdict"),
|
|
360
|
-
coverageState: text("coverage_state"),
|
|
361
|
-
pageFetchState: text("page_fetch_state"),
|
|
362
|
-
robotsTxtState: text("robots_txt_state"),
|
|
363
|
-
crawlTime: text("crawl_time"),
|
|
364
|
-
lastCrawlResult: text("last_crawl_result"),
|
|
365
|
-
isMobileFriendly: integer("is_mobile_friendly"),
|
|
366
|
-
richResults: text("rich_results").notNull().default("[]"),
|
|
367
|
-
referringUrls: text("referring_urls").notNull().default("[]"),
|
|
368
|
-
inspectedAt: text("inspected_at").notNull(),
|
|
369
|
-
createdAt: text("created_at").notNull()
|
|
370
|
-
}, (table) => [
|
|
371
|
-
index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
|
|
372
|
-
index("idx_gsc_inspect_run").on(table.syncRunId),
|
|
373
|
-
index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
|
|
211
|
+
|
|
212
|
+
// ../contracts/src/notification.ts
|
|
213
|
+
import { z as z2 } from "zod";
|
|
214
|
+
var notificationEventSchema = z2.enum([
|
|
215
|
+
"citation.lost",
|
|
216
|
+
"citation.gained",
|
|
217
|
+
"run.completed",
|
|
218
|
+
"run.failed"
|
|
374
219
|
]);
|
|
375
|
-
var
|
|
376
|
-
id:
|
|
377
|
-
projectId:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
createdAt:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
index("idx_gsc_coverage_snap_run").on(table.syncRunId)
|
|
387
|
-
]);
|
|
388
|
-
var usageCounters = sqliteTable("usage_counters", {
|
|
389
|
-
id: text("id").primaryKey(),
|
|
390
|
-
scope: text("scope").notNull(),
|
|
391
|
-
period: text("period").notNull(),
|
|
392
|
-
metric: text("metric").notNull(),
|
|
393
|
-
count: integer("count").notNull().default(0),
|
|
394
|
-
updatedAt: text("updated_at").notNull()
|
|
395
|
-
}, (table) => [
|
|
396
|
-
uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
|
|
397
|
-
index("idx_usage_scope_period").on(table.scope, table.period)
|
|
398
|
-
]);
|
|
399
|
-
|
|
400
|
-
// ../db/src/client.ts
|
|
401
|
-
function createClient(databasePath) {
|
|
402
|
-
mkdirSync(dirname(databasePath), { recursive: true });
|
|
403
|
-
const sqlite = new Database(databasePath);
|
|
404
|
-
sqlite.pragma("journal_mode = WAL");
|
|
405
|
-
sqlite.pragma("foreign_keys = ON");
|
|
406
|
-
return drizzle(sqlite, { schema: schema_exports });
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// ../db/src/migrate.ts
|
|
410
|
-
import { sql } from "drizzle-orm";
|
|
411
|
-
var MIGRATION_SQL = `
|
|
412
|
-
CREATE TABLE IF NOT EXISTS projects (
|
|
413
|
-
id TEXT PRIMARY KEY,
|
|
414
|
-
name TEXT NOT NULL UNIQUE,
|
|
415
|
-
display_name TEXT NOT NULL,
|
|
416
|
-
canonical_domain TEXT NOT NULL,
|
|
417
|
-
owned_domains TEXT NOT NULL DEFAULT '[]',
|
|
418
|
-
country TEXT NOT NULL,
|
|
419
|
-
language TEXT NOT NULL,
|
|
420
|
-
tags TEXT NOT NULL DEFAULT '[]',
|
|
421
|
-
labels TEXT NOT NULL DEFAULT '{}',
|
|
422
|
-
providers TEXT NOT NULL DEFAULT '[]',
|
|
423
|
-
config_source TEXT NOT NULL DEFAULT 'cli',
|
|
424
|
-
config_revision INTEGER NOT NULL DEFAULT 1,
|
|
425
|
-
created_at TEXT NOT NULL,
|
|
426
|
-
updated_at TEXT NOT NULL
|
|
427
|
-
);
|
|
428
|
-
|
|
429
|
-
CREATE TABLE IF NOT EXISTS keywords (
|
|
430
|
-
id TEXT PRIMARY KEY,
|
|
431
|
-
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
432
|
-
keyword TEXT NOT NULL,
|
|
433
|
-
created_at TEXT NOT NULL,
|
|
434
|
-
UNIQUE(project_id, keyword)
|
|
435
|
-
);
|
|
220
|
+
var notificationDtoSchema = z2.object({
|
|
221
|
+
id: z2.string(),
|
|
222
|
+
projectId: z2.string(),
|
|
223
|
+
channel: z2.literal("webhook"),
|
|
224
|
+
url: z2.string().url(),
|
|
225
|
+
events: z2.array(notificationEventSchema),
|
|
226
|
+
enabled: z2.boolean().default(true),
|
|
227
|
+
webhookSecret: z2.string().optional(),
|
|
228
|
+
createdAt: z2.string(),
|
|
229
|
+
updatedAt: z2.string()
|
|
230
|
+
});
|
|
436
231
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
);
|
|
232
|
+
// ../contracts/src/config-schema.ts
|
|
233
|
+
var configMetadataSchema = z3.object({
|
|
234
|
+
name: z3.string().min(1).max(63).regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, {
|
|
235
|
+
message: "Name must be a lowercase slug (letters, numbers, hyphens)"
|
|
236
|
+
}),
|
|
237
|
+
labels: z3.record(z3.string(), z3.string()).optional().default({})
|
|
238
|
+
});
|
|
239
|
+
var configScheduleSchema = z3.object({
|
|
240
|
+
preset: z3.string().optional(),
|
|
241
|
+
cron: z3.string().optional(),
|
|
242
|
+
timezone: z3.string().optional().default("UTC"),
|
|
243
|
+
providers: z3.array(providerNameSchema).optional().default([])
|
|
244
|
+
}).refine(
|
|
245
|
+
(data) => data.preset && !data.cron || !data.preset && data.cron,
|
|
246
|
+
{ message: 'Exactly one of "preset" or "cron" must be provided' }
|
|
247
|
+
).optional();
|
|
248
|
+
var configNotificationSchema = z3.object({
|
|
249
|
+
channel: z3.literal("webhook"),
|
|
250
|
+
url: z3.string().url(),
|
|
251
|
+
events: z3.array(notificationEventSchema).min(1)
|
|
252
|
+
});
|
|
253
|
+
var configGoogleSchema = z3.object({
|
|
254
|
+
gsc: z3.object({
|
|
255
|
+
propertyUrl: z3.string()
|
|
256
|
+
}).optional(),
|
|
257
|
+
syncSchedule: z3.object({
|
|
258
|
+
preset: z3.string().optional(),
|
|
259
|
+
cron: z3.string().optional()
|
|
260
|
+
}).optional()
|
|
261
|
+
}).optional();
|
|
262
|
+
var configSpecSchema = z3.object({
|
|
263
|
+
displayName: z3.string().min(1),
|
|
264
|
+
canonicalDomain: z3.string().min(1),
|
|
265
|
+
ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
|
|
266
|
+
country: z3.string().length(2),
|
|
267
|
+
language: z3.string().min(2),
|
|
268
|
+
keywords: z3.array(z3.string().min(1)).optional().default([]),
|
|
269
|
+
competitors: z3.array(z3.string().min(1)).optional().default([]),
|
|
270
|
+
providers: z3.array(providerNameSchema).optional().default([]),
|
|
271
|
+
locations: z3.array(locationContextSchema).optional().default([]),
|
|
272
|
+
defaultLocation: z3.string().optional(),
|
|
273
|
+
schedule: configScheduleSchema,
|
|
274
|
+
notifications: z3.array(configNotificationSchema).optional().default([]),
|
|
275
|
+
google: configGoogleSchema
|
|
276
|
+
});
|
|
277
|
+
var projectConfigSchema = z3.object({
|
|
278
|
+
apiVersion: z3.literal("canonry/v1"),
|
|
279
|
+
kind: z3.literal("Project"),
|
|
280
|
+
metadata: configMetadataSchema,
|
|
281
|
+
spec: configSpecSchema
|
|
282
|
+
});
|
|
444
283
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
284
|
+
// ../contracts/src/models.ts
|
|
285
|
+
var MODEL_REGISTRY = {
|
|
286
|
+
gemini: {
|
|
287
|
+
defaultModel: "gemini-3-flash",
|
|
288
|
+
validationPattern: /^gemini-/,
|
|
289
|
+
validationHint: 'model name must start with "gemini-" (e.g. gemini-3-flash)',
|
|
290
|
+
knownModels: [
|
|
291
|
+
{ id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
|
|
292
|
+
{ id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
|
|
293
|
+
{ id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
|
|
294
|
+
{ id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
|
|
295
|
+
]
|
|
296
|
+
},
|
|
297
|
+
openai: {
|
|
298
|
+
defaultModel: "gpt-5.4",
|
|
299
|
+
validationPattern: /^(gpt-|o\d)/,
|
|
300
|
+
validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
|
|
301
|
+
knownModels: [
|
|
302
|
+
{ id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
|
|
303
|
+
{ id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
|
|
304
|
+
{ id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
|
|
305
|
+
{ id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
|
|
306
|
+
{ id: "gpt-5", displayName: "GPT-5", tier: "standard" },
|
|
307
|
+
{ id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
|
|
308
|
+
]
|
|
309
|
+
},
|
|
310
|
+
claude: {
|
|
311
|
+
defaultModel: "claude-sonnet-4-6",
|
|
312
|
+
validationPattern: /^claude-/,
|
|
313
|
+
validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
|
|
314
|
+
knownModels: [
|
|
315
|
+
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
|
|
316
|
+
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
|
|
317
|
+
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
local: {
|
|
321
|
+
defaultModel: "llama3",
|
|
322
|
+
validationPattern: /./,
|
|
323
|
+
validationHint: "any model name accepted",
|
|
324
|
+
knownModels: [
|
|
325
|
+
{ id: "llama3", displayName: "Llama 3", tier: "standard" }
|
|
326
|
+
]
|
|
327
|
+
},
|
|
328
|
+
"cdp:chatgpt": {
|
|
329
|
+
defaultModel: "chatgpt-web",
|
|
330
|
+
validationPattern: /./,
|
|
331
|
+
validationHint: "model is detected from the ChatGPT web UI",
|
|
332
|
+
knownModels: [
|
|
333
|
+
{ id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
function getDefaultModel(provider) {
|
|
338
|
+
return MODEL_REGISTRY[provider].defaultModel;
|
|
339
|
+
}
|
|
340
|
+
function isValidModelName(provider, model) {
|
|
341
|
+
return MODEL_REGISTRY[provider].validationPattern.test(model);
|
|
342
|
+
}
|
|
456
343
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
344
|
+
// ../contracts/src/errors.ts
|
|
345
|
+
var AppError = class extends Error {
|
|
346
|
+
code;
|
|
347
|
+
statusCode;
|
|
348
|
+
details;
|
|
349
|
+
constructor(code, message, statusCode, details) {
|
|
350
|
+
super(message);
|
|
351
|
+
this.name = "AppError";
|
|
352
|
+
this.code = code;
|
|
353
|
+
this.statusCode = statusCode;
|
|
354
|
+
this.details = details;
|
|
355
|
+
}
|
|
356
|
+
toJSON() {
|
|
357
|
+
return {
|
|
358
|
+
error: {
|
|
359
|
+
code: this.code,
|
|
360
|
+
message: this.message,
|
|
361
|
+
...this.details ? { details: this.details } : {}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
function notFound(entity, id) {
|
|
367
|
+
return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
|
|
368
|
+
}
|
|
369
|
+
function validationError(message, details) {
|
|
370
|
+
return new AppError("VALIDATION_ERROR", message, 400, details);
|
|
371
|
+
}
|
|
372
|
+
function authRequired() {
|
|
373
|
+
return new AppError("AUTH_REQUIRED", "Authentication required", 401);
|
|
374
|
+
}
|
|
375
|
+
function authInvalid() {
|
|
376
|
+
return new AppError("AUTH_INVALID", "Invalid API key", 401);
|
|
377
|
+
}
|
|
378
|
+
function runInProgress(projectName) {
|
|
379
|
+
return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
|
|
380
|
+
}
|
|
381
|
+
function unsupportedKind(kind) {
|
|
382
|
+
return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
|
|
383
|
+
}
|
|
469
384
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
)
|
|
385
|
+
// ../contracts/src/google.ts
|
|
386
|
+
import { z as z4 } from "zod";
|
|
387
|
+
var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
|
|
388
|
+
var googleConnectionDtoSchema = z4.object({
|
|
389
|
+
id: z4.string(),
|
|
390
|
+
domain: z4.string(),
|
|
391
|
+
connectionType: googleConnectionTypeSchema,
|
|
392
|
+
propertyId: z4.string().nullable().optional(),
|
|
393
|
+
sitemapUrl: z4.string().nullable().optional(),
|
|
394
|
+
scopes: z4.array(z4.string()).default([]),
|
|
395
|
+
createdAt: z4.string(),
|
|
396
|
+
updatedAt: z4.string()
|
|
397
|
+
});
|
|
398
|
+
var gscSearchDataDtoSchema = z4.object({
|
|
399
|
+
date: z4.string(),
|
|
400
|
+
query: z4.string(),
|
|
401
|
+
page: z4.string(),
|
|
402
|
+
country: z4.string().nullable().optional(),
|
|
403
|
+
device: z4.string().nullable().optional(),
|
|
404
|
+
clicks: z4.number(),
|
|
405
|
+
impressions: z4.number(),
|
|
406
|
+
ctr: z4.number(),
|
|
407
|
+
position: z4.number()
|
|
408
|
+
});
|
|
409
|
+
var gscUrlInspectionDtoSchema = z4.object({
|
|
410
|
+
id: z4.string(),
|
|
411
|
+
url: z4.string(),
|
|
412
|
+
indexingState: z4.string().nullable().optional(),
|
|
413
|
+
verdict: z4.string().nullable().optional(),
|
|
414
|
+
coverageState: z4.string().nullable().optional(),
|
|
415
|
+
pageFetchState: z4.string().nullable().optional(),
|
|
416
|
+
robotsTxtState: z4.string().nullable().optional(),
|
|
417
|
+
crawlTime: z4.string().nullable().optional(),
|
|
418
|
+
lastCrawlResult: z4.string().nullable().optional(),
|
|
419
|
+
isMobileFriendly: z4.boolean().nullable().optional(),
|
|
420
|
+
richResults: z4.array(z4.string()).default([]),
|
|
421
|
+
inspectedAt: z4.string()
|
|
422
|
+
});
|
|
423
|
+
var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
|
|
424
|
+
var gscDeindexedRowSchema = z4.object({
|
|
425
|
+
url: z4.string(),
|
|
426
|
+
previousState: z4.string().nullable(),
|
|
427
|
+
currentState: z4.string().nullable(),
|
|
428
|
+
transitionDate: z4.string()
|
|
429
|
+
});
|
|
430
|
+
var gscReasonGroupSchema = z4.object({
|
|
431
|
+
reason: z4.string(),
|
|
432
|
+
count: z4.number(),
|
|
433
|
+
urls: z4.array(gscUrlInspectionDtoSchema).default([])
|
|
434
|
+
});
|
|
435
|
+
var gscCoverageSummaryDtoSchema = z4.object({
|
|
436
|
+
summary: z4.object({
|
|
437
|
+
total: z4.number(),
|
|
438
|
+
indexed: z4.number(),
|
|
439
|
+
notIndexed: z4.number(),
|
|
440
|
+
deindexed: z4.number(),
|
|
441
|
+
percentage: z4.number()
|
|
442
|
+
}),
|
|
443
|
+
lastInspectedAt: z4.string().nullable(),
|
|
444
|
+
indexed: z4.array(gscUrlInspectionDtoSchema).default([]),
|
|
445
|
+
notIndexed: z4.array(gscUrlInspectionDtoSchema).default([]),
|
|
446
|
+
deindexed: z4.array(gscDeindexedRowSchema).default([]),
|
|
447
|
+
reasonGroups: z4.array(gscReasonGroupSchema).default([])
|
|
448
|
+
});
|
|
449
|
+
var indexingNotificationDtoSchema = z4.object({
|
|
450
|
+
url: z4.string(),
|
|
451
|
+
type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
|
|
452
|
+
notifiedAt: z4.string()
|
|
453
|
+
});
|
|
454
|
+
var indexingRequestResultDtoSchema = z4.object({
|
|
455
|
+
url: z4.string(),
|
|
456
|
+
type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
|
|
457
|
+
notifiedAt: z4.string(),
|
|
458
|
+
status: z4.enum(["success", "error"]),
|
|
459
|
+
error: z4.string().optional()
|
|
460
|
+
});
|
|
461
|
+
var gscCoverageSnapshotDtoSchema = z4.object({
|
|
462
|
+
date: z4.string(),
|
|
463
|
+
indexed: z4.number(),
|
|
464
|
+
notIndexed: z4.number(),
|
|
465
|
+
reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
|
|
466
|
+
});
|
|
480
467
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
468
|
+
// ../contracts/src/bing.ts
|
|
469
|
+
import { z as z5 } from "zod";
|
|
470
|
+
var bingConnectionDtoSchema = z5.object({
|
|
471
|
+
id: z5.string(),
|
|
472
|
+
domain: z5.string(),
|
|
473
|
+
siteUrl: z5.string().nullable().optional(),
|
|
474
|
+
createdAt: z5.string(),
|
|
475
|
+
updatedAt: z5.string()
|
|
476
|
+
});
|
|
477
|
+
var bingUrlInspectionDtoSchema = z5.object({
|
|
478
|
+
id: z5.string(),
|
|
479
|
+
url: z5.string(),
|
|
480
|
+
httpCode: z5.number().nullable().optional(),
|
|
481
|
+
inIndex: z5.boolean().nullable().optional(),
|
|
482
|
+
lastCrawledDate: z5.string().nullable().optional(),
|
|
483
|
+
inIndexDate: z5.string().nullable().optional(),
|
|
484
|
+
inspectedAt: z5.string()
|
|
485
|
+
});
|
|
486
|
+
var bingCoverageSummaryDtoSchema = z5.object({
|
|
487
|
+
summary: z5.object({
|
|
488
|
+
total: z5.number(),
|
|
489
|
+
indexed: z5.number(),
|
|
490
|
+
notIndexed: z5.number(),
|
|
491
|
+
percentage: z5.number()
|
|
492
|
+
}),
|
|
493
|
+
lastInspectedAt: z5.string().nullable(),
|
|
494
|
+
indexed: z5.array(bingUrlInspectionDtoSchema).default([]),
|
|
495
|
+
notIndexed: z5.array(bingUrlInspectionDtoSchema).default([])
|
|
496
|
+
});
|
|
497
|
+
var bingKeywordStatsDtoSchema = z5.object({
|
|
498
|
+
query: z5.string(),
|
|
499
|
+
impressions: z5.number(),
|
|
500
|
+
clicks: z5.number(),
|
|
501
|
+
ctr: z5.number(),
|
|
502
|
+
averagePosition: z5.number()
|
|
503
|
+
});
|
|
504
|
+
var bingSubmitResultDtoSchema = z5.object({
|
|
505
|
+
url: z5.string(),
|
|
506
|
+
status: z5.enum(["success", "error"]),
|
|
507
|
+
submittedAt: z5.string(),
|
|
508
|
+
error: z5.string().optional()
|
|
509
|
+
});
|
|
491
510
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
)
|
|
511
|
+
// ../contracts/src/project.ts
|
|
512
|
+
import { z as z6 } from "zod";
|
|
513
|
+
var configSourceSchema = z6.enum(["cli", "api", "config-file"]);
|
|
514
|
+
var projectDtoSchema = z6.object({
|
|
515
|
+
id: z6.string(),
|
|
516
|
+
name: z6.string(),
|
|
517
|
+
displayName: z6.string().optional(),
|
|
518
|
+
canonicalDomain: z6.string(),
|
|
519
|
+
ownedDomains: z6.array(z6.string()).default([]),
|
|
520
|
+
country: z6.string().length(2),
|
|
521
|
+
language: z6.string().min(2),
|
|
522
|
+
tags: z6.array(z6.string()).default([]),
|
|
523
|
+
labels: z6.record(z6.string(), z6.string()).default({}),
|
|
524
|
+
locations: z6.array(locationContextSchema).default([]),
|
|
525
|
+
defaultLocation: z6.string().nullable().optional(),
|
|
526
|
+
configSource: configSourceSchema.default("cli"),
|
|
527
|
+
configRevision: z6.number().int().positive().default(1),
|
|
528
|
+
createdAt: z6.string().optional(),
|
|
529
|
+
updatedAt: z6.string().optional()
|
|
530
|
+
});
|
|
531
|
+
function normalizeProjectDomain(input) {
|
|
532
|
+
let domain = input.trim().toLowerCase();
|
|
533
|
+
try {
|
|
534
|
+
if (domain.includes("://")) {
|
|
535
|
+
domain = new URL(domain).hostname.toLowerCase();
|
|
536
|
+
}
|
|
537
|
+
} catch {
|
|
538
|
+
}
|
|
539
|
+
return domain.replace(/^www\./, "");
|
|
540
|
+
}
|
|
541
|
+
function effectiveDomains(project) {
|
|
542
|
+
const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
|
|
543
|
+
const seen = /* @__PURE__ */ new Set();
|
|
544
|
+
const result = [];
|
|
545
|
+
for (const d of all) {
|
|
546
|
+
const trimmed = d.trim();
|
|
547
|
+
if (!trimmed) continue;
|
|
548
|
+
const norm = normalizeProjectDomain(trimmed);
|
|
549
|
+
if (seen.has(norm)) continue;
|
|
550
|
+
seen.add(norm);
|
|
551
|
+
result.push(trimmed);
|
|
552
|
+
}
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
501
555
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
)
|
|
556
|
+
// ../contracts/src/run.ts
|
|
557
|
+
import { z as z7 } from "zod";
|
|
558
|
+
var runStatusSchema = z7.enum(["queued", "running", "completed", "partial", "failed"]);
|
|
559
|
+
var runKindSchema = z7.enum(["answer-visibility", "site-audit", "gsc-sync", "inspect-sitemap"]);
|
|
560
|
+
var runTriggerSchema = z7.enum(["manual", "scheduled", "config-apply"]);
|
|
561
|
+
var citationStateSchema = z7.enum(["cited", "not-cited"]);
|
|
562
|
+
var computedTransitionSchema = z7.enum(["new", "cited", "lost", "emerging", "not-cited"]);
|
|
563
|
+
var runDtoSchema = z7.object({
|
|
564
|
+
id: z7.string(),
|
|
565
|
+
projectId: z7.string(),
|
|
566
|
+
kind: runKindSchema,
|
|
567
|
+
status: runStatusSchema,
|
|
568
|
+
trigger: runTriggerSchema.default("manual"),
|
|
569
|
+
location: z7.string().nullable().optional(),
|
|
570
|
+
startedAt: z7.string().nullable().optional(),
|
|
571
|
+
finishedAt: z7.string().nullable().optional(),
|
|
572
|
+
error: z7.string().nullable().optional(),
|
|
573
|
+
createdAt: z7.string()
|
|
574
|
+
});
|
|
575
|
+
var groundingSourceSchema = z7.object({
|
|
576
|
+
uri: z7.string(),
|
|
577
|
+
title: z7.string()
|
|
578
|
+
});
|
|
579
|
+
var querySnapshotDtoSchema = z7.object({
|
|
580
|
+
id: z7.string(),
|
|
581
|
+
runId: z7.string(),
|
|
582
|
+
keywordId: z7.string(),
|
|
583
|
+
keyword: z7.string().optional(),
|
|
584
|
+
provider: providerNameSchema,
|
|
585
|
+
citationState: citationStateSchema,
|
|
586
|
+
transition: computedTransitionSchema.optional(),
|
|
587
|
+
answerText: z7.string().nullable().optional(),
|
|
588
|
+
citedDomains: z7.array(z7.string()).default([]),
|
|
589
|
+
competitorOverlap: z7.array(z7.string()).default([]),
|
|
590
|
+
groundingSources: z7.array(groundingSourceSchema).default([]),
|
|
591
|
+
searchQueries: z7.array(z7.string()).default([]),
|
|
592
|
+
model: z7.string().nullable().optional(),
|
|
593
|
+
location: z7.string().nullable().optional(),
|
|
594
|
+
createdAt: z7.string()
|
|
595
|
+
});
|
|
596
|
+
var auditLogEntrySchema = z7.object({
|
|
597
|
+
id: z7.string(),
|
|
598
|
+
projectId: z7.string().nullable().optional(),
|
|
599
|
+
actor: z7.string(),
|
|
600
|
+
action: z7.string(),
|
|
601
|
+
entityType: z7.string(),
|
|
602
|
+
entityId: z7.string().nullable().optional(),
|
|
603
|
+
diff: z7.unknown().optional(),
|
|
604
|
+
createdAt: z7.string()
|
|
605
|
+
});
|
|
524
606
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
)
|
|
607
|
+
// ../contracts/src/schedule.ts
|
|
608
|
+
import { z as z8 } from "zod";
|
|
609
|
+
var scheduleDtoSchema = z8.object({
|
|
610
|
+
id: z8.string(),
|
|
611
|
+
projectId: z8.string(),
|
|
612
|
+
cronExpr: z8.string(),
|
|
613
|
+
preset: z8.string().nullable().optional(),
|
|
614
|
+
timezone: z8.string().default("UTC"),
|
|
615
|
+
enabled: z8.boolean().default(true),
|
|
616
|
+
providers: z8.array(providerNameSchema).default([]),
|
|
617
|
+
lastRunAt: z8.string().nullable().optional(),
|
|
618
|
+
nextRunAt: z8.string().nullable().optional(),
|
|
619
|
+
createdAt: z8.string(),
|
|
620
|
+
updatedAt: z8.string()
|
|
621
|
+
});
|
|
534
622
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
//
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
crawl_time TEXT,
|
|
596
|
-
last_crawl_result TEXT,
|
|
597
|
-
is_mobile_friendly INTEGER,
|
|
598
|
-
rich_results TEXT NOT NULL DEFAULT '[]',
|
|
599
|
-
referring_urls TEXT NOT NULL DEFAULT '[]',
|
|
600
|
-
inspected_at TEXT NOT NULL,
|
|
601
|
-
created_at TEXT NOT NULL
|
|
602
|
-
)`,
|
|
603
|
-
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
|
|
604
|
-
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
|
|
605
|
-
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
|
|
606
|
-
// v7: GSC coverage snapshots for historical tracking
|
|
607
|
-
`CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
|
|
608
|
-
id TEXT PRIMARY KEY,
|
|
609
|
-
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
610
|
-
sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
611
|
-
date TEXT NOT NULL,
|
|
612
|
-
indexed INTEGER NOT NULL DEFAULT 0,
|
|
613
|
-
not_indexed INTEGER NOT NULL DEFAULT 0,
|
|
614
|
-
reason_breakdown TEXT NOT NULL DEFAULT '{}',
|
|
615
|
-
created_at TEXT NOT NULL
|
|
616
|
-
)`,
|
|
617
|
-
`CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
|
|
618
|
-
`CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
|
|
619
|
-
// v8: Location-aware sweeps — project locations + snapshot location tag
|
|
620
|
-
`ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
|
|
621
|
-
`ALTER TABLE projects ADD COLUMN default_location TEXT`,
|
|
622
|
-
`ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
|
|
623
|
-
// v9: Add location column to runs for per-location run tracking
|
|
624
|
-
`ALTER TABLE runs ADD COLUMN location TEXT`,
|
|
625
|
-
// v10: Add sitemapUrl to google_connections for persistent sitemap storage
|
|
626
|
-
`ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
|
|
627
|
-
// v11: CDP browser provider — screenshot path for captured evidence
|
|
628
|
-
`ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
|
|
623
|
+
// ../contracts/src/source-categories.ts
|
|
624
|
+
var SOURCE_CATEGORY_RULES = [
|
|
625
|
+
// Forums
|
|
626
|
+
{ pattern: "reddit.com", category: "forum", label: "Reddit" },
|
|
627
|
+
{ pattern: "quora.com", category: "forum", label: "Quora" },
|
|
628
|
+
{ pattern: "stackexchange.com", category: "forum", label: "Stack Exchange" },
|
|
629
|
+
{ pattern: "stackoverflow.com", category: "forum", label: "Stack Overflow" },
|
|
630
|
+
{ pattern: "discourse.org", category: "forum", label: "Discourse" },
|
|
631
|
+
// Social
|
|
632
|
+
{ pattern: "linkedin.com", category: "social", label: "LinkedIn" },
|
|
633
|
+
{ pattern: "twitter.com", category: "social", label: "X (Twitter)" },
|
|
634
|
+
{ pattern: "x.com", category: "social", label: "X (Twitter)" },
|
|
635
|
+
{ pattern: "facebook.com", category: "social", label: "Facebook" },
|
|
636
|
+
{ pattern: "instagram.com", category: "social", label: "Instagram" },
|
|
637
|
+
{ pattern: "threads.net", category: "social", label: "Threads" },
|
|
638
|
+
{ pattern: "pinterest.com", category: "social", label: "Pinterest" },
|
|
639
|
+
{ pattern: "tiktok.com", category: "social", label: "TikTok" },
|
|
640
|
+
// Video
|
|
641
|
+
{ pattern: "youtube.com", category: "video", label: "YouTube" },
|
|
642
|
+
{ pattern: "youtu.be", category: "video", label: "YouTube" },
|
|
643
|
+
{ pattern: "vimeo.com", category: "video", label: "Vimeo" },
|
|
644
|
+
// News
|
|
645
|
+
{ pattern: "nytimes.com", category: "news", label: "NY Times" },
|
|
646
|
+
{ pattern: "bbc.com", category: "news", label: "BBC" },
|
|
647
|
+
{ pattern: "bbc.co.uk", category: "news", label: "BBC" },
|
|
648
|
+
{ pattern: "cnn.com", category: "news", label: "CNN" },
|
|
649
|
+
{ pattern: "reuters.com", category: "news", label: "Reuters" },
|
|
650
|
+
{ pattern: "apnews.com", category: "news", label: "AP News" },
|
|
651
|
+
{ pattern: "theguardian.com", category: "news", label: "The Guardian" },
|
|
652
|
+
{ pattern: "washingtonpost.com", category: "news", label: "Washington Post" },
|
|
653
|
+
{ pattern: "wsj.com", category: "news", label: "WSJ" },
|
|
654
|
+
{ pattern: "forbes.com", category: "news", label: "Forbes" },
|
|
655
|
+
{ pattern: "techcrunch.com", category: "news", label: "TechCrunch" },
|
|
656
|
+
{ pattern: "theverge.com", category: "news", label: "The Verge" },
|
|
657
|
+
{ pattern: "wired.com", category: "news", label: "Wired" },
|
|
658
|
+
{ pattern: "arstechnica.com", category: "news", label: "Ars Technica" },
|
|
659
|
+
// Reference
|
|
660
|
+
{ pattern: "wikipedia.org", category: "reference", label: "Wikipedia" },
|
|
661
|
+
{ pattern: "wikimedia.org", category: "reference", label: "Wikimedia" },
|
|
662
|
+
{ pattern: "britannica.com", category: "reference", label: "Britannica" },
|
|
663
|
+
{ pattern: "merriam-webster.com", category: "reference", label: "Merriam-Webster" },
|
|
664
|
+
// Blog / Content platforms
|
|
665
|
+
{ pattern: "medium.com", category: "blog", label: "Medium" },
|
|
666
|
+
{ pattern: "substack.com", category: "blog", label: "Substack" },
|
|
667
|
+
{ pattern: "dev.to", category: "blog", label: "DEV Community" },
|
|
668
|
+
{ pattern: "hashnode.dev", category: "blog", label: "Hashnode" },
|
|
669
|
+
{ pattern: "wordpress.com", category: "blog", label: "WordPress" },
|
|
670
|
+
{ pattern: "blogger.com", category: "blog", label: "Blogger" },
|
|
671
|
+
{ pattern: "hubspot.com", category: "blog", label: "HubSpot" },
|
|
672
|
+
// E-commerce
|
|
673
|
+
{ pattern: "amazon.com", category: "ecommerce", label: "Amazon" },
|
|
674
|
+
{ pattern: "amazon.co.uk", category: "ecommerce", label: "Amazon UK" },
|
|
675
|
+
{ pattern: "shopify.com", category: "ecommerce", label: "Shopify" },
|
|
676
|
+
{ pattern: "ebay.com", category: "ecommerce", label: "eBay" },
|
|
677
|
+
// Academic
|
|
678
|
+
{ pattern: "scholar.google.com", category: "academic", label: "Google Scholar" },
|
|
679
|
+
{ pattern: "arxiv.org", category: "academic", label: "arXiv" },
|
|
680
|
+
{ pattern: "pubmed.ncbi.nlm.nih.gov", category: "academic", label: "PubMed" },
|
|
681
|
+
{ pattern: "researchgate.net", category: "academic", label: "ResearchGate" },
|
|
682
|
+
{ pattern: ".edu", category: "academic", label: "Academic (.edu)" }
|
|
629
683
|
];
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
684
|
+
var CATEGORY_LABELS = {
|
|
685
|
+
social: "Social Media",
|
|
686
|
+
forum: "Forums & Q&A",
|
|
687
|
+
news: "News & Media",
|
|
688
|
+
reference: "Reference",
|
|
689
|
+
blog: "Blogs & Content",
|
|
690
|
+
ecommerce: "E-commerce",
|
|
691
|
+
video: "Video",
|
|
692
|
+
academic: "Academic",
|
|
693
|
+
other: "Other"
|
|
694
|
+
};
|
|
695
|
+
function categorizeSource(uri) {
|
|
696
|
+
let domain;
|
|
697
|
+
try {
|
|
698
|
+
const url = new URL(uri.startsWith("http") ? uri : `https://${uri}`);
|
|
699
|
+
domain = url.hostname.replace(/^www\./, "");
|
|
700
|
+
} catch {
|
|
701
|
+
domain = uri.replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0] ?? uri;
|
|
634
702
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
703
|
+
const domainLower = domain.toLowerCase();
|
|
704
|
+
for (const rule of SOURCE_CATEGORY_RULES) {
|
|
705
|
+
if (domainLower === rule.pattern || domainLower.endsWith(`.${rule.pattern}`) || rule.pattern.startsWith(".") && domainLower.endsWith(rule.pattern)) {
|
|
706
|
+
return { category: rule.category, label: rule.label, domain };
|
|
639
707
|
}
|
|
640
708
|
}
|
|
709
|
+
return { category: "other", label: CATEGORY_LABELS.other, domain };
|
|
641
710
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
import { z as z3 } from "zod";
|
|
645
|
-
|
|
646
|
-
// ../contracts/src/provider.ts
|
|
647
|
-
import { z } from "zod";
|
|
648
|
-
var providerQuotaPolicySchema = z.object({
|
|
649
|
-
maxConcurrency: z.number().int().positive(),
|
|
650
|
-
maxRequestsPerMinute: z.number().int().positive(),
|
|
651
|
-
maxRequestsPerDay: z.number().int().positive()
|
|
652
|
-
});
|
|
653
|
-
var PROVIDER_NAMES = ["gemini", "openai", "claude", "local", "cdp:chatgpt"];
|
|
654
|
-
var providerNameSchema = z.enum(PROVIDER_NAMES);
|
|
655
|
-
var PROVIDER_MODE = {
|
|
656
|
-
gemini: "api",
|
|
657
|
-
openai: "api",
|
|
658
|
-
claude: "api",
|
|
659
|
-
local: "api",
|
|
660
|
-
"cdp:chatgpt": "browser"
|
|
661
|
-
};
|
|
662
|
-
function isBrowserProvider(name) {
|
|
663
|
-
return PROVIDER_MODE[name] === "browser";
|
|
664
|
-
}
|
|
665
|
-
var CDP_TARGETS = ["cdp:chatgpt"];
|
|
666
|
-
function parseProviderName(input) {
|
|
667
|
-
const lower = input.trim().toLowerCase();
|
|
668
|
-
return PROVIDER_NAMES.includes(lower) ? lower : void 0;
|
|
669
|
-
}
|
|
670
|
-
function resolveProviderInput(input) {
|
|
671
|
-
const lower = input.trim().toLowerCase();
|
|
672
|
-
if (lower === "cdp") {
|
|
673
|
-
return [...CDP_TARGETS];
|
|
674
|
-
}
|
|
675
|
-
const parsed = parseProviderName(lower);
|
|
676
|
-
return parsed ? [parsed] : [];
|
|
711
|
+
function categoryLabel(category) {
|
|
712
|
+
return CATEGORY_LABELS[category];
|
|
677
713
|
}
|
|
678
|
-
var locationContextSchema = z.object({
|
|
679
|
-
label: z.string().min(1),
|
|
680
|
-
city: z.string().min(1),
|
|
681
|
-
region: z.string().min(1),
|
|
682
|
-
country: z.string().length(2),
|
|
683
|
-
timezone: z.string().optional()
|
|
684
|
-
});
|
|
685
714
|
|
|
686
|
-
// ../
|
|
687
|
-
import
|
|
688
|
-
|
|
689
|
-
"citation.lost",
|
|
690
|
-
"citation.gained",
|
|
691
|
-
"run.completed",
|
|
692
|
-
"run.failed"
|
|
693
|
-
]);
|
|
694
|
-
var notificationDtoSchema = z2.object({
|
|
695
|
-
id: z2.string(),
|
|
696
|
-
projectId: z2.string(),
|
|
697
|
-
channel: z2.literal("webhook"),
|
|
698
|
-
url: z2.string().url(),
|
|
699
|
-
events: z2.array(notificationEventSchema),
|
|
700
|
-
enabled: z2.boolean().default(true),
|
|
701
|
-
webhookSecret: z2.string().optional(),
|
|
702
|
-
createdAt: z2.string(),
|
|
703
|
-
updatedAt: z2.string()
|
|
704
|
-
});
|
|
715
|
+
// ../api-routes/src/auth.ts
|
|
716
|
+
import crypto2 from "crypto";
|
|
717
|
+
import { eq } from "drizzle-orm";
|
|
705
718
|
|
|
706
|
-
// ../
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
719
|
+
// ../db/src/client.ts
|
|
720
|
+
import { mkdirSync } from "fs";
|
|
721
|
+
import { dirname } from "path";
|
|
722
|
+
import Database from "better-sqlite3";
|
|
723
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
724
|
+
|
|
725
|
+
// ../db/src/schema.ts
|
|
726
|
+
var schema_exports = {};
|
|
727
|
+
__export(schema_exports, {
|
|
728
|
+
apiKeys: () => apiKeys,
|
|
729
|
+
auditLog: () => auditLog,
|
|
730
|
+
bingConnections: () => bingConnections,
|
|
731
|
+
bingKeywordStats: () => bingKeywordStats,
|
|
732
|
+
bingUrlInspections: () => bingUrlInspections,
|
|
733
|
+
competitors: () => competitors,
|
|
734
|
+
googleConnections: () => googleConnections,
|
|
735
|
+
gscCoverageSnapshots: () => gscCoverageSnapshots,
|
|
736
|
+
gscSearchData: () => gscSearchData,
|
|
737
|
+
gscUrlInspections: () => gscUrlInspections,
|
|
738
|
+
keywords: () => keywords,
|
|
739
|
+
notifications: () => notifications,
|
|
740
|
+
projects: () => projects,
|
|
741
|
+
querySnapshots: () => querySnapshots,
|
|
742
|
+
runs: () => runs,
|
|
743
|
+
schedules: () => schedules,
|
|
744
|
+
usageCounters: () => usageCounters
|
|
712
745
|
});
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
(
|
|
720
|
-
|
|
721
|
-
).
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
syncSchedule: z3.object({
|
|
732
|
-
preset: z3.string().optional(),
|
|
733
|
-
cron: z3.string().optional()
|
|
734
|
-
}).optional()
|
|
735
|
-
}).optional();
|
|
736
|
-
var configSpecSchema = z3.object({
|
|
737
|
-
displayName: z3.string().min(1),
|
|
738
|
-
canonicalDomain: z3.string().min(1),
|
|
739
|
-
ownedDomains: z3.array(z3.string().min(1)).optional().default([]),
|
|
740
|
-
country: z3.string().length(2),
|
|
741
|
-
language: z3.string().min(2),
|
|
742
|
-
keywords: z3.array(z3.string().min(1)).optional().default([]),
|
|
743
|
-
competitors: z3.array(z3.string().min(1)).optional().default([]),
|
|
744
|
-
providers: z3.array(providerNameSchema).optional().default([]),
|
|
745
|
-
locations: z3.array(locationContextSchema).optional().default([]),
|
|
746
|
-
defaultLocation: z3.string().optional(),
|
|
747
|
-
schedule: configScheduleSchema,
|
|
748
|
-
notifications: z3.array(configNotificationSchema).optional().default([]),
|
|
749
|
-
google: configGoogleSchema
|
|
750
|
-
});
|
|
751
|
-
var projectConfigSchema = z3.object({
|
|
752
|
-
apiVersion: z3.literal("canonry/v1"),
|
|
753
|
-
kind: z3.literal("Project"),
|
|
754
|
-
metadata: configMetadataSchema,
|
|
755
|
-
spec: configSpecSchema
|
|
746
|
+
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
|
747
|
+
var projects = sqliteTable("projects", {
|
|
748
|
+
id: text("id").primaryKey(),
|
|
749
|
+
name: text("name").notNull().unique(),
|
|
750
|
+
displayName: text("display_name").notNull(),
|
|
751
|
+
canonicalDomain: text("canonical_domain").notNull(),
|
|
752
|
+
ownedDomains: text("owned_domains").notNull().default("[]"),
|
|
753
|
+
country: text("country").notNull(),
|
|
754
|
+
language: text("language").notNull(),
|
|
755
|
+
tags: text("tags").notNull().default("[]"),
|
|
756
|
+
labels: text("labels").notNull().default("{}"),
|
|
757
|
+
providers: text("providers").notNull().default("[]"),
|
|
758
|
+
locations: text("locations").notNull().default("[]"),
|
|
759
|
+
defaultLocation: text("default_location"),
|
|
760
|
+
configSource: text("config_source").notNull().default("cli"),
|
|
761
|
+
configRevision: integer("config_revision").notNull().default(1),
|
|
762
|
+
createdAt: text("created_at").notNull(),
|
|
763
|
+
updatedAt: text("updated_at").notNull()
|
|
756
764
|
});
|
|
765
|
+
var keywords = sqliteTable("keywords", {
|
|
766
|
+
id: text("id").primaryKey(),
|
|
767
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
768
|
+
keyword: text("keyword").notNull(),
|
|
769
|
+
createdAt: text("created_at").notNull()
|
|
770
|
+
}, (table) => [
|
|
771
|
+
index("idx_keywords_project").on(table.projectId),
|
|
772
|
+
uniqueIndex("idx_keywords_project_keyword").on(table.projectId, table.keyword)
|
|
773
|
+
]);
|
|
774
|
+
var competitors = sqliteTable("competitors", {
|
|
775
|
+
id: text("id").primaryKey(),
|
|
776
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
777
|
+
domain: text("domain").notNull(),
|
|
778
|
+
createdAt: text("created_at").notNull()
|
|
779
|
+
}, (table) => [
|
|
780
|
+
index("idx_competitors_project").on(table.projectId),
|
|
781
|
+
uniqueIndex("idx_competitors_project_domain").on(table.projectId, table.domain)
|
|
782
|
+
]);
|
|
783
|
+
var runs = sqliteTable("runs", {
|
|
784
|
+
id: text("id").primaryKey(),
|
|
785
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
786
|
+
kind: text("kind").notNull().default("answer-visibility"),
|
|
787
|
+
status: text("status").notNull().default("queued"),
|
|
788
|
+
trigger: text("trigger").notNull().default("manual"),
|
|
789
|
+
location: text("location"),
|
|
790
|
+
startedAt: text("started_at"),
|
|
791
|
+
finishedAt: text("finished_at"),
|
|
792
|
+
error: text("error"),
|
|
793
|
+
createdAt: text("created_at").notNull()
|
|
794
|
+
}, (table) => [
|
|
795
|
+
index("idx_runs_project").on(table.projectId),
|
|
796
|
+
index("idx_runs_status").on(table.status)
|
|
797
|
+
]);
|
|
798
|
+
var querySnapshots = sqliteTable("query_snapshots", {
|
|
799
|
+
id: text("id").primaryKey(),
|
|
800
|
+
runId: text("run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
801
|
+
keywordId: text("keyword_id").notNull().references(() => keywords.id, { onDelete: "cascade" }),
|
|
802
|
+
provider: text("provider").notNull().default("gemini"),
|
|
803
|
+
model: text("model"),
|
|
804
|
+
citationState: text("citation_state").notNull(),
|
|
805
|
+
answerText: text("answer_text"),
|
|
806
|
+
citedDomains: text("cited_domains").notNull().default("[]"),
|
|
807
|
+
competitorOverlap: text("competitor_overlap").notNull().default("[]"),
|
|
808
|
+
location: text("location"),
|
|
809
|
+
screenshotPath: text("screenshot_path"),
|
|
810
|
+
rawResponse: text("raw_response"),
|
|
811
|
+
createdAt: text("created_at").notNull()
|
|
812
|
+
}, (table) => [
|
|
813
|
+
index("idx_snapshots_run").on(table.runId),
|
|
814
|
+
index("idx_snapshots_keyword").on(table.keywordId)
|
|
815
|
+
]);
|
|
816
|
+
var auditLog = sqliteTable("audit_log", {
|
|
817
|
+
id: text("id").primaryKey(),
|
|
818
|
+
projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
|
819
|
+
actor: text("actor").notNull(),
|
|
820
|
+
action: text("action").notNull(),
|
|
821
|
+
entityType: text("entity_type").notNull(),
|
|
822
|
+
entityId: text("entity_id"),
|
|
823
|
+
diff: text("diff"),
|
|
824
|
+
createdAt: text("created_at").notNull()
|
|
825
|
+
}, (table) => [
|
|
826
|
+
index("idx_audit_log_project").on(table.projectId),
|
|
827
|
+
index("idx_audit_log_created").on(table.createdAt)
|
|
828
|
+
]);
|
|
829
|
+
var apiKeys = sqliteTable("api_keys", {
|
|
830
|
+
id: text("id").primaryKey(),
|
|
831
|
+
name: text("name").notNull(),
|
|
832
|
+
keyHash: text("key_hash").notNull().unique(),
|
|
833
|
+
keyPrefix: text("key_prefix").notNull(),
|
|
834
|
+
scopes: text("scopes").notNull().default('["*"]'),
|
|
835
|
+
createdAt: text("created_at").notNull(),
|
|
836
|
+
lastUsedAt: text("last_used_at"),
|
|
837
|
+
revokedAt: text("revoked_at")
|
|
838
|
+
}, (table) => [
|
|
839
|
+
index("idx_api_keys_prefix").on(table.keyPrefix)
|
|
840
|
+
]);
|
|
841
|
+
var schedules = sqliteTable("schedules", {
|
|
842
|
+
id: text("id").primaryKey(),
|
|
843
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
844
|
+
cronExpr: text("cron_expr").notNull(),
|
|
845
|
+
preset: text("preset"),
|
|
846
|
+
timezone: text("timezone").notNull().default("UTC"),
|
|
847
|
+
enabled: integer("enabled").notNull().default(1),
|
|
848
|
+
providers: text("providers").notNull().default("[]"),
|
|
849
|
+
lastRunAt: text("last_run_at"),
|
|
850
|
+
nextRunAt: text("next_run_at"),
|
|
851
|
+
createdAt: text("created_at").notNull(),
|
|
852
|
+
updatedAt: text("updated_at").notNull()
|
|
853
|
+
}, (table) => [
|
|
854
|
+
uniqueIndex("idx_schedules_project").on(table.projectId)
|
|
855
|
+
]);
|
|
856
|
+
var notifications = sqliteTable("notifications", {
|
|
857
|
+
id: text("id").primaryKey(),
|
|
858
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
859
|
+
channel: text("channel").notNull(),
|
|
860
|
+
config: text("config").notNull(),
|
|
861
|
+
webhookSecret: text("webhook_secret"),
|
|
862
|
+
enabled: integer("enabled").notNull().default(1),
|
|
863
|
+
createdAt: text("created_at").notNull(),
|
|
864
|
+
updatedAt: text("updated_at").notNull()
|
|
865
|
+
}, (table) => [
|
|
866
|
+
index("idx_notifications_project").on(table.projectId)
|
|
867
|
+
]);
|
|
868
|
+
var googleConnections = sqliteTable("google_connections", {
|
|
869
|
+
id: text("id").primaryKey(),
|
|
870
|
+
domain: text("domain").notNull(),
|
|
871
|
+
connectionType: text("connection_type").notNull(),
|
|
872
|
+
propertyId: text("property_id"),
|
|
873
|
+
sitemapUrl: text("sitemap_url"),
|
|
874
|
+
accessToken: text("access_token"),
|
|
875
|
+
refreshToken: text("refresh_token"),
|
|
876
|
+
tokenExpiresAt: text("token_expires_at"),
|
|
877
|
+
scopes: text("scopes").notNull().default("[]"),
|
|
878
|
+
createdAt: text("created_at").notNull(),
|
|
879
|
+
updatedAt: text("updated_at").notNull()
|
|
880
|
+
}, (table) => [
|
|
881
|
+
uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
|
|
882
|
+
]);
|
|
883
|
+
var gscSearchData = sqliteTable("gsc_search_data", {
|
|
884
|
+
id: text("id").primaryKey(),
|
|
885
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
886
|
+
syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
887
|
+
date: text("date").notNull(),
|
|
888
|
+
query: text("query").notNull(),
|
|
889
|
+
page: text("page").notNull(),
|
|
890
|
+
country: text("country"),
|
|
891
|
+
device: text("device"),
|
|
892
|
+
clicks: integer("clicks").notNull().default(0),
|
|
893
|
+
impressions: integer("impressions").notNull().default(0),
|
|
894
|
+
ctr: text("ctr").notNull().default("0"),
|
|
895
|
+
position: text("position").notNull().default("0"),
|
|
896
|
+
createdAt: text("created_at").notNull()
|
|
897
|
+
}, (table) => [
|
|
898
|
+
index("idx_gsc_search_project_date").on(table.projectId, table.date),
|
|
899
|
+
index("idx_gsc_search_query").on(table.query),
|
|
900
|
+
index("idx_gsc_search_run").on(table.syncRunId)
|
|
901
|
+
]);
|
|
902
|
+
var gscUrlInspections = sqliteTable("gsc_url_inspections", {
|
|
903
|
+
id: text("id").primaryKey(),
|
|
904
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
905
|
+
syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
|
|
906
|
+
url: text("url").notNull(),
|
|
907
|
+
indexingState: text("indexing_state"),
|
|
908
|
+
verdict: text("verdict"),
|
|
909
|
+
coverageState: text("coverage_state"),
|
|
910
|
+
pageFetchState: text("page_fetch_state"),
|
|
911
|
+
robotsTxtState: text("robots_txt_state"),
|
|
912
|
+
crawlTime: text("crawl_time"),
|
|
913
|
+
lastCrawlResult: text("last_crawl_result"),
|
|
914
|
+
isMobileFriendly: integer("is_mobile_friendly"),
|
|
915
|
+
richResults: text("rich_results").notNull().default("[]"),
|
|
916
|
+
referringUrls: text("referring_urls").notNull().default("[]"),
|
|
917
|
+
inspectedAt: text("inspected_at").notNull(),
|
|
918
|
+
createdAt: text("created_at").notNull()
|
|
919
|
+
}, (table) => [
|
|
920
|
+
index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
|
|
921
|
+
index("idx_gsc_inspect_run").on(table.syncRunId),
|
|
922
|
+
index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
|
|
923
|
+
]);
|
|
924
|
+
var gscCoverageSnapshots = sqliteTable("gsc_coverage_snapshots", {
|
|
925
|
+
id: text("id").primaryKey(),
|
|
926
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
927
|
+
syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
|
|
928
|
+
date: text("date").notNull(),
|
|
929
|
+
indexed: integer("indexed").notNull().default(0),
|
|
930
|
+
notIndexed: integer("not_indexed").notNull().default(0),
|
|
931
|
+
reasonBreakdown: text("reason_breakdown").notNull().default("{}"),
|
|
932
|
+
createdAt: text("created_at").notNull()
|
|
933
|
+
}, (table) => [
|
|
934
|
+
index("idx_gsc_coverage_snap_project_date").on(table.projectId, table.date),
|
|
935
|
+
index("idx_gsc_coverage_snap_run").on(table.syncRunId)
|
|
936
|
+
]);
|
|
937
|
+
var bingConnections = sqliteTable("bing_connections", {
|
|
938
|
+
id: text("id").primaryKey(),
|
|
939
|
+
domain: text("domain").notNull(),
|
|
940
|
+
siteUrl: text("site_url"),
|
|
941
|
+
createdAt: text("created_at").notNull(),
|
|
942
|
+
updatedAt: text("updated_at").notNull()
|
|
943
|
+
}, (table) => [
|
|
944
|
+
uniqueIndex("idx_bing_conn_domain").on(table.domain)
|
|
945
|
+
]);
|
|
946
|
+
var bingUrlInspections = sqliteTable("bing_url_inspections", {
|
|
947
|
+
id: text("id").primaryKey(),
|
|
948
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
949
|
+
url: text("url").notNull(),
|
|
950
|
+
httpCode: integer("http_code"),
|
|
951
|
+
inIndex: integer("in_index"),
|
|
952
|
+
lastCrawledDate: text("last_crawled_date"),
|
|
953
|
+
inIndexDate: text("in_index_date"),
|
|
954
|
+
inspectedAt: text("inspected_at").notNull(),
|
|
955
|
+
createdAt: text("created_at").notNull()
|
|
956
|
+
}, (table) => [
|
|
957
|
+
index("idx_bing_inspect_project_url").on(table.projectId, table.url),
|
|
958
|
+
index("idx_bing_inspect_url_time").on(table.url, table.inspectedAt)
|
|
959
|
+
]);
|
|
960
|
+
var bingKeywordStats = sqliteTable("bing_keyword_stats", {
|
|
961
|
+
id: text("id").primaryKey(),
|
|
962
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
963
|
+
query: text("query").notNull(),
|
|
964
|
+
impressions: integer("impressions").notNull().default(0),
|
|
965
|
+
clicks: integer("clicks").notNull().default(0),
|
|
966
|
+
ctr: text("ctr").notNull().default("0"),
|
|
967
|
+
averagePosition: text("average_position").notNull().default("0"),
|
|
968
|
+
syncedAt: text("synced_at").notNull(),
|
|
969
|
+
createdAt: text("created_at").notNull()
|
|
970
|
+
}, (table) => [
|
|
971
|
+
index("idx_bing_keyword_project").on(table.projectId),
|
|
972
|
+
index("idx_bing_keyword_query").on(table.query)
|
|
973
|
+
]);
|
|
974
|
+
var usageCounters = sqliteTable("usage_counters", {
|
|
975
|
+
id: text("id").primaryKey(),
|
|
976
|
+
scope: text("scope").notNull(),
|
|
977
|
+
period: text("period").notNull(),
|
|
978
|
+
metric: text("metric").notNull(),
|
|
979
|
+
count: integer("count").notNull().default(0),
|
|
980
|
+
updatedAt: text("updated_at").notNull()
|
|
981
|
+
}, (table) => [
|
|
982
|
+
uniqueIndex("idx_usage_scope_period_metric").on(table.scope, table.period, table.metric),
|
|
983
|
+
index("idx_usage_scope_period").on(table.scope, table.period)
|
|
984
|
+
]);
|
|
757
985
|
|
|
758
|
-
// ../
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
{ id: "gemini-3.1-pro-preview", displayName: "Gemini 3.1 Pro (Preview)", tier: "flagship" },
|
|
766
|
-
{ id: "gemini-3-flash-preview", displayName: "Gemini 3 Flash (Preview)", tier: "standard" },
|
|
767
|
-
{ id: "gemini-3.1-flash-lite-preview", displayName: "Gemini 3.1 Flash-Lite (Preview)", tier: "economy" },
|
|
768
|
-
{ id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", tier: "standard" }
|
|
769
|
-
]
|
|
770
|
-
},
|
|
771
|
-
openai: {
|
|
772
|
-
defaultModel: "gpt-5.4",
|
|
773
|
-
validationPattern: /^(gpt-|o\d)/,
|
|
774
|
-
validationHint: "expected a GPT or o-series model name (e.g. gpt-5.4, o3)",
|
|
775
|
-
knownModels: [
|
|
776
|
-
{ id: "gpt-5.4", displayName: "GPT-5.4", tier: "flagship" },
|
|
777
|
-
{ id: "gpt-5.4-pro", displayName: "GPT-5.4 Pro", tier: "flagship" },
|
|
778
|
-
{ id: "gpt-5-mini", displayName: "GPT-5 Mini", tier: "fast" },
|
|
779
|
-
{ id: "gpt-5-nano", displayName: "GPT-5 Nano", tier: "economy" },
|
|
780
|
-
{ id: "gpt-5", displayName: "GPT-5", tier: "standard" },
|
|
781
|
-
{ id: "gpt-4.1", displayName: "GPT-4.1", tier: "standard" }
|
|
782
|
-
]
|
|
783
|
-
},
|
|
784
|
-
claude: {
|
|
785
|
-
defaultModel: "claude-sonnet-4-6",
|
|
786
|
-
validationPattern: /^claude-/,
|
|
787
|
-
validationHint: 'model name must start with "claude-" (e.g. claude-sonnet-4-6)',
|
|
788
|
-
knownModels: [
|
|
789
|
-
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6", tier: "flagship" },
|
|
790
|
-
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", tier: "standard" },
|
|
791
|
-
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", tier: "fast" }
|
|
792
|
-
]
|
|
793
|
-
},
|
|
794
|
-
local: {
|
|
795
|
-
defaultModel: "llama3",
|
|
796
|
-
validationPattern: /./,
|
|
797
|
-
validationHint: "any model name accepted",
|
|
798
|
-
knownModels: [
|
|
799
|
-
{ id: "llama3", displayName: "Llama 3", tier: "standard" }
|
|
800
|
-
]
|
|
801
|
-
},
|
|
802
|
-
"cdp:chatgpt": {
|
|
803
|
-
defaultModel: "chatgpt-web",
|
|
804
|
-
validationPattern: /./,
|
|
805
|
-
validationHint: "model is detected from the ChatGPT web UI",
|
|
806
|
-
knownModels: [
|
|
807
|
-
{ id: "chatgpt-web", displayName: "ChatGPT (Web UI)", tier: "standard" }
|
|
808
|
-
]
|
|
809
|
-
}
|
|
810
|
-
};
|
|
811
|
-
function getDefaultModel(provider) {
|
|
812
|
-
return MODEL_REGISTRY[provider].defaultModel;
|
|
813
|
-
}
|
|
814
|
-
function isValidModelName(provider, model) {
|
|
815
|
-
return MODEL_REGISTRY[provider].validationPattern.test(model);
|
|
986
|
+
// ../db/src/client.ts
|
|
987
|
+
function createClient(databasePath) {
|
|
988
|
+
mkdirSync(dirname(databasePath), { recursive: true });
|
|
989
|
+
const sqlite = new Database(databasePath);
|
|
990
|
+
sqlite.pragma("journal_mode = WAL");
|
|
991
|
+
sqlite.pragma("foreign_keys = ON");
|
|
992
|
+
return drizzle(sqlite, { schema: schema_exports });
|
|
816
993
|
}
|
|
817
994
|
|
|
818
|
-
// ../
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
};
|
|
840
|
-
function notFound(entity, id) {
|
|
841
|
-
return new AppError("NOT_FOUND", `${entity} '${id}' not found`, 404);
|
|
842
|
-
}
|
|
843
|
-
function validationError(message, details) {
|
|
844
|
-
return new AppError("VALIDATION_ERROR", message, 400, details);
|
|
845
|
-
}
|
|
846
|
-
function authRequired() {
|
|
847
|
-
return new AppError("AUTH_REQUIRED", "Authentication required", 401);
|
|
848
|
-
}
|
|
849
|
-
function authInvalid() {
|
|
850
|
-
return new AppError("AUTH_INVALID", "Invalid API key", 401);
|
|
851
|
-
}
|
|
852
|
-
function runInProgress(projectName) {
|
|
853
|
-
return new AppError("RUN_IN_PROGRESS", `A run is already in progress for '${projectName}'`, 409);
|
|
854
|
-
}
|
|
855
|
-
function unsupportedKind(kind) {
|
|
856
|
-
return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
|
|
857
|
-
}
|
|
995
|
+
// ../db/src/migrate.ts
|
|
996
|
+
import { sql } from "drizzle-orm";
|
|
997
|
+
var MIGRATION_SQL = `
|
|
998
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
999
|
+
id TEXT PRIMARY KEY,
|
|
1000
|
+
name TEXT NOT NULL UNIQUE,
|
|
1001
|
+
display_name TEXT NOT NULL,
|
|
1002
|
+
canonical_domain TEXT NOT NULL,
|
|
1003
|
+
owned_domains TEXT NOT NULL DEFAULT '[]',
|
|
1004
|
+
country TEXT NOT NULL,
|
|
1005
|
+
language TEXT NOT NULL,
|
|
1006
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
1007
|
+
labels TEXT NOT NULL DEFAULT '{}',
|
|
1008
|
+
providers TEXT NOT NULL DEFAULT '[]',
|
|
1009
|
+
config_source TEXT NOT NULL DEFAULT 'cli',
|
|
1010
|
+
config_revision INTEGER NOT NULL DEFAULT 1,
|
|
1011
|
+
created_at TEXT NOT NULL,
|
|
1012
|
+
updated_at TEXT NOT NULL
|
|
1013
|
+
);
|
|
858
1014
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
reasonGroups: z4.array(gscReasonGroupSchema).default([])
|
|
922
|
-
});
|
|
923
|
-
var indexingNotificationDtoSchema = z4.object({
|
|
924
|
-
url: z4.string(),
|
|
925
|
-
type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
|
|
926
|
-
notifiedAt: z4.string()
|
|
927
|
-
});
|
|
928
|
-
var indexingRequestResultDtoSchema = z4.object({
|
|
929
|
-
url: z4.string(),
|
|
930
|
-
type: z4.enum(["URL_UPDATED", "URL_DELETED"]),
|
|
931
|
-
notifiedAt: z4.string(),
|
|
932
|
-
status: z4.enum(["success", "error"]),
|
|
933
|
-
error: z4.string().optional()
|
|
934
|
-
});
|
|
935
|
-
var gscCoverageSnapshotDtoSchema = z4.object({
|
|
936
|
-
date: z4.string(),
|
|
937
|
-
indexed: z4.number(),
|
|
938
|
-
notIndexed: z4.number(),
|
|
939
|
-
reasonBreakdown: z4.record(z4.string(), z4.number()).default({})
|
|
940
|
-
});
|
|
1015
|
+
CREATE TABLE IF NOT EXISTS keywords (
|
|
1016
|
+
id TEXT PRIMARY KEY,
|
|
1017
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1018
|
+
keyword TEXT NOT NULL,
|
|
1019
|
+
created_at TEXT NOT NULL,
|
|
1020
|
+
UNIQUE(project_id, keyword)
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
CREATE TABLE IF NOT EXISTS competitors (
|
|
1024
|
+
id TEXT PRIMARY KEY,
|
|
1025
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1026
|
+
domain TEXT NOT NULL,
|
|
1027
|
+
created_at TEXT NOT NULL,
|
|
1028
|
+
UNIQUE(project_id, domain)
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
1032
|
+
id TEXT PRIMARY KEY,
|
|
1033
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1034
|
+
kind TEXT NOT NULL DEFAULT 'answer-visibility',
|
|
1035
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
1036
|
+
trigger TEXT NOT NULL DEFAULT 'manual',
|
|
1037
|
+
started_at TEXT,
|
|
1038
|
+
finished_at TEXT,
|
|
1039
|
+
error TEXT,
|
|
1040
|
+
created_at TEXT NOT NULL
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
CREATE TABLE IF NOT EXISTS query_snapshots (
|
|
1044
|
+
id TEXT PRIMARY KEY,
|
|
1045
|
+
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
1046
|
+
keyword_id TEXT NOT NULL REFERENCES keywords(id) ON DELETE CASCADE,
|
|
1047
|
+
provider TEXT NOT NULL DEFAULT 'gemini',
|
|
1048
|
+
citation_state TEXT NOT NULL,
|
|
1049
|
+
answer_text TEXT,
|
|
1050
|
+
cited_domains TEXT NOT NULL DEFAULT '[]',
|
|
1051
|
+
competitor_overlap TEXT NOT NULL DEFAULT '[]',
|
|
1052
|
+
raw_response TEXT,
|
|
1053
|
+
created_at TEXT NOT NULL
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
1057
|
+
id TEXT PRIMARY KEY,
|
|
1058
|
+
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
1059
|
+
actor TEXT NOT NULL,
|
|
1060
|
+
action TEXT NOT NULL,
|
|
1061
|
+
entity_type TEXT NOT NULL,
|
|
1062
|
+
entity_id TEXT,
|
|
1063
|
+
diff TEXT,
|
|
1064
|
+
created_at TEXT NOT NULL
|
|
1065
|
+
);
|
|
1066
|
+
|
|
1067
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
1068
|
+
id TEXT PRIMARY KEY,
|
|
1069
|
+
name TEXT NOT NULL,
|
|
1070
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
1071
|
+
key_prefix TEXT NOT NULL,
|
|
1072
|
+
scopes TEXT NOT NULL DEFAULT '["*"]',
|
|
1073
|
+
created_at TEXT NOT NULL,
|
|
1074
|
+
last_used_at TEXT,
|
|
1075
|
+
revoked_at TEXT
|
|
1076
|
+
);
|
|
941
1077
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
country: z5.string().length(2),
|
|
952
|
-
language: z5.string().min(2),
|
|
953
|
-
tags: z5.array(z5.string()).default([]),
|
|
954
|
-
labels: z5.record(z5.string(), z5.string()).default({}),
|
|
955
|
-
locations: z5.array(locationContextSchema).default([]),
|
|
956
|
-
defaultLocation: z5.string().nullable().optional(),
|
|
957
|
-
configSource: configSourceSchema.default("cli"),
|
|
958
|
-
configRevision: z5.number().int().positive().default(1),
|
|
959
|
-
createdAt: z5.string().optional(),
|
|
960
|
-
updatedAt: z5.string().optional()
|
|
961
|
-
});
|
|
962
|
-
function normalizeProjectDomain(input) {
|
|
963
|
-
let domain = input.trim().toLowerCase();
|
|
964
|
-
try {
|
|
965
|
-
if (domain.includes("://")) {
|
|
966
|
-
domain = new URL(domain).hostname.toLowerCase();
|
|
967
|
-
}
|
|
968
|
-
} catch {
|
|
969
|
-
}
|
|
970
|
-
return domain.replace(/^www\./, "");
|
|
971
|
-
}
|
|
972
|
-
function effectiveDomains(project) {
|
|
973
|
-
const all = [project.canonicalDomain, ...project.ownedDomains ?? []];
|
|
974
|
-
const seen = /* @__PURE__ */ new Set();
|
|
975
|
-
const result = [];
|
|
976
|
-
for (const d of all) {
|
|
977
|
-
const trimmed = d.trim();
|
|
978
|
-
if (!trimmed) continue;
|
|
979
|
-
const norm = normalizeProjectDomain(trimmed);
|
|
980
|
-
if (seen.has(norm)) continue;
|
|
981
|
-
seen.add(norm);
|
|
982
|
-
result.push(trimmed);
|
|
983
|
-
}
|
|
984
|
-
return result;
|
|
985
|
-
}
|
|
1078
|
+
CREATE TABLE IF NOT EXISTS usage_counters (
|
|
1079
|
+
id TEXT PRIMARY KEY,
|
|
1080
|
+
scope TEXT NOT NULL,
|
|
1081
|
+
period TEXT NOT NULL,
|
|
1082
|
+
metric TEXT NOT NULL,
|
|
1083
|
+
count INTEGER NOT NULL DEFAULT 0,
|
|
1084
|
+
updated_at TEXT NOT NULL,
|
|
1085
|
+
UNIQUE(scope, period, metric)
|
|
1086
|
+
);
|
|
986
1087
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
});
|
|
1010
|
-
var querySnapshotDtoSchema = z6.object({
|
|
1011
|
-
id: z6.string(),
|
|
1012
|
-
runId: z6.string(),
|
|
1013
|
-
keywordId: z6.string(),
|
|
1014
|
-
keyword: z6.string().optional(),
|
|
1015
|
-
provider: providerNameSchema,
|
|
1016
|
-
citationState: citationStateSchema,
|
|
1017
|
-
transition: computedTransitionSchema.optional(),
|
|
1018
|
-
answerText: z6.string().nullable().optional(),
|
|
1019
|
-
citedDomains: z6.array(z6.string()).default([]),
|
|
1020
|
-
competitorOverlap: z6.array(z6.string()).default([]),
|
|
1021
|
-
groundingSources: z6.array(groundingSourceSchema).default([]),
|
|
1022
|
-
searchQueries: z6.array(z6.string()).default([]),
|
|
1023
|
-
model: z6.string().nullable().optional(),
|
|
1024
|
-
location: z6.string().nullable().optional(),
|
|
1025
|
-
createdAt: z6.string()
|
|
1026
|
-
});
|
|
1027
|
-
var auditLogEntrySchema = z6.object({
|
|
1028
|
-
id: z6.string(),
|
|
1029
|
-
projectId: z6.string().nullable().optional(),
|
|
1030
|
-
actor: z6.string(),
|
|
1031
|
-
action: z6.string(),
|
|
1032
|
-
entityType: z6.string(),
|
|
1033
|
-
entityId: z6.string().nullable().optional(),
|
|
1034
|
-
diff: z6.unknown().optional(),
|
|
1035
|
-
createdAt: z6.string()
|
|
1036
|
-
});
|
|
1088
|
+
CREATE INDEX IF NOT EXISTS idx_keywords_project ON keywords(project_id);
|
|
1089
|
+
CREATE INDEX IF NOT EXISTS idx_competitors_project ON competitors(project_id);
|
|
1090
|
+
CREATE INDEX IF NOT EXISTS idx_runs_project ON runs(project_id);
|
|
1091
|
+
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1092
|
+
CREATE INDEX IF NOT EXISTS idx_snapshots_run ON query_snapshots(run_id);
|
|
1093
|
+
CREATE INDEX IF NOT EXISTS idx_snapshots_keyword ON query_snapshots(keyword_id);
|
|
1094
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_project ON audit_log(project_id);
|
|
1095
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
|
|
1096
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
1097
|
+
id TEXT PRIMARY KEY,
|
|
1098
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1099
|
+
cron_expr TEXT NOT NULL,
|
|
1100
|
+
preset TEXT,
|
|
1101
|
+
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
1102
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1103
|
+
providers TEXT NOT NULL DEFAULT '[]',
|
|
1104
|
+
last_run_at TEXT,
|
|
1105
|
+
next_run_at TEXT,
|
|
1106
|
+
created_at TEXT NOT NULL,
|
|
1107
|
+
updated_at TEXT NOT NULL,
|
|
1108
|
+
UNIQUE(project_id)
|
|
1109
|
+
);
|
|
1037
1110
|
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
providers: z7.array(providerNameSchema).default([]),
|
|
1048
|
-
lastRunAt: z7.string().nullable().optional(),
|
|
1049
|
-
nextRunAt: z7.string().nullable().optional(),
|
|
1050
|
-
createdAt: z7.string(),
|
|
1051
|
-
updatedAt: z7.string()
|
|
1052
|
-
});
|
|
1111
|
+
CREATE TABLE IF NOT EXISTS notifications (
|
|
1112
|
+
id TEXT PRIMARY KEY,
|
|
1113
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1114
|
+
channel TEXT NOT NULL,
|
|
1115
|
+
config TEXT NOT NULL,
|
|
1116
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
1117
|
+
created_at TEXT NOT NULL,
|
|
1118
|
+
updated_at TEXT NOT NULL
|
|
1119
|
+
);
|
|
1053
1120
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
//
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1121
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix);
|
|
1122
|
+
CREATE INDEX IF NOT EXISTS idx_usage_scope_period ON usage_counters(scope, period);
|
|
1123
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_project ON schedules(project_id);
|
|
1124
|
+
CREATE INDEX IF NOT EXISTS idx_notifications_project ON notifications(project_id);
|
|
1125
|
+
`;
|
|
1126
|
+
var MIGRATIONS = [
|
|
1127
|
+
// v2: Add providers column to projects for multi-provider support
|
|
1128
|
+
`ALTER TABLE projects ADD COLUMN providers TEXT NOT NULL DEFAULT '[]'`,
|
|
1129
|
+
// v3: Add webhook_secret column to notifications for HMAC signing
|
|
1130
|
+
`ALTER TABLE notifications ADD COLUMN webhook_secret TEXT`,
|
|
1131
|
+
// v4: Add owned_domains column to projects for multi-domain citation matching
|
|
1132
|
+
`ALTER TABLE projects ADD COLUMN owned_domains TEXT NOT NULL DEFAULT '[]'`,
|
|
1133
|
+
// v5: Add model column to query_snapshots for per-model scoring
|
|
1134
|
+
`ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
|
|
1135
|
+
// v5b: Backfill model from rawResponse JSON for existing snapshots
|
|
1136
|
+
`UPDATE query_snapshots SET model = json_extract(raw_response, '$.model') WHERE model IS NULL AND raw_response IS NOT NULL AND json_extract(raw_response, '$.model') IS NOT NULL`,
|
|
1137
|
+
// v6: Google Search Console integration — google_connections table (domain-scoped)
|
|
1138
|
+
`CREATE TABLE IF NOT EXISTS google_connections (
|
|
1139
|
+
id TEXT PRIMARY KEY,
|
|
1140
|
+
domain TEXT NOT NULL,
|
|
1141
|
+
connection_type TEXT NOT NULL,
|
|
1142
|
+
property_id TEXT,
|
|
1143
|
+
access_token TEXT,
|
|
1144
|
+
refresh_token TEXT,
|
|
1145
|
+
token_expires_at TEXT,
|
|
1146
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
1147
|
+
created_at TEXT NOT NULL,
|
|
1148
|
+
updated_at TEXT NOT NULL
|
|
1149
|
+
)`,
|
|
1150
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
|
|
1151
|
+
// v6: Google Search Console integration — gsc_search_data table
|
|
1152
|
+
`CREATE TABLE IF NOT EXISTS gsc_search_data (
|
|
1153
|
+
id TEXT PRIMARY KEY,
|
|
1154
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1155
|
+
sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
1156
|
+
date TEXT NOT NULL,
|
|
1157
|
+
query TEXT NOT NULL,
|
|
1158
|
+
page TEXT NOT NULL,
|
|
1159
|
+
country TEXT,
|
|
1160
|
+
device TEXT,
|
|
1161
|
+
clicks INTEGER NOT NULL DEFAULT 0,
|
|
1162
|
+
impressions INTEGER NOT NULL DEFAULT 0,
|
|
1163
|
+
ctr TEXT NOT NULL DEFAULT '0',
|
|
1164
|
+
position TEXT NOT NULL DEFAULT '0',
|
|
1165
|
+
created_at TEXT NOT NULL
|
|
1166
|
+
)`,
|
|
1167
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
|
|
1168
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
|
|
1169
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
|
|
1170
|
+
// v6: Google Search Console integration — gsc_url_inspections table
|
|
1171
|
+
`CREATE TABLE IF NOT EXISTS gsc_url_inspections (
|
|
1172
|
+
id TEXT PRIMARY KEY,
|
|
1173
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1174
|
+
sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
1175
|
+
url TEXT NOT NULL,
|
|
1176
|
+
indexing_state TEXT,
|
|
1177
|
+
verdict TEXT,
|
|
1178
|
+
coverage_state TEXT,
|
|
1179
|
+
page_fetch_state TEXT,
|
|
1180
|
+
robots_txt_state TEXT,
|
|
1181
|
+
crawl_time TEXT,
|
|
1182
|
+
last_crawl_result TEXT,
|
|
1183
|
+
is_mobile_friendly INTEGER,
|
|
1184
|
+
rich_results TEXT NOT NULL DEFAULT '[]',
|
|
1185
|
+
referring_urls TEXT NOT NULL DEFAULT '[]',
|
|
1186
|
+
inspected_at TEXT NOT NULL,
|
|
1187
|
+
created_at TEXT NOT NULL
|
|
1188
|
+
)`,
|
|
1189
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
|
|
1190
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
|
|
1191
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`,
|
|
1192
|
+
// v7: GSC coverage snapshots for historical tracking
|
|
1193
|
+
`CREATE TABLE IF NOT EXISTS gsc_coverage_snapshots (
|
|
1194
|
+
id TEXT PRIMARY KEY,
|
|
1195
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1196
|
+
sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
1197
|
+
date TEXT NOT NULL,
|
|
1198
|
+
indexed INTEGER NOT NULL DEFAULT 0,
|
|
1199
|
+
not_indexed INTEGER NOT NULL DEFAULT 0,
|
|
1200
|
+
reason_breakdown TEXT NOT NULL DEFAULT '{}',
|
|
1201
|
+
created_at TEXT NOT NULL
|
|
1202
|
+
)`,
|
|
1203
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_project_date ON gsc_coverage_snapshots(project_id, date)`,
|
|
1204
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_coverage_snap_run ON gsc_coverage_snapshots(sync_run_id)`,
|
|
1205
|
+
// v8: Location-aware sweeps — project locations + snapshot location tag
|
|
1206
|
+
`ALTER TABLE projects ADD COLUMN locations TEXT NOT NULL DEFAULT '[]'`,
|
|
1207
|
+
`ALTER TABLE projects ADD COLUMN default_location TEXT`,
|
|
1208
|
+
`ALTER TABLE query_snapshots ADD COLUMN location TEXT`,
|
|
1209
|
+
// v9: Add location column to runs for per-location run tracking
|
|
1210
|
+
`ALTER TABLE runs ADD COLUMN location TEXT`,
|
|
1211
|
+
// v10: Add sitemapUrl to google_connections for persistent sitemap storage
|
|
1212
|
+
`ALTER TABLE google_connections ADD COLUMN sitemap_url TEXT`,
|
|
1213
|
+
// v11: CDP browser provider — screenshot path for captured evidence
|
|
1214
|
+
`ALTER TABLE query_snapshots ADD COLUMN screenshot_path TEXT`
|
|
1215
|
+
];
|
|
1216
|
+
function migrate(db) {
|
|
1217
|
+
const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1218
|
+
for (const statement of statements) {
|
|
1219
|
+
db.run(sql.raw(statement));
|
|
1133
1220
|
}
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1221
|
+
for (const migration of MIGRATIONS) {
|
|
1222
|
+
try {
|
|
1223
|
+
db.run(sql.raw(migration));
|
|
1224
|
+
} catch {
|
|
1138
1225
|
}
|
|
1139
1226
|
}
|
|
1140
|
-
return { category: "other", label: CATEGORY_LABELS.other, domain };
|
|
1141
|
-
}
|
|
1142
|
-
function categoryLabel(category) {
|
|
1143
|
-
return CATEGORY_LABELS[category];
|
|
1144
1227
|
}
|
|
1145
1228
|
|
|
1146
1229
|
// ../api-routes/src/auth.ts
|
|
@@ -3644,7 +3727,8 @@ function buildOperationId(method, path7) {
|
|
|
3644
3727
|
async function settingsRoutes(app, opts) {
|
|
3645
3728
|
app.get("/settings", async () => ({
|
|
3646
3729
|
providers: opts.providerSummary ?? [],
|
|
3647
|
-
google: opts.google ?? { configured: false }
|
|
3730
|
+
google: opts.google ?? { configured: false },
|
|
3731
|
+
bing: opts.bing ?? { configured: false }
|
|
3648
3732
|
}));
|
|
3649
3733
|
app.put("/settings/providers/:name", async (request, reply) => {
|
|
3650
3734
|
const providerName = parseProviderName(request.params.name);
|
|
@@ -3708,6 +3792,22 @@ async function settingsRoutes(app, opts) {
|
|
|
3708
3792
|
}
|
|
3709
3793
|
return result;
|
|
3710
3794
|
});
|
|
3795
|
+
app.put("/settings/bing", async (request, reply) => {
|
|
3796
|
+
const { apiKey } = request.body ?? {};
|
|
3797
|
+
if (!apiKey || typeof apiKey !== "string") {
|
|
3798
|
+
return reply.status(400).send({
|
|
3799
|
+
error: { code: "VALIDATION_ERROR", message: "apiKey is required" }
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
if (!opts.onBingUpdate) {
|
|
3803
|
+
return reply.status(501).send({ error: "Bing configuration updates are not supported in this deployment" });
|
|
3804
|
+
}
|
|
3805
|
+
const result = opts.onBingUpdate(apiKey);
|
|
3806
|
+
if (!result) {
|
|
3807
|
+
return reply.status(500).send({ error: "Failed to update Bing configuration" });
|
|
3808
|
+
}
|
|
3809
|
+
return result;
|
|
3810
|
+
});
|
|
3711
3811
|
}
|
|
3712
3812
|
|
|
3713
3813
|
// ../api-routes/src/telemetry.ts
|
|
@@ -4750,89 +4850,480 @@ async function googleRoutes(app, opts) {
|
|
|
4750
4850
|
const run = app.db.select().from(runs).where(eq13(runs.id, runId)).get();
|
|
4751
4851
|
return { sitemaps, primarySitemapUrl: sitemapUrl, run };
|
|
4752
4852
|
});
|
|
4753
|
-
app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
|
|
4853
|
+
app.post("/projects/:name/google/gsc/inspect-sitemap", async (request, reply) => {
|
|
4854
|
+
const store = requireConnectionStore(reply);
|
|
4855
|
+
if (!store) return;
|
|
4856
|
+
const project = resolveProject(app.db, request.params.name);
|
|
4857
|
+
const conn = store.getConnection(project.canonicalDomain, "gsc");
|
|
4858
|
+
if (!conn) {
|
|
4859
|
+
const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
|
|
4860
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4861
|
+
}
|
|
4862
|
+
if (!conn.propertyId) {
|
|
4863
|
+
const err = validationError("No GSC property configured for this connection");
|
|
4864
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4865
|
+
}
|
|
4866
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4867
|
+
const runId = crypto13.randomUUID();
|
|
4868
|
+
app.db.insert(runs).values({
|
|
4869
|
+
id: runId,
|
|
4870
|
+
projectId: project.id,
|
|
4871
|
+
kind: "inspect-sitemap",
|
|
4872
|
+
status: "queued",
|
|
4873
|
+
trigger: "manual",
|
|
4874
|
+
createdAt: now
|
|
4875
|
+
}).run();
|
|
4876
|
+
const { sitemapUrl } = request.body ?? {};
|
|
4877
|
+
if (opts.onInspectSitemapRequested) {
|
|
4878
|
+
opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl: sitemapUrl ?? void 0 });
|
|
4879
|
+
}
|
|
4880
|
+
const run = app.db.select().from(runs).where(eq13(runs.id, runId)).get();
|
|
4881
|
+
return run;
|
|
4882
|
+
});
|
|
4883
|
+
app.put("/projects/:name/google/connections/:type/sitemap", async (request, reply) => {
|
|
4884
|
+
const store = requireConnectionStore(reply);
|
|
4885
|
+
if (!store) return;
|
|
4886
|
+
const project = resolveProject(app.db, request.params.name);
|
|
4887
|
+
const { sitemapUrl } = request.body ?? {};
|
|
4888
|
+
if (!sitemapUrl || !sitemapUrl.trim()) {
|
|
4889
|
+
const err = validationError("sitemapUrl is required");
|
|
4890
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4891
|
+
}
|
|
4892
|
+
const conn = store.updateConnection(
|
|
4893
|
+
project.canonicalDomain,
|
|
4894
|
+
request.params.type,
|
|
4895
|
+
{ sitemapUrl: sitemapUrl.trim(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
4896
|
+
);
|
|
4897
|
+
if (!conn) {
|
|
4898
|
+
const err = notFound("Google connection", request.params.type);
|
|
4899
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4900
|
+
}
|
|
4901
|
+
return { sitemapUrl: sitemapUrl.trim() };
|
|
4902
|
+
});
|
|
4903
|
+
app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
|
|
4904
|
+
const store = requireConnectionStore(reply);
|
|
4905
|
+
if (!store) return;
|
|
4906
|
+
const project = resolveProject(app.db, request.params.name);
|
|
4907
|
+
const { propertyId } = request.body ?? {};
|
|
4908
|
+
if (!propertyId) {
|
|
4909
|
+
const err = validationError("propertyId is required");
|
|
4910
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4911
|
+
}
|
|
4912
|
+
const conn = store.updateConnection(
|
|
4913
|
+
project.canonicalDomain,
|
|
4914
|
+
request.params.type,
|
|
4915
|
+
{ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
4916
|
+
);
|
|
4917
|
+
if (!conn) {
|
|
4918
|
+
const err = notFound("Google connection", request.params.type);
|
|
4919
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4920
|
+
}
|
|
4921
|
+
return { propertyId };
|
|
4922
|
+
});
|
|
4923
|
+
app.post("/projects/:name/google/indexing/request", async (request, reply) => {
|
|
4924
|
+
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
4925
|
+
if (!googleClientId || !googleClientSecret) {
|
|
4926
|
+
const err = validationError("Google OAuth is not configured");
|
|
4927
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4928
|
+
}
|
|
4929
|
+
const store = requireConnectionStore(reply);
|
|
4930
|
+
if (!store) return;
|
|
4931
|
+
const project = resolveProject(app.db, request.params.name);
|
|
4932
|
+
const { accessToken } = await getValidToken(store, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
4933
|
+
let urlsToNotify = request.body?.urls ?? [];
|
|
4934
|
+
if (request.body?.allUnindexed) {
|
|
4935
|
+
const allInspections = app.db.select().from(gscUrlInspections).where(eq13(gscUrlInspections.projectId, project.id)).orderBy(desc3(gscUrlInspections.inspectedAt)).all();
|
|
4936
|
+
const latestByUrl = /* @__PURE__ */ new Map();
|
|
4937
|
+
for (const row of allInspections) {
|
|
4938
|
+
if (!latestByUrl.has(row.url)) {
|
|
4939
|
+
latestByUrl.set(row.url, row);
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
const unindexedUrls = [];
|
|
4943
|
+
for (const [url, row] of latestByUrl) {
|
|
4944
|
+
if (row.indexingState !== "INDEXING_ALLOWED") {
|
|
4945
|
+
unindexedUrls.push(url);
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4948
|
+
if (unindexedUrls.length === 0) {
|
|
4949
|
+
const err = validationError('No unindexed URLs found. Run "canonry google inspect-sitemap" first.');
|
|
4950
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4951
|
+
}
|
|
4952
|
+
urlsToNotify = unindexedUrls;
|
|
4953
|
+
}
|
|
4954
|
+
if (urlsToNotify.length === 0) {
|
|
4955
|
+
const err = validationError("At least one URL is required (or use allUnindexed: true)");
|
|
4956
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4957
|
+
}
|
|
4958
|
+
if (urlsToNotify.length > INDEXING_API_DAILY_LIMIT) {
|
|
4959
|
+
const err = validationError(`Cannot request indexing for more than ${INDEXING_API_DAILY_LIMIT} URLs per request (got ${urlsToNotify.length})`);
|
|
4960
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4961
|
+
}
|
|
4962
|
+
const projectDomain = normalizeProjectDomain(project.canonicalDomain);
|
|
4963
|
+
const invalidUrls = urlsToNotify.filter((url) => {
|
|
4964
|
+
try {
|
|
4965
|
+
const hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
4966
|
+
return hostname !== projectDomain;
|
|
4967
|
+
} catch {
|
|
4968
|
+
return true;
|
|
4969
|
+
}
|
|
4970
|
+
});
|
|
4971
|
+
if (invalidUrls.length > 0) {
|
|
4972
|
+
const err = validationError(
|
|
4973
|
+
`URLs must belong to project domain "${project.canonicalDomain}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`
|
|
4974
|
+
);
|
|
4975
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
4976
|
+
}
|
|
4977
|
+
const results = [];
|
|
4978
|
+
for (const url of urlsToNotify) {
|
|
4979
|
+
try {
|
|
4980
|
+
const response = await publishUrlNotification(accessToken, url, "URL_UPDATED");
|
|
4981
|
+
const notifyTime = response.urlNotificationMetadata?.latestUpdate?.notifyTime ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4982
|
+
results.push({
|
|
4983
|
+
url,
|
|
4984
|
+
type: "URL_UPDATED",
|
|
4985
|
+
notifiedAt: notifyTime,
|
|
4986
|
+
status: "success"
|
|
4987
|
+
});
|
|
4988
|
+
} catch (err) {
|
|
4989
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4990
|
+
results.push({
|
|
4991
|
+
url,
|
|
4992
|
+
type: "URL_UPDATED",
|
|
4993
|
+
notifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4994
|
+
status: "error",
|
|
4995
|
+
error: msg
|
|
4996
|
+
});
|
|
4997
|
+
}
|
|
4998
|
+
}
|
|
4999
|
+
const succeeded = results.filter((r) => r.status === "success").length;
|
|
5000
|
+
const failed = results.filter((r) => r.status === "error").length;
|
|
5001
|
+
return {
|
|
5002
|
+
summary: { total: results.length, succeeded, failed },
|
|
5003
|
+
results
|
|
5004
|
+
};
|
|
5005
|
+
});
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
// ../api-routes/src/bing.ts
|
|
5009
|
+
import crypto14 from "crypto";
|
|
5010
|
+
import { eq as eq14, and as and4, desc as desc4 } from "drizzle-orm";
|
|
5011
|
+
|
|
5012
|
+
// ../integration-bing/src/constants.ts
|
|
5013
|
+
var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
|
|
5014
|
+
var BING_SUBMIT_URL_BATCH_LIMIT = 500;
|
|
5015
|
+
var BING_SUBMIT_URL_DAILY_LIMIT = 1e4;
|
|
5016
|
+
|
|
5017
|
+
// ../integration-bing/src/types.ts
|
|
5018
|
+
var BingApiError = class extends Error {
|
|
5019
|
+
status;
|
|
5020
|
+
constructor(message, status) {
|
|
5021
|
+
super(message);
|
|
5022
|
+
this.name = "BingApiError";
|
|
5023
|
+
this.status = status;
|
|
5024
|
+
}
|
|
5025
|
+
};
|
|
5026
|
+
|
|
5027
|
+
// ../integration-bing/src/bing-client.ts
|
|
5028
|
+
async function bingFetch(apiKey, endpoint, opts) {
|
|
5029
|
+
const method = opts?.method ?? "GET";
|
|
5030
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
5031
|
+
const url = `${BING_WMT_API_BASE}/${endpoint}${separator}apikey=${encodeURIComponent(apiKey)}`;
|
|
5032
|
+
const headers = {
|
|
5033
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
5034
|
+
};
|
|
5035
|
+
const res = await fetch(url, {
|
|
5036
|
+
method,
|
|
5037
|
+
headers,
|
|
5038
|
+
body: opts?.body != null ? JSON.stringify(opts.body) : void 0
|
|
5039
|
+
});
|
|
5040
|
+
if (res.status === 401 || res.status === 403) {
|
|
5041
|
+
throw new BingApiError("Bing API key is invalid or unauthorized", res.status);
|
|
5042
|
+
}
|
|
5043
|
+
if (res.status === 429) {
|
|
5044
|
+
throw new BingApiError("Bing API rate limit exceeded", 429);
|
|
5045
|
+
}
|
|
5046
|
+
if (!res.ok) {
|
|
5047
|
+
const body = await res.text();
|
|
5048
|
+
throw new BingApiError(`Bing API error (${res.status}): ${body}`, res.status);
|
|
5049
|
+
}
|
|
5050
|
+
const text2 = await res.text();
|
|
5051
|
+
if (!text2 || text2.trim() === "") {
|
|
5052
|
+
return void 0;
|
|
5053
|
+
}
|
|
5054
|
+
try {
|
|
5055
|
+
const parsed = JSON.parse(text2);
|
|
5056
|
+
if (parsed && typeof parsed === "object" && "d" in parsed) {
|
|
5057
|
+
return parsed.d;
|
|
5058
|
+
}
|
|
5059
|
+
return parsed;
|
|
5060
|
+
} catch {
|
|
5061
|
+
throw new BingApiError("Bing API returned invalid JSON", 502);
|
|
5062
|
+
}
|
|
5063
|
+
}
|
|
5064
|
+
async function getSites(apiKey) {
|
|
5065
|
+
const data = await bingFetch(apiKey, "GetUserSites");
|
|
5066
|
+
return data ?? [];
|
|
5067
|
+
}
|
|
5068
|
+
async function getUrlInfo(apiKey, siteUrl, url) {
|
|
5069
|
+
const encodedSite = encodeURIComponent(siteUrl);
|
|
5070
|
+
const encodedUrl = encodeURIComponent(url);
|
|
5071
|
+
return bingFetch(apiKey, `GetUrlInfo?siteUrl=${encodedSite}&url=${encodedUrl}`);
|
|
5072
|
+
}
|
|
5073
|
+
async function submitUrl(apiKey, siteUrl, url) {
|
|
5074
|
+
await bingFetch(apiKey, "SubmitUrl", {
|
|
5075
|
+
method: "POST",
|
|
5076
|
+
body: { siteUrl, url }
|
|
5077
|
+
});
|
|
5078
|
+
}
|
|
5079
|
+
async function submitUrlBatch(apiKey, siteUrl, urls) {
|
|
5080
|
+
for (let i = 0; i < urls.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
|
|
5081
|
+
const batch = urls.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
|
|
5082
|
+
await bingFetch(apiKey, "SubmitUrlbatch", {
|
|
5083
|
+
method: "POST",
|
|
5084
|
+
body: { siteUrl, urlList: batch }
|
|
5085
|
+
});
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
async function getKeywordStats(apiKey, siteUrl) {
|
|
5089
|
+
const encodedSite = encodeURIComponent(siteUrl);
|
|
5090
|
+
const data = await bingFetch(apiKey, `GetQueryStats?siteUrl=${encodedSite}`);
|
|
5091
|
+
return data ?? [];
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
// ../api-routes/src/bing.ts
|
|
5095
|
+
async function bingRoutes(app, opts) {
|
|
5096
|
+
function requireConnectionStore(reply) {
|
|
5097
|
+
if (opts.bingConnectionStore) return opts.bingConnectionStore;
|
|
5098
|
+
const err = validationError("Bing connection storage is not configured for this deployment");
|
|
5099
|
+
reply.status(err.statusCode).send(err.toJSON());
|
|
5100
|
+
return null;
|
|
5101
|
+
}
|
|
5102
|
+
function requireConnection(store, domain, reply) {
|
|
5103
|
+
const conn = store.getConnection(domain);
|
|
5104
|
+
if (!conn) {
|
|
5105
|
+
const err = validationError('No Bing connection found for this domain. Run "canonry bing connect <project>" first.');
|
|
5106
|
+
reply.status(err.statusCode).send(err.toJSON());
|
|
5107
|
+
return null;
|
|
5108
|
+
}
|
|
5109
|
+
return conn;
|
|
5110
|
+
}
|
|
5111
|
+
app.post("/projects/:name/bing/connect", async (request, reply) => {
|
|
5112
|
+
const store = requireConnectionStore(reply);
|
|
5113
|
+
if (!store) return;
|
|
5114
|
+
const { apiKey } = request.body ?? {};
|
|
5115
|
+
if (!apiKey || typeof apiKey !== "string") {
|
|
5116
|
+
const err = validationError("apiKey is required");
|
|
5117
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
5118
|
+
}
|
|
5119
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5120
|
+
let sites;
|
|
5121
|
+
try {
|
|
5122
|
+
sites = await getSites(apiKey);
|
|
5123
|
+
} catch (e) {
|
|
5124
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
5125
|
+
const err = validationError(`Failed to verify Bing API key: ${msg}`);
|
|
5126
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
5127
|
+
}
|
|
5128
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5129
|
+
const existing = store.getConnection(project.canonicalDomain);
|
|
5130
|
+
store.upsertConnection({
|
|
5131
|
+
domain: project.canonicalDomain,
|
|
5132
|
+
apiKey,
|
|
5133
|
+
siteUrl: existing?.siteUrl ?? null,
|
|
5134
|
+
createdAt: existing?.createdAt ?? now,
|
|
5135
|
+
updatedAt: now
|
|
5136
|
+
});
|
|
5137
|
+
writeAuditLog(app.db, {
|
|
5138
|
+
projectId: project.id,
|
|
5139
|
+
actor: "api",
|
|
5140
|
+
action: "bing.connected",
|
|
5141
|
+
entityType: "bing_connection",
|
|
5142
|
+
entityId: project.canonicalDomain
|
|
5143
|
+
});
|
|
5144
|
+
return {
|
|
5145
|
+
connected: true,
|
|
5146
|
+
domain: project.canonicalDomain,
|
|
5147
|
+
siteUrl: existing?.siteUrl ?? null,
|
|
5148
|
+
availableSites: sites.map((s) => ({ url: s.Url, verified: s.Verified ?? false }))
|
|
5149
|
+
};
|
|
5150
|
+
});
|
|
5151
|
+
app.delete("/projects/:name/bing/disconnect", async (request, reply) => {
|
|
4754
5152
|
const store = requireConnectionStore(reply);
|
|
4755
5153
|
if (!store) return;
|
|
4756
5154
|
const project = resolveProject(app.db, request.params.name);
|
|
4757
|
-
const
|
|
4758
|
-
if (!
|
|
4759
|
-
const err =
|
|
4760
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
4761
|
-
}
|
|
4762
|
-
if (!conn.propertyId) {
|
|
4763
|
-
const err = validationError("No GSC property configured for this connection");
|
|
5155
|
+
const deleted = store.deleteConnection(project.canonicalDomain);
|
|
5156
|
+
if (!deleted) {
|
|
5157
|
+
const err = notFound("Bing connection", project.canonicalDomain);
|
|
4764
5158
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4765
5159
|
}
|
|
4766
|
-
|
|
4767
|
-
const runId = crypto13.randomUUID();
|
|
4768
|
-
app.db.insert(runs).values({
|
|
4769
|
-
id: runId,
|
|
5160
|
+
writeAuditLog(app.db, {
|
|
4770
5161
|
projectId: project.id,
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
})
|
|
4776
|
-
|
|
4777
|
-
if (opts.onInspectSitemapRequested) {
|
|
4778
|
-
opts.onInspectSitemapRequested(runId, project.id, { sitemapUrl: sitemapUrl ?? void 0 });
|
|
4779
|
-
}
|
|
4780
|
-
const run = app.db.select().from(runs).where(eq13(runs.id, runId)).get();
|
|
4781
|
-
return run;
|
|
5162
|
+
actor: "api",
|
|
5163
|
+
action: "bing.disconnected",
|
|
5164
|
+
entityType: "bing_connection",
|
|
5165
|
+
entityId: project.canonicalDomain
|
|
5166
|
+
});
|
|
5167
|
+
return reply.status(204).send();
|
|
4782
5168
|
});
|
|
4783
|
-
app.
|
|
5169
|
+
app.get("/projects/:name/bing/status", async (request, reply) => {
|
|
4784
5170
|
const store = requireConnectionStore(reply);
|
|
4785
5171
|
if (!store) return;
|
|
4786
5172
|
const project = resolveProject(app.db, request.params.name);
|
|
4787
|
-
const
|
|
4788
|
-
|
|
4789
|
-
|
|
5173
|
+
const conn = store.getConnection(project.canonicalDomain);
|
|
5174
|
+
return {
|
|
5175
|
+
connected: !!conn,
|
|
5176
|
+
domain: project.canonicalDomain,
|
|
5177
|
+
siteUrl: conn?.siteUrl ?? null,
|
|
5178
|
+
createdAt: conn?.createdAt ?? null,
|
|
5179
|
+
updatedAt: conn?.updatedAt ?? null
|
|
5180
|
+
};
|
|
5181
|
+
});
|
|
5182
|
+
app.get("/projects/:name/bing/sites", async (request, reply) => {
|
|
5183
|
+
const store = requireConnectionStore(reply);
|
|
5184
|
+
if (!store) return;
|
|
5185
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5186
|
+
const conn = requireConnection(store, project.canonicalDomain, reply);
|
|
5187
|
+
if (!conn) return;
|
|
5188
|
+
const sites = await getSites(conn.apiKey);
|
|
5189
|
+
return { sites: sites.map((s) => ({ url: s.Url, verified: s.Verified ?? false })) };
|
|
5190
|
+
});
|
|
5191
|
+
app.post("/projects/:name/bing/set-site", async (request, reply) => {
|
|
5192
|
+
const store = requireConnectionStore(reply);
|
|
5193
|
+
if (!store) return;
|
|
5194
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5195
|
+
const conn = requireConnection(store, project.canonicalDomain, reply);
|
|
5196
|
+
if (!conn) return;
|
|
5197
|
+
const { siteUrl } = request.body ?? {};
|
|
5198
|
+
if (!siteUrl || typeof siteUrl !== "string") {
|
|
5199
|
+
const err = validationError("siteUrl is required");
|
|
4790
5200
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4791
5201
|
}
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
5202
|
+
store.updateConnection(project.canonicalDomain, {
|
|
5203
|
+
siteUrl,
|
|
5204
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5205
|
+
});
|
|
5206
|
+
return { siteUrl };
|
|
5207
|
+
});
|
|
5208
|
+
app.get("/projects/:name/bing/coverage", async (request, reply) => {
|
|
5209
|
+
const store = requireConnectionStore(reply);
|
|
5210
|
+
if (!store) return;
|
|
5211
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5212
|
+
const conn = requireConnection(store, project.canonicalDomain, reply);
|
|
5213
|
+
if (!conn) return;
|
|
5214
|
+
const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
|
|
5215
|
+
const latestByUrl = /* @__PURE__ */ new Map();
|
|
5216
|
+
for (const row of allInspections) {
|
|
5217
|
+
if (!latestByUrl.has(row.url)) {
|
|
5218
|
+
latestByUrl.set(row.url, row);
|
|
5219
|
+
}
|
|
4800
5220
|
}
|
|
4801
|
-
|
|
5221
|
+
const indexedUrls = [];
|
|
5222
|
+
const notIndexedUrls = [];
|
|
5223
|
+
let lastInspectedAt = null;
|
|
5224
|
+
for (const [, row] of latestByUrl) {
|
|
5225
|
+
if (row.inIndex === 1) {
|
|
5226
|
+
indexedUrls.push(row);
|
|
5227
|
+
} else {
|
|
5228
|
+
notIndexedUrls.push(row);
|
|
5229
|
+
}
|
|
5230
|
+
if (!lastInspectedAt || row.inspectedAt > lastInspectedAt) {
|
|
5231
|
+
lastInspectedAt = row.inspectedAt;
|
|
5232
|
+
}
|
|
5233
|
+
}
|
|
5234
|
+
const total = latestByUrl.size;
|
|
5235
|
+
const indexed = indexedUrls.length;
|
|
5236
|
+
const notIndexed = notIndexedUrls.length;
|
|
5237
|
+
const formatRow = (r) => ({
|
|
5238
|
+
id: r.id,
|
|
5239
|
+
url: r.url,
|
|
5240
|
+
httpCode: r.httpCode,
|
|
5241
|
+
inIndex: r.inIndex === 1 ? true : r.inIndex === 0 ? false : null,
|
|
5242
|
+
lastCrawledDate: r.lastCrawledDate,
|
|
5243
|
+
inIndexDate: r.inIndexDate,
|
|
5244
|
+
inspectedAt: r.inspectedAt
|
|
5245
|
+
});
|
|
5246
|
+
return {
|
|
5247
|
+
summary: {
|
|
5248
|
+
total,
|
|
5249
|
+
indexed,
|
|
5250
|
+
notIndexed,
|
|
5251
|
+
percentage: total > 0 ? Math.round(indexed / total * 1e3) / 10 : 0
|
|
5252
|
+
},
|
|
5253
|
+
lastInspectedAt,
|
|
5254
|
+
indexed: indexedUrls.map(formatRow),
|
|
5255
|
+
notIndexed: notIndexedUrls.map(formatRow)
|
|
5256
|
+
};
|
|
4802
5257
|
});
|
|
4803
|
-
app.
|
|
5258
|
+
app.get("/projects/:name/bing/inspections", async (request, reply) => {
|
|
4804
5259
|
const store = requireConnectionStore(reply);
|
|
4805
5260
|
if (!store) return;
|
|
4806
5261
|
const project = resolveProject(app.db, request.params.name);
|
|
4807
|
-
const {
|
|
4808
|
-
|
|
4809
|
-
|
|
5262
|
+
const { url, limit } = request.query;
|
|
5263
|
+
const whereClause = url ? and4(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
|
|
5264
|
+
const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc4(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
|
|
5265
|
+
return filtered.map((r) => ({
|
|
5266
|
+
id: r.id,
|
|
5267
|
+
url: r.url,
|
|
5268
|
+
httpCode: r.httpCode,
|
|
5269
|
+
inIndex: r.inIndex === 1 ? true : r.inIndex === 0 ? false : null,
|
|
5270
|
+
lastCrawledDate: r.lastCrawledDate,
|
|
5271
|
+
inIndexDate: r.inIndexDate,
|
|
5272
|
+
inspectedAt: r.inspectedAt
|
|
5273
|
+
}));
|
|
5274
|
+
});
|
|
5275
|
+
app.post("/projects/:name/bing/inspect-url", async (request, reply) => {
|
|
5276
|
+
const store = requireConnectionStore(reply);
|
|
5277
|
+
if (!store) return;
|
|
5278
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5279
|
+
const conn = requireConnection(store, project.canonicalDomain, reply);
|
|
5280
|
+
if (!conn) return;
|
|
5281
|
+
if (!conn.siteUrl) {
|
|
5282
|
+
const err = validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
|
|
4810
5283
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4811
5284
|
}
|
|
4812
|
-
const
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
{ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
4816
|
-
);
|
|
4817
|
-
if (!conn) {
|
|
4818
|
-
const err = notFound("Google connection", request.params.type);
|
|
5285
|
+
const { url } = request.body ?? {};
|
|
5286
|
+
if (!url) {
|
|
5287
|
+
const err = validationError("url is required");
|
|
4819
5288
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4820
5289
|
}
|
|
4821
|
-
|
|
5290
|
+
const result = await getUrlInfo(conn.apiKey, conn.siteUrl, url);
|
|
5291
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5292
|
+
const id = crypto14.randomUUID();
|
|
5293
|
+
app.db.insert(bingUrlInspections).values({
|
|
5294
|
+
id,
|
|
5295
|
+
projectId: project.id,
|
|
5296
|
+
url,
|
|
5297
|
+
httpCode: result.HttpCode ?? null,
|
|
5298
|
+
inIndex: result.InIndex === true ? 1 : result.InIndex === false ? 0 : null,
|
|
5299
|
+
lastCrawledDate: result.LastCrawledDate ?? null,
|
|
5300
|
+
inIndexDate: result.InIndexDate ?? null,
|
|
5301
|
+
inspectedAt: now,
|
|
5302
|
+
createdAt: now
|
|
5303
|
+
}).run();
|
|
5304
|
+
return {
|
|
5305
|
+
id,
|
|
5306
|
+
url,
|
|
5307
|
+
httpCode: result.HttpCode ?? null,
|
|
5308
|
+
inIndex: result.InIndex ?? null,
|
|
5309
|
+
lastCrawledDate: result.LastCrawledDate ?? null,
|
|
5310
|
+
inIndexDate: result.InIndexDate ?? null,
|
|
5311
|
+
inspectedAt: now
|
|
5312
|
+
};
|
|
4822
5313
|
});
|
|
4823
|
-
app.post("/projects/:name/
|
|
4824
|
-
const { clientId: googleClientId, clientSecret: googleClientSecret } = getAuthConfig();
|
|
4825
|
-
if (!googleClientId || !googleClientSecret) {
|
|
4826
|
-
const err = validationError("Google OAuth is not configured");
|
|
4827
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
4828
|
-
}
|
|
5314
|
+
app.post("/projects/:name/bing/request-indexing", async (request, reply) => {
|
|
4829
5315
|
const store = requireConnectionStore(reply);
|
|
4830
5316
|
if (!store) return;
|
|
4831
5317
|
const project = resolveProject(app.db, request.params.name);
|
|
4832
|
-
const
|
|
4833
|
-
|
|
5318
|
+
const conn = requireConnection(store, project.canonicalDomain, reply);
|
|
5319
|
+
if (!conn) return;
|
|
5320
|
+
if (!conn.siteUrl) {
|
|
5321
|
+
const err = validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
|
|
5322
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
5323
|
+
}
|
|
5324
|
+
let urlsToSubmit = request.body?.urls ?? [];
|
|
4834
5325
|
if (request.body?.allUnindexed) {
|
|
4835
|
-
const allInspections = app.db.select().from(
|
|
5326
|
+
const allInspections = app.db.select().from(bingUrlInspections).where(eq14(bingUrlInspections.projectId, project.id)).orderBy(desc4(bingUrlInspections.inspectedAt)).all();
|
|
4836
5327
|
const latestByUrl = /* @__PURE__ */ new Map();
|
|
4837
5328
|
for (const row of allInspections) {
|
|
4838
5329
|
if (!latestByUrl.has(row.url)) {
|
|
@@ -4841,59 +5332,50 @@ async function googleRoutes(app, opts) {
|
|
|
4841
5332
|
}
|
|
4842
5333
|
const unindexedUrls = [];
|
|
4843
5334
|
for (const [url, row] of latestByUrl) {
|
|
4844
|
-
if (row.
|
|
5335
|
+
if (row.inIndex !== 1) {
|
|
4845
5336
|
unindexedUrls.push(url);
|
|
4846
5337
|
}
|
|
4847
5338
|
}
|
|
4848
5339
|
if (unindexedUrls.length === 0) {
|
|
4849
|
-
const err = validationError('No unindexed URLs found. Run "canonry
|
|
5340
|
+
const err = validationError('No unindexed URLs found. Run "canonry bing inspect <project> <url>" first.');
|
|
4850
5341
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4851
5342
|
}
|
|
4852
|
-
|
|
5343
|
+
urlsToSubmit = unindexedUrls;
|
|
4853
5344
|
}
|
|
4854
|
-
if (
|
|
5345
|
+
if (urlsToSubmit.length === 0) {
|
|
4855
5346
|
const err = validationError("At least one URL is required (or use allUnindexed: true)");
|
|
4856
5347
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4857
5348
|
}
|
|
4858
|
-
if (
|
|
4859
|
-
const err = validationError(`Cannot
|
|
4860
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
4861
|
-
}
|
|
4862
|
-
const projectDomain = normalizeProjectDomain(project.canonicalDomain);
|
|
4863
|
-
const invalidUrls = urlsToNotify.filter((url) => {
|
|
4864
|
-
try {
|
|
4865
|
-
const hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
|
|
4866
|
-
return hostname !== projectDomain;
|
|
4867
|
-
} catch {
|
|
4868
|
-
return true;
|
|
4869
|
-
}
|
|
4870
|
-
});
|
|
4871
|
-
if (invalidUrls.length > 0) {
|
|
4872
|
-
const err = validationError(
|
|
4873
|
-
`URLs must belong to project domain "${project.canonicalDomain}". Invalid: ${invalidUrls.slice(0, 5).join(", ")}`
|
|
4874
|
-
);
|
|
5349
|
+
if (urlsToSubmit.length > BING_SUBMIT_URL_DAILY_LIMIT) {
|
|
5350
|
+
const err = validationError(`Cannot submit more than ${BING_SUBMIT_URL_DAILY_LIMIT} URLs per day (got ${urlsToSubmit.length})`);
|
|
4875
5351
|
return reply.status(err.statusCode).send(err.toJSON());
|
|
4876
5352
|
}
|
|
4877
5353
|
const results = [];
|
|
4878
|
-
|
|
5354
|
+
if (urlsToSubmit.length > 1) {
|
|
5355
|
+
for (let i = 0; i < urlsToSubmit.length; i += BING_SUBMIT_URL_BATCH_LIMIT) {
|
|
5356
|
+
const batch = urlsToSubmit.slice(i, i + BING_SUBMIT_URL_BATCH_LIMIT);
|
|
5357
|
+
try {
|
|
5358
|
+
await submitUrlBatch(conn.apiKey, conn.siteUrl, batch);
|
|
5359
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5360
|
+
for (const url of batch) {
|
|
5361
|
+
results.push({ url, status: "success", submittedAt: now });
|
|
5362
|
+
}
|
|
5363
|
+
} catch (e) {
|
|
5364
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
5365
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5366
|
+
for (const url of batch) {
|
|
5367
|
+
results.push({ url, status: "error", submittedAt: now, error: msg });
|
|
5368
|
+
}
|
|
5369
|
+
}
|
|
5370
|
+
}
|
|
5371
|
+
} else {
|
|
5372
|
+
const url = urlsToSubmit[0];
|
|
4879
5373
|
try {
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
notifiedAt: notifyTime,
|
|
4886
|
-
status: "success"
|
|
4887
|
-
});
|
|
4888
|
-
} catch (err) {
|
|
4889
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4890
|
-
results.push({
|
|
4891
|
-
url,
|
|
4892
|
-
type: "URL_UPDATED",
|
|
4893
|
-
notifiedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4894
|
-
status: "error",
|
|
4895
|
-
error: msg
|
|
4896
|
-
});
|
|
5374
|
+
await submitUrl(conn.apiKey, conn.siteUrl, url);
|
|
5375
|
+
results.push({ url, status: "success", submittedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
5376
|
+
} catch (e) {
|
|
5377
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
5378
|
+
results.push({ url, status: "error", submittedAt: (/* @__PURE__ */ new Date()).toISOString(), error: msg });
|
|
4897
5379
|
}
|
|
4898
5380
|
}
|
|
4899
5381
|
const succeeded = results.filter((r) => r.status === "success").length;
|
|
@@ -4903,20 +5385,39 @@ async function googleRoutes(app, opts) {
|
|
|
4903
5385
|
results
|
|
4904
5386
|
};
|
|
4905
5387
|
});
|
|
5388
|
+
app.get("/projects/:name/bing/performance", async (request, reply) => {
|
|
5389
|
+
const store = requireConnectionStore(reply);
|
|
5390
|
+
if (!store) return;
|
|
5391
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5392
|
+
const conn = requireConnection(store, project.canonicalDomain, reply);
|
|
5393
|
+
if (!conn) return;
|
|
5394
|
+
if (!conn.siteUrl) {
|
|
5395
|
+
const err = validationError('No Bing site configured. Run "canonry bing set-site <project> <url>" first.');
|
|
5396
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
5397
|
+
}
|
|
5398
|
+
const stats = await getKeywordStats(conn.apiKey, conn.siteUrl);
|
|
5399
|
+
return stats.map((s) => ({
|
|
5400
|
+
query: s.Query,
|
|
5401
|
+
impressions: s.Impressions,
|
|
5402
|
+
clicks: s.Clicks,
|
|
5403
|
+
ctr: s.Ctr,
|
|
5404
|
+
averagePosition: s.AverageClickPosition ?? s.AverageImpressionPosition ?? 0
|
|
5405
|
+
}));
|
|
5406
|
+
});
|
|
4906
5407
|
}
|
|
4907
5408
|
|
|
4908
5409
|
// ../api-routes/src/cdp.ts
|
|
4909
5410
|
import fs2 from "fs";
|
|
4910
5411
|
import path2 from "path";
|
|
4911
5412
|
import os2 from "os";
|
|
4912
|
-
import { eq as
|
|
5413
|
+
import { eq as eq15, and as and5 } from "drizzle-orm";
|
|
4913
5414
|
function getScreenshotDir() {
|
|
4914
5415
|
return path2.join(os2.homedir(), ".canonry", "screenshots");
|
|
4915
5416
|
}
|
|
4916
5417
|
async function cdpRoutes(app, opts) {
|
|
4917
5418
|
app.get("/screenshots/:snapshotId", async (request, reply) => {
|
|
4918
5419
|
const { snapshotId } = request.params;
|
|
4919
|
-
const snapshot = app.db.select({ screenshotPath: querySnapshots.screenshotPath }).from(querySnapshots).where(
|
|
5420
|
+
const snapshot = app.db.select({ screenshotPath: querySnapshots.screenshotPath }).from(querySnapshots).where(eq15(querySnapshots.id, snapshotId)).get();
|
|
4920
5421
|
if (!snapshot?.screenshotPath) {
|
|
4921
5422
|
return reply.code(404).send({ error: "Screenshot not found" });
|
|
4922
5423
|
}
|
|
@@ -4972,7 +5473,7 @@ async function cdpRoutes(app, opts) {
|
|
|
4972
5473
|
async (request, reply) => {
|
|
4973
5474
|
const project = resolveProject(app.db, request.params.name);
|
|
4974
5475
|
const { runId } = request.params;
|
|
4975
|
-
const run = app.db.select().from(runs).where(
|
|
5476
|
+
const run = app.db.select().from(runs).where(and5(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
|
|
4976
5477
|
if (!run) return reply.code(404).send({ error: "Run not found" });
|
|
4977
5478
|
const snapshots = app.db.select({
|
|
4978
5479
|
id: querySnapshots.id,
|
|
@@ -4982,8 +5483,8 @@ async function cdpRoutes(app, opts) {
|
|
|
4982
5483
|
citedDomains: querySnapshots.citedDomains,
|
|
4983
5484
|
screenshotPath: querySnapshots.screenshotPath,
|
|
4984
5485
|
rawResponse: querySnapshots.rawResponse
|
|
4985
|
-
}).from(querySnapshots).where(
|
|
4986
|
-
const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(
|
|
5486
|
+
}).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
|
|
5487
|
+
const keywordRows = app.db.select({ id: keywords.id, keyword: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, project.id)).all();
|
|
4987
5488
|
const keywordMap = new Map(keywordRows.map((k) => [k.id, k.keyword]));
|
|
4988
5489
|
const byKeyword = /* @__PURE__ */ new Map();
|
|
4989
5490
|
for (const snap of snapshots) {
|
|
@@ -5067,6 +5568,27 @@ async function cdpRoutes(app, opts) {
|
|
|
5067
5568
|
// ../api-routes/src/index.ts
|
|
5068
5569
|
async function apiRoutes(app, opts) {
|
|
5069
5570
|
app.decorate("db", opts.db);
|
|
5571
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
5572
|
+
if (error instanceof AppError) {
|
|
5573
|
+
return reply.status(error.statusCode).send(error.toJSON());
|
|
5574
|
+
}
|
|
5575
|
+
const httpStatus = error.statusCode ?? error.status ?? 500;
|
|
5576
|
+
if (httpStatus >= 400 && httpStatus < 500) {
|
|
5577
|
+
return reply.status(httpStatus).send({
|
|
5578
|
+
error: {
|
|
5579
|
+
code: httpStatus === 401 ? "AUTH_INVALID" : httpStatus === 403 ? "FORBIDDEN" : httpStatus === 404 ? "NOT_FOUND" : httpStatus === 429 ? "QUOTA_EXCEEDED" : "VALIDATION_ERROR",
|
|
5580
|
+
message: error.message
|
|
5581
|
+
}
|
|
5582
|
+
});
|
|
5583
|
+
}
|
|
5584
|
+
app.log.error(error);
|
|
5585
|
+
return reply.status(500).send({
|
|
5586
|
+
error: {
|
|
5587
|
+
code: "INTERNAL_ERROR",
|
|
5588
|
+
message: "An unexpected error occurred"
|
|
5589
|
+
}
|
|
5590
|
+
});
|
|
5591
|
+
});
|
|
5070
5592
|
if (!opts.skipAuth) {
|
|
5071
5593
|
await app.register(authPlugin);
|
|
5072
5594
|
}
|
|
@@ -5095,7 +5617,9 @@ async function apiRoutes(app, opts) {
|
|
|
5095
5617
|
providerSummary: opts.providerSummary,
|
|
5096
5618
|
onProviderUpdate: opts.onProviderUpdate,
|
|
5097
5619
|
google: opts.googleSettingsSummary,
|
|
5098
|
-
onGoogleUpdate: opts.onGoogleSettingsUpdate
|
|
5620
|
+
onGoogleUpdate: opts.onGoogleSettingsUpdate,
|
|
5621
|
+
bing: opts.bingSettingsSummary,
|
|
5622
|
+
onBingUpdate: opts.onBingSettingsUpdate
|
|
5099
5623
|
});
|
|
5100
5624
|
await api.register(scheduleRoutes, {
|
|
5101
5625
|
onScheduleUpdated: opts.onScheduleUpdated
|
|
@@ -5105,6 +5629,9 @@ async function apiRoutes(app, opts) {
|
|
|
5105
5629
|
getTelemetryStatus: opts.getTelemetryStatus,
|
|
5106
5630
|
setTelemetryEnabled: opts.setTelemetryEnabled
|
|
5107
5631
|
});
|
|
5632
|
+
await api.register(bingRoutes, {
|
|
5633
|
+
bingConnectionStore: opts.bingConnectionStore
|
|
5634
|
+
});
|
|
5108
5635
|
await api.register(googleRoutes, {
|
|
5109
5636
|
getGoogleAuthConfig: opts.getGoogleAuthConfig,
|
|
5110
5637
|
googleConnectionStore: opts.googleConnectionStore,
|
|
@@ -5118,7 +5645,7 @@ async function apiRoutes(app, opts) {
|
|
|
5118
5645
|
onCdpScreenshot: opts.onCdpScreenshot,
|
|
5119
5646
|
onCdpConfigure: opts.onCdpConfigure
|
|
5120
5647
|
});
|
|
5121
|
-
}, { prefix: "/api/v1" });
|
|
5648
|
+
}, { prefix: opts.routePrefix ?? "/api/v1" });
|
|
5122
5649
|
}
|
|
5123
5650
|
|
|
5124
5651
|
// ../provider-gemini/src/normalize.ts
|
|
@@ -6677,11 +7204,11 @@ function removeGoogleConnection(config, domain, connectionType) {
|
|
|
6677
7204
|
}
|
|
6678
7205
|
|
|
6679
7206
|
// src/job-runner.ts
|
|
6680
|
-
import
|
|
7207
|
+
import crypto15 from "crypto";
|
|
6681
7208
|
import fs4 from "fs";
|
|
6682
7209
|
import path5 from "path";
|
|
6683
7210
|
import os4 from "os";
|
|
6684
|
-
import { eq as
|
|
7211
|
+
import { eq as eq16, inArray as inArray3 } from "drizzle-orm";
|
|
6685
7212
|
var JobRunner = class {
|
|
6686
7213
|
db;
|
|
6687
7214
|
registry;
|
|
@@ -6695,7 +7222,7 @@ var JobRunner = class {
|
|
|
6695
7222
|
if (stale.length === 0) return;
|
|
6696
7223
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6697
7224
|
for (const run of stale) {
|
|
6698
|
-
this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(
|
|
7225
|
+
this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq16(runs.id, run.id)).run();
|
|
6699
7226
|
console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
|
|
6700
7227
|
}
|
|
6701
7228
|
}
|
|
@@ -6703,8 +7230,8 @@ var JobRunner = class {
|
|
|
6703
7230
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6704
7231
|
const startTime = Date.now();
|
|
6705
7232
|
try {
|
|
6706
|
-
this.db.update(runs).set({ status: "running", startedAt: now }).where(
|
|
6707
|
-
const project = this.db.select().from(projects).where(
|
|
7233
|
+
this.db.update(runs).set({ status: "running", startedAt: now }).where(eq16(runs.id, runId)).run();
|
|
7234
|
+
const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
|
|
6708
7235
|
if (!project) {
|
|
6709
7236
|
throw new Error(`Project ${projectId} not found`);
|
|
6710
7237
|
}
|
|
@@ -6725,14 +7252,14 @@ var JobRunner = class {
|
|
|
6725
7252
|
throw new Error("No providers configured. Add at least one provider API key.");
|
|
6726
7253
|
}
|
|
6727
7254
|
console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
|
|
6728
|
-
const projectKeywords = this.db.select().from(keywords).where(
|
|
6729
|
-
const projectCompetitors = this.db.select().from(competitors).where(
|
|
7255
|
+
const projectKeywords = this.db.select().from(keywords).where(eq16(keywords.projectId, projectId)).all();
|
|
7256
|
+
const projectCompetitors = this.db.select().from(competitors).where(eq16(competitors.projectId, projectId)).all();
|
|
6730
7257
|
const competitorDomains = projectCompetitors.map((c) => c.domain);
|
|
6731
7258
|
const queriesPerProvider = projectKeywords.length;
|
|
6732
7259
|
const todayPeriod = getCurrentPeriod();
|
|
6733
7260
|
for (const p of activeProviders) {
|
|
6734
7261
|
const providerScope = `${projectId}:${p.adapter.name}`;
|
|
6735
|
-
const providerUsage = this.db.select().from(usageCounters).where(
|
|
7262
|
+
const providerUsage = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
|
|
6736
7263
|
const limit = p.config.quotaPolicy.maxRequestsPerDay;
|
|
6737
7264
|
if (providerUsage + queriesPerProvider > limit) {
|
|
6738
7265
|
throw new Error(
|
|
@@ -6776,7 +7303,7 @@ var JobRunner = class {
|
|
|
6776
7303
|
const overlap = computeCompetitorOverlap(normalized, competitorDomains);
|
|
6777
7304
|
let screenshotRelPath = null;
|
|
6778
7305
|
if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
|
|
6779
|
-
const snapshotId =
|
|
7306
|
+
const snapshotId = crypto15.randomUUID();
|
|
6780
7307
|
const screenshotDir = path5.join(os4.homedir(), ".canonry", "screenshots", runId);
|
|
6781
7308
|
if (!fs4.existsSync(screenshotDir)) fs4.mkdirSync(screenshotDir, { recursive: true });
|
|
6782
7309
|
const destPath = path5.join(screenshotDir, `${snapshotId}.png`);
|
|
@@ -6804,7 +7331,7 @@ var JobRunner = class {
|
|
|
6804
7331
|
}).run();
|
|
6805
7332
|
} else {
|
|
6806
7333
|
this.db.insert(querySnapshots).values({
|
|
6807
|
-
id:
|
|
7334
|
+
id: crypto15.randomUUID(),
|
|
6808
7335
|
runId,
|
|
6809
7336
|
keywordId: kw.id,
|
|
6810
7337
|
provider: providerName,
|
|
@@ -6859,7 +7386,7 @@ var JobRunner = class {
|
|
|
6859
7386
|
const overlap = computeCompetitorOverlap(normalized, competitorDomains);
|
|
6860
7387
|
let screenshotRelPath = null;
|
|
6861
7388
|
if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
|
|
6862
|
-
const snapshotId =
|
|
7389
|
+
const snapshotId = crypto15.randomUUID();
|
|
6863
7390
|
const screenshotDir = path5.join(os4.homedir(), ".canonry", "screenshots", runId);
|
|
6864
7391
|
if (!fs4.existsSync(screenshotDir)) fs4.mkdirSync(screenshotDir, { recursive: true });
|
|
6865
7392
|
const destPath = path5.join(screenshotDir, `${snapshotId}.png`);
|
|
@@ -6887,7 +7414,7 @@ var JobRunner = class {
|
|
|
6887
7414
|
}).run();
|
|
6888
7415
|
} else {
|
|
6889
7416
|
this.db.insert(querySnapshots).values({
|
|
6890
|
-
id:
|
|
7417
|
+
id: crypto15.randomUUID(),
|
|
6891
7418
|
runId,
|
|
6892
7419
|
keywordId: kw.id,
|
|
6893
7420
|
provider: providerName,
|
|
@@ -6919,12 +7446,12 @@ var JobRunner = class {
|
|
|
6919
7446
|
const someFailed = providerErrors.size > 0;
|
|
6920
7447
|
if (allFailed) {
|
|
6921
7448
|
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
|
|
6922
|
-
this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(
|
|
7449
|
+
this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq16(runs.id, runId)).run();
|
|
6923
7450
|
} else if (someFailed) {
|
|
6924
7451
|
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
|
|
6925
|
-
this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(
|
|
7452
|
+
this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq16(runs.id, runId)).run();
|
|
6926
7453
|
} else {
|
|
6927
|
-
this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
7454
|
+
this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq16(runs.id, runId)).run();
|
|
6928
7455
|
}
|
|
6929
7456
|
const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
|
|
6930
7457
|
trackEvent("run.completed", {
|
|
@@ -6950,7 +7477,7 @@ var JobRunner = class {
|
|
|
6950
7477
|
status: "failed",
|
|
6951
7478
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6952
7479
|
error: errorMessage
|
|
6953
|
-
}).where(
|
|
7480
|
+
}).where(eq16(runs.id, runId)).run();
|
|
6954
7481
|
trackEvent("run.completed", {
|
|
6955
7482
|
status: "failed",
|
|
6956
7483
|
providerCount: 0,
|
|
@@ -6986,10 +7513,10 @@ var JobRunner = class {
|
|
|
6986
7513
|
incrementUsage(scope, metric, count) {
|
|
6987
7514
|
const now = /* @__PURE__ */ new Date();
|
|
6988
7515
|
const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
6989
|
-
const id =
|
|
6990
|
-
const existing = this.db.select().from(usageCounters).where(
|
|
7516
|
+
const id = crypto15.randomUUID();
|
|
7517
|
+
const existing = this.db.select().from(usageCounters).where(eq16(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
|
|
6991
7518
|
if (existing) {
|
|
6992
|
-
this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(
|
|
7519
|
+
this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq16(usageCounters.id, existing.id)).run();
|
|
6993
7520
|
} else {
|
|
6994
7521
|
this.db.insert(usageCounters).values({
|
|
6995
7522
|
id,
|
|
@@ -7069,8 +7596,8 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
|
|
|
7069
7596
|
}
|
|
7070
7597
|
|
|
7071
7598
|
// src/gsc-sync.ts
|
|
7072
|
-
import
|
|
7073
|
-
import { eq as
|
|
7599
|
+
import crypto16 from "crypto";
|
|
7600
|
+
import { eq as eq17, and as and6, sql as sql3 } from "drizzle-orm";
|
|
7074
7601
|
function formatDate(d) {
|
|
7075
7602
|
return d.toISOString().split("T")[0];
|
|
7076
7603
|
}
|
|
@@ -7081,13 +7608,13 @@ function daysAgo(n) {
|
|
|
7081
7608
|
}
|
|
7082
7609
|
async function executeGscSync(db, runId, projectId, opts) {
|
|
7083
7610
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7084
|
-
db.update(runs).set({ status: "running", startedAt: now }).where(
|
|
7611
|
+
db.update(runs).set({ status: "running", startedAt: now }).where(eq17(runs.id, runId)).run();
|
|
7085
7612
|
try {
|
|
7086
7613
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
|
|
7087
7614
|
if (!googleClientId || !googleClientSecret) {
|
|
7088
7615
|
throw new Error("Google OAuth is not configured in the local Canonry config");
|
|
7089
7616
|
}
|
|
7090
|
-
const project = db.select().from(projects).where(
|
|
7617
|
+
const project = db.select().from(projects).where(eq17(projects.id, projectId)).get();
|
|
7091
7618
|
if (!project) {
|
|
7092
7619
|
throw new Error(`Project not found: ${projectId}`);
|
|
7093
7620
|
}
|
|
@@ -7121,8 +7648,8 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
7121
7648
|
});
|
|
7122
7649
|
console.log(`[GSC Sync] Received ${rows.length} rows`);
|
|
7123
7650
|
db.delete(gscSearchData).where(
|
|
7124
|
-
|
|
7125
|
-
|
|
7651
|
+
and6(
|
|
7652
|
+
eq17(gscSearchData.projectId, projectId),
|
|
7126
7653
|
sql3`${gscSearchData.date} >= ${startDate}`,
|
|
7127
7654
|
sql3`${gscSearchData.date} <= ${endDate}`
|
|
7128
7655
|
)
|
|
@@ -7134,7 +7661,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
7134
7661
|
for (const row of batch) {
|
|
7135
7662
|
const [query, page, country, device, date] = row.keys;
|
|
7136
7663
|
db.insert(gscSearchData).values({
|
|
7137
|
-
id:
|
|
7664
|
+
id: crypto16.randomUUID(),
|
|
7138
7665
|
projectId,
|
|
7139
7666
|
syncRunId: runId,
|
|
7140
7667
|
date: date ?? "",
|
|
@@ -7168,7 +7695,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
7168
7695
|
const rich = ir.richResultsResult;
|
|
7169
7696
|
const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7170
7697
|
db.insert(gscUrlInspections).values({
|
|
7171
|
-
id:
|
|
7698
|
+
id: crypto16.randomUUID(),
|
|
7172
7699
|
projectId,
|
|
7173
7700
|
syncRunId: runId,
|
|
7174
7701
|
url: pageUrl,
|
|
@@ -7189,7 +7716,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
7189
7716
|
console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
|
|
7190
7717
|
}
|
|
7191
7718
|
}
|
|
7192
|
-
const allInspections = db.select().from(gscUrlInspections).where(
|
|
7719
|
+
const allInspections = db.select().from(gscUrlInspections).where(eq17(gscUrlInspections.projectId, projectId)).all();
|
|
7193
7720
|
const latestByUrl = /* @__PURE__ */ new Map();
|
|
7194
7721
|
for (const row of allInspections) {
|
|
7195
7722
|
const existing = latestByUrl.get(row.url);
|
|
@@ -7210,9 +7737,9 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
7210
7737
|
}
|
|
7211
7738
|
}
|
|
7212
7739
|
const snapshotDate = formatDate(/* @__PURE__ */ new Date());
|
|
7213
|
-
db.delete(gscCoverageSnapshots).where(
|
|
7740
|
+
db.delete(gscCoverageSnapshots).where(and6(eq17(gscCoverageSnapshots.projectId, projectId), eq17(gscCoverageSnapshots.date, snapshotDate))).run();
|
|
7214
7741
|
db.insert(gscCoverageSnapshots).values({
|
|
7215
|
-
id:
|
|
7742
|
+
id: crypto16.randomUUID(),
|
|
7216
7743
|
projectId,
|
|
7217
7744
|
syncRunId: runId,
|
|
7218
7745
|
date: snapshotDate,
|
|
@@ -7221,19 +7748,19 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
7221
7748
|
reasonBreakdown: JSON.stringify(reasonCounts),
|
|
7222
7749
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7223
7750
|
}).run();
|
|
7224
|
-
db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
7751
|
+
db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq17(runs.id, runId)).run();
|
|
7225
7752
|
console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections, coverage snapshot: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
|
|
7226
7753
|
} catch (err) {
|
|
7227
7754
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
7228
|
-
db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
7755
|
+
db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq17(runs.id, runId)).run();
|
|
7229
7756
|
console.error(`[GSC Sync] Failed:`, errorMsg);
|
|
7230
7757
|
throw err;
|
|
7231
7758
|
}
|
|
7232
7759
|
}
|
|
7233
7760
|
|
|
7234
7761
|
// src/gsc-inspect-sitemap.ts
|
|
7235
|
-
import
|
|
7236
|
-
import { eq as
|
|
7762
|
+
import crypto17 from "crypto";
|
|
7763
|
+
import { eq as eq18, and as and7 } from "drizzle-orm";
|
|
7237
7764
|
|
|
7238
7765
|
// src/sitemap-parser.ts
|
|
7239
7766
|
var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
|
|
@@ -7301,13 +7828,13 @@ async function parseSitemapRecursive(url, urls, depth) {
|
|
|
7301
7828
|
// src/gsc-inspect-sitemap.ts
|
|
7302
7829
|
async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
7303
7830
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7304
|
-
db.update(runs).set({ status: "running", startedAt: now }).where(
|
|
7831
|
+
db.update(runs).set({ status: "running", startedAt: now }).where(eq18(runs.id, runId)).run();
|
|
7305
7832
|
try {
|
|
7306
7833
|
const { clientId: googleClientId, clientSecret: googleClientSecret } = getGoogleAuthConfig(opts.config);
|
|
7307
7834
|
if (!googleClientId || !googleClientSecret) {
|
|
7308
7835
|
throw new Error("Google OAuth is not configured in the local Canonry config");
|
|
7309
7836
|
}
|
|
7310
|
-
const project = db.select().from(projects).where(
|
|
7837
|
+
const project = db.select().from(projects).where(eq18(projects.id, projectId)).get();
|
|
7311
7838
|
if (!project) {
|
|
7312
7839
|
throw new Error(`Project not found: ${projectId}`);
|
|
7313
7840
|
}
|
|
@@ -7330,7 +7857,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
|
7330
7857
|
});
|
|
7331
7858
|
saveConfig(opts.config);
|
|
7332
7859
|
}
|
|
7333
|
-
const sitemapUrl = opts.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
|
|
7860
|
+
const sitemapUrl = opts.sitemapUrl || conn.sitemapUrl || `https://${project.canonicalDomain}/sitemap.xml`;
|
|
7334
7861
|
console.log(`[Inspect Sitemap] Fetching sitemap from ${sitemapUrl}`);
|
|
7335
7862
|
const urls = await fetchAndParseSitemap(sitemapUrl);
|
|
7336
7863
|
console.log(`[Inspect Sitemap] Found ${urls.length} URLs in sitemap`);
|
|
@@ -7348,7 +7875,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
|
7348
7875
|
const rich = ir.richResultsResult;
|
|
7349
7876
|
const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7350
7877
|
db.insert(gscUrlInspections).values({
|
|
7351
|
-
id:
|
|
7878
|
+
id: crypto17.randomUUID(),
|
|
7352
7879
|
projectId,
|
|
7353
7880
|
syncRunId: runId,
|
|
7354
7881
|
url: pageUrl,
|
|
@@ -7375,7 +7902,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
|
7375
7902
|
await new Promise((r) => setTimeout(r, 1e3));
|
|
7376
7903
|
}
|
|
7377
7904
|
}
|
|
7378
|
-
const allInspections = db.select().from(gscUrlInspections).where(
|
|
7905
|
+
const allInspections = db.select().from(gscUrlInspections).where(eq18(gscUrlInspections.projectId, projectId)).all();
|
|
7379
7906
|
const latestByUrl = /* @__PURE__ */ new Map();
|
|
7380
7907
|
for (const row of allInspections) {
|
|
7381
7908
|
const existing = latestByUrl.get(row.url);
|
|
@@ -7396,9 +7923,9 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
|
7396
7923
|
}
|
|
7397
7924
|
}
|
|
7398
7925
|
const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
7399
|
-
db.delete(gscCoverageSnapshots).where(
|
|
7926
|
+
db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
|
|
7400
7927
|
db.insert(gscCoverageSnapshots).values({
|
|
7401
|
-
id:
|
|
7928
|
+
id: crypto17.randomUUID(),
|
|
7402
7929
|
projectId,
|
|
7403
7930
|
syncRunId: runId,
|
|
7404
7931
|
date: snapshotDate,
|
|
@@ -7408,11 +7935,11 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
|
7408
7935
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7409
7936
|
}).run();
|
|
7410
7937
|
const status = errors > 0 && inspected > 0 ? "partial" : errors === urls.length ? "failed" : "completed";
|
|
7411
|
-
db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
7938
|
+
db.update(runs).set({ status, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq18(runs.id, runId)).run();
|
|
7412
7939
|
console.log(`[Inspect Sitemap] Done. ${inspected} inspected, ${errors} errors out of ${urls.length} URLs. Coverage: ${snapIndexed} indexed / ${snapNotIndexed} not-indexed.`);
|
|
7413
7940
|
} catch (err) {
|
|
7414
7941
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
7415
|
-
db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
7942
|
+
db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq18(runs.id, runId)).run();
|
|
7416
7943
|
console.error(`[Inspect Sitemap] Failed:`, errorMsg);
|
|
7417
7944
|
throw err;
|
|
7418
7945
|
}
|
|
@@ -7471,7 +7998,7 @@ var ProviderRegistry = class {
|
|
|
7471
7998
|
|
|
7472
7999
|
// src/scheduler.ts
|
|
7473
8000
|
import cron from "node-cron";
|
|
7474
|
-
import { eq as
|
|
8001
|
+
import { eq as eq19 } from "drizzle-orm";
|
|
7475
8002
|
var Scheduler = class {
|
|
7476
8003
|
db;
|
|
7477
8004
|
callbacks;
|
|
@@ -7482,7 +8009,7 @@ var Scheduler = class {
|
|
|
7482
8009
|
}
|
|
7483
8010
|
/** Load all enabled schedules from DB and register cron jobs. */
|
|
7484
8011
|
start() {
|
|
7485
|
-
const allSchedules = this.db.select().from(schedules).where(
|
|
8012
|
+
const allSchedules = this.db.select().from(schedules).where(eq19(schedules.enabled, 1)).all();
|
|
7486
8013
|
for (const schedule of allSchedules) {
|
|
7487
8014
|
const missedRunAt = schedule.nextRunAt;
|
|
7488
8015
|
this.registerCronTask(schedule);
|
|
@@ -7507,7 +8034,7 @@ var Scheduler = class {
|
|
|
7507
8034
|
this.stopTask(projectId, existing, "Stopped");
|
|
7508
8035
|
this.tasks.delete(projectId);
|
|
7509
8036
|
}
|
|
7510
|
-
const schedule = this.db.select().from(schedules).where(
|
|
8037
|
+
const schedule = this.db.select().from(schedules).where(eq19(schedules.projectId, projectId)).get();
|
|
7511
8038
|
if (schedule && schedule.enabled === 1) {
|
|
7512
8039
|
this.registerCronTask(schedule);
|
|
7513
8040
|
}
|
|
@@ -7540,13 +8067,13 @@ var Scheduler = class {
|
|
|
7540
8067
|
this.db.update(schedules).set({
|
|
7541
8068
|
nextRunAt: task.getNextRun()?.toISOString() ?? null,
|
|
7542
8069
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7543
|
-
}).where(
|
|
8070
|
+
}).where(eq19(schedules.id, scheduleId)).run();
|
|
7544
8071
|
const label = schedule.preset ?? cronExpr;
|
|
7545
8072
|
console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
|
|
7546
8073
|
}
|
|
7547
8074
|
triggerRun(scheduleId, projectId) {
|
|
7548
8075
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7549
|
-
const currentSchedule = this.db.select().from(schedules).where(
|
|
8076
|
+
const currentSchedule = this.db.select().from(schedules).where(eq19(schedules.id, scheduleId)).get();
|
|
7550
8077
|
if (!currentSchedule || currentSchedule.enabled !== 1) {
|
|
7551
8078
|
console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
|
|
7552
8079
|
this.remove(projectId);
|
|
@@ -7554,7 +8081,7 @@ var Scheduler = class {
|
|
|
7554
8081
|
}
|
|
7555
8082
|
const task = this.tasks.get(projectId);
|
|
7556
8083
|
const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
|
|
7557
|
-
const project = this.db.select().from(projects).where(
|
|
8084
|
+
const project = this.db.select().from(projects).where(eq19(projects.id, projectId)).get();
|
|
7558
8085
|
if (!project) {
|
|
7559
8086
|
console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
|
|
7560
8087
|
this.remove(projectId);
|
|
@@ -7571,7 +8098,7 @@ var Scheduler = class {
|
|
|
7571
8098
|
this.db.update(schedules).set({
|
|
7572
8099
|
nextRunAt,
|
|
7573
8100
|
updatedAt: now
|
|
7574
|
-
}).where(
|
|
8101
|
+
}).where(eq19(schedules.id, currentSchedule.id)).run();
|
|
7575
8102
|
return;
|
|
7576
8103
|
}
|
|
7577
8104
|
const runId = queueResult.runId;
|
|
@@ -7579,7 +8106,7 @@ var Scheduler = class {
|
|
|
7579
8106
|
lastRunAt: now,
|
|
7580
8107
|
nextRunAt,
|
|
7581
8108
|
updatedAt: now
|
|
7582
|
-
}).where(
|
|
8109
|
+
}).where(eq19(schedules.id, currentSchedule.id)).run();
|
|
7583
8110
|
const scheduleProviders = JSON.parse(currentSchedule.providers);
|
|
7584
8111
|
const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
|
|
7585
8112
|
console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
|
|
@@ -7588,8 +8115,8 @@ var Scheduler = class {
|
|
|
7588
8115
|
};
|
|
7589
8116
|
|
|
7590
8117
|
// src/notifier.ts
|
|
7591
|
-
import { eq as
|
|
7592
|
-
import
|
|
8118
|
+
import { eq as eq20, desc as desc5, and as and8, or as or2 } from "drizzle-orm";
|
|
8119
|
+
import crypto18 from "crypto";
|
|
7593
8120
|
var Notifier = class {
|
|
7594
8121
|
db;
|
|
7595
8122
|
serverUrl;
|
|
@@ -7600,18 +8127,18 @@ var Notifier = class {
|
|
|
7600
8127
|
/** Called after a run completes (success, partial, or failed). */
|
|
7601
8128
|
async onRunCompleted(runId, projectId) {
|
|
7602
8129
|
console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
|
|
7603
|
-
const notifs = this.db.select().from(notifications).where(
|
|
8130
|
+
const notifs = this.db.select().from(notifications).where(eq20(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
|
|
7604
8131
|
if (notifs.length === 0) {
|
|
7605
8132
|
console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
|
|
7606
8133
|
return;
|
|
7607
8134
|
}
|
|
7608
8135
|
console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
|
|
7609
|
-
const run = this.db.select().from(runs).where(
|
|
8136
|
+
const run = this.db.select().from(runs).where(eq20(runs.id, runId)).get();
|
|
7610
8137
|
if (!run) {
|
|
7611
8138
|
console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
|
|
7612
8139
|
return;
|
|
7613
8140
|
}
|
|
7614
|
-
const project = this.db.select().from(projects).where(
|
|
8141
|
+
const project = this.db.select().from(projects).where(eq20(projects.id, projectId)).get();
|
|
7615
8142
|
if (!project) {
|
|
7616
8143
|
console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
|
|
7617
8144
|
return;
|
|
@@ -7651,11 +8178,11 @@ var Notifier = class {
|
|
|
7651
8178
|
}
|
|
7652
8179
|
computeTransitions(runId, projectId) {
|
|
7653
8180
|
const recentRuns = this.db.select().from(runs).where(
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
or2(
|
|
8181
|
+
and8(
|
|
8182
|
+
eq20(runs.projectId, projectId),
|
|
8183
|
+
or2(eq20(runs.status, "completed"), eq20(runs.status, "partial"))
|
|
7657
8184
|
)
|
|
7658
|
-
).orderBy(
|
|
8185
|
+
).orderBy(desc5(runs.createdAt)).limit(2).all();
|
|
7659
8186
|
if (recentRuns.length < 2) return [];
|
|
7660
8187
|
const currentRunId = recentRuns[0].id;
|
|
7661
8188
|
const previousRunId = recentRuns[1].id;
|
|
@@ -7665,12 +8192,12 @@ var Notifier = class {
|
|
|
7665
8192
|
keyword: keywords.keyword,
|
|
7666
8193
|
provider: querySnapshots.provider,
|
|
7667
8194
|
citationState: querySnapshots.citationState
|
|
7668
|
-
}).from(querySnapshots).leftJoin(keywords,
|
|
8195
|
+
}).from(querySnapshots).leftJoin(keywords, eq20(querySnapshots.keywordId, keywords.id)).where(eq20(querySnapshots.runId, currentRunId)).all();
|
|
7669
8196
|
const previousSnapshots = this.db.select({
|
|
7670
8197
|
keywordId: querySnapshots.keywordId,
|
|
7671
8198
|
provider: querySnapshots.provider,
|
|
7672
8199
|
citationState: querySnapshots.citationState
|
|
7673
|
-
}).from(querySnapshots).where(
|
|
8200
|
+
}).from(querySnapshots).where(eq20(querySnapshots.runId, previousRunId)).all();
|
|
7674
8201
|
const prevMap = /* @__PURE__ */ new Map();
|
|
7675
8202
|
for (const s of previousSnapshots) {
|
|
7676
8203
|
prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
|
|
@@ -7727,7 +8254,7 @@ var Notifier = class {
|
|
|
7727
8254
|
}
|
|
7728
8255
|
logDelivery(projectId, notificationId, event, status, error) {
|
|
7729
8256
|
this.db.insert(auditLog).values({
|
|
7730
|
-
id:
|
|
8257
|
+
id: crypto18.randomUUID(),
|
|
7731
8258
|
projectId,
|
|
7732
8259
|
actor: "scheduler",
|
|
7733
8260
|
action: `notification.${status}`,
|
|
@@ -7959,8 +8486,43 @@ async function createServer(opts) {
|
|
|
7959
8486
|
const googleSettingsSummary = {
|
|
7960
8487
|
configured: Boolean(opts.config.google?.clientId && opts.config.google?.clientSecret)
|
|
7961
8488
|
};
|
|
8489
|
+
const bingSettingsSummary = {
|
|
8490
|
+
configured: Boolean(opts.config.bing?.apiKey)
|
|
8491
|
+
};
|
|
8492
|
+
const bingConnectionStore = {
|
|
8493
|
+
getConnection: (domain) => {
|
|
8494
|
+
return opts.config.bing?.connections?.find((c) => c.domain === domain);
|
|
8495
|
+
},
|
|
8496
|
+
upsertConnection: (connection) => {
|
|
8497
|
+
if (!opts.config.bing) opts.config.bing = {};
|
|
8498
|
+
if (!opts.config.bing.connections) opts.config.bing.connections = [];
|
|
8499
|
+
const idx = opts.config.bing.connections.findIndex((c) => c.domain === connection.domain);
|
|
8500
|
+
if (idx >= 0) {
|
|
8501
|
+
opts.config.bing.connections[idx] = connection;
|
|
8502
|
+
} else {
|
|
8503
|
+
opts.config.bing.connections.push(connection);
|
|
8504
|
+
}
|
|
8505
|
+
saveConfig(opts.config);
|
|
8506
|
+
return connection;
|
|
8507
|
+
},
|
|
8508
|
+
updateConnection: (domain, patch) => {
|
|
8509
|
+
const conn = opts.config.bing?.connections?.find((c) => c.domain === domain);
|
|
8510
|
+
if (!conn) return void 0;
|
|
8511
|
+
Object.assign(conn, patch);
|
|
8512
|
+
saveConfig(opts.config);
|
|
8513
|
+
return conn;
|
|
8514
|
+
},
|
|
8515
|
+
deleteConnection: (domain) => {
|
|
8516
|
+
if (!opts.config.bing?.connections) return false;
|
|
8517
|
+
const idx = opts.config.bing.connections.findIndex((c) => c.domain === domain);
|
|
8518
|
+
if (idx < 0) return false;
|
|
8519
|
+
opts.config.bing.connections.splice(idx, 1);
|
|
8520
|
+
saveConfig(opts.config);
|
|
8521
|
+
return true;
|
|
8522
|
+
}
|
|
8523
|
+
};
|
|
7962
8524
|
const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
|
|
7963
|
-
const googleStateSecret = process.env.GOOGLE_STATE_SECRET ??
|
|
8525
|
+
const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto19.randomBytes(32).toString("hex");
|
|
7964
8526
|
const googleConnectionStore = {
|
|
7965
8527
|
listConnections: (domain) => listGoogleConnections(opts.config, domain),
|
|
7966
8528
|
getConnection: (domain, connectionType) => getGoogleConnection(opts.config, domain, connectionType),
|
|
@@ -7980,8 +8542,13 @@ async function createServer(opts) {
|
|
|
7980
8542
|
return removed;
|
|
7981
8543
|
}
|
|
7982
8544
|
};
|
|
8545
|
+
const rawBasePath = process.env.CANONRY_BASE_PATH ?? opts.config.basePath;
|
|
8546
|
+
const normalizedBasePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
|
|
8547
|
+
const basePath = normalizedBasePath === "/" ? void 0 : normalizedBasePath;
|
|
8548
|
+
const apiPrefix = basePath ? `${basePath}api/v1` : "/api/v1";
|
|
7983
8549
|
await app.register(apiRoutes, {
|
|
7984
8550
|
db: opts.db,
|
|
8551
|
+
routePrefix: apiPrefix,
|
|
7985
8552
|
skipAuth: false,
|
|
7986
8553
|
getGoogleAuthConfig: () => getGoogleAuthConfig(opts.config),
|
|
7987
8554
|
googleConnectionStore,
|
|
@@ -8019,6 +8586,8 @@ async function createServer(opts) {
|
|
|
8019
8586
|
},
|
|
8020
8587
|
providerSummary,
|
|
8021
8588
|
googleSettingsSummary,
|
|
8589
|
+
bingSettingsSummary,
|
|
8590
|
+
bingConnectionStore,
|
|
8022
8591
|
onRunCreated: (runId, projectId, providers2, location) => {
|
|
8023
8592
|
jobRunner.executeRun(runId, projectId, providers2, location).catch((err) => {
|
|
8024
8593
|
app.log.error({ runId, err }, "Job runner failed");
|
|
@@ -8074,7 +8643,7 @@ async function createServer(opts) {
|
|
|
8074
8643
|
const targetProjectIds = affectedProjectIds.length > 0 ? affectedProjectIds : [null];
|
|
8075
8644
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
8076
8645
|
opts.db.insert(auditLog).values(targetProjectIds.map((projectId) => ({
|
|
8077
|
-
id:
|
|
8646
|
+
id: crypto19.randomUUID(),
|
|
8078
8647
|
projectId,
|
|
8079
8648
|
actor: "api",
|
|
8080
8649
|
action: existing ? "provider.updated" : "provider.created",
|
|
@@ -8102,6 +8671,18 @@ async function createServer(opts) {
|
|
|
8102
8671
|
return null;
|
|
8103
8672
|
}
|
|
8104
8673
|
},
|
|
8674
|
+
onBingSettingsUpdate: (apiKey) => {
|
|
8675
|
+
try {
|
|
8676
|
+
if (!opts.config.bing) opts.config.bing = {};
|
|
8677
|
+
opts.config.bing.apiKey = apiKey;
|
|
8678
|
+
saveConfig(opts.config);
|
|
8679
|
+
bingSettingsSummary.configured = true;
|
|
8680
|
+
return { ...bingSettingsSummary };
|
|
8681
|
+
} catch (err) {
|
|
8682
|
+
app.log.error({ err }, "Failed to save Bing API key config");
|
|
8683
|
+
return null;
|
|
8684
|
+
}
|
|
8685
|
+
},
|
|
8105
8686
|
onScheduleUpdated: (action, projectId) => {
|
|
8106
8687
|
if (action === "upsert") scheduler.upsert(projectId);
|
|
8107
8688
|
if (action === "delete") scheduler.remove(projectId);
|
|
@@ -8194,8 +8775,6 @@ async function createServer(opts) {
|
|
|
8194
8775
|
const assetsDir = path6.join(dirname2, "..", "assets");
|
|
8195
8776
|
if (fs5.existsSync(assetsDir)) {
|
|
8196
8777
|
const indexPath = path6.join(assetsDir, "index.html");
|
|
8197
|
-
const rawBasePath = process.env.CANONRY_BASE_PATH ?? opts.config.basePath;
|
|
8198
|
-
const basePath = rawBasePath ? "/" + rawBasePath.replace(/^\//, "").replace(/\/?$/, "/") : void 0;
|
|
8199
8778
|
const injectConfig = (html) => {
|
|
8200
8779
|
const clientConfig = { apiKey: opts.config.apiKey };
|
|
8201
8780
|
if (basePath) clientConfig.basePath = basePath;
|
|
@@ -8206,21 +8785,32 @@ async function createServer(opts) {
|
|
|
8206
8785
|
const fastifyStatic = await import("@fastify/static");
|
|
8207
8786
|
await app.register(fastifyStatic.default, {
|
|
8208
8787
|
root: assetsDir,
|
|
8209
|
-
prefix: "/",
|
|
8788
|
+
prefix: basePath ?? "/",
|
|
8210
8789
|
wildcard: false,
|
|
8211
8790
|
// Don't serve index.html automatically — we handle it with config injection
|
|
8212
8791
|
serve: true,
|
|
8213
8792
|
index: false
|
|
8214
8793
|
});
|
|
8215
|
-
|
|
8794
|
+
const serveIndex = (_request, reply) => {
|
|
8216
8795
|
if (fs5.existsSync(indexPath)) {
|
|
8217
8796
|
const html = fs5.readFileSync(indexPath, "utf-8");
|
|
8218
8797
|
return reply.type("text/html").send(injectConfig(html));
|
|
8219
8798
|
}
|
|
8220
8799
|
return reply.status(404).send({ error: "Dashboard not built" });
|
|
8221
|
-
}
|
|
8800
|
+
};
|
|
8801
|
+
const rootRouteTrailing = basePath ?? "/";
|
|
8802
|
+
app.get(rootRouteTrailing, serveIndex);
|
|
8803
|
+
if (basePath) {
|
|
8804
|
+
const rootRouteBare = basePath.replace(/\/$/, "");
|
|
8805
|
+
if (rootRouteBare) app.get(rootRouteBare, serveIndex);
|
|
8806
|
+
}
|
|
8222
8807
|
app.setNotFoundHandler((request, reply) => {
|
|
8223
|
-
|
|
8808
|
+
const url = request.url.split("?")[0];
|
|
8809
|
+
const isApiRoute = url.startsWith("/api/") || basePath !== void 0 && url.startsWith(`${basePath}api/`);
|
|
8810
|
+
if (isApiRoute) {
|
|
8811
|
+
return reply.status(404).send({ error: "Not found", path: request.url });
|
|
8812
|
+
}
|
|
8813
|
+
if (basePath && !url.startsWith(basePath)) {
|
|
8224
8814
|
return reply.status(404).send({ error: "Not found", path: request.url });
|
|
8225
8815
|
}
|
|
8226
8816
|
if (fs5.existsSync(indexPath)) {
|
|
@@ -8230,11 +8820,11 @@ async function createServer(opts) {
|
|
|
8230
8820
|
return reply.status(404).send({ error: "Not found" });
|
|
8231
8821
|
});
|
|
8232
8822
|
}
|
|
8233
|
-
|
|
8234
|
-
|
|
8235
|
-
|
|
8236
|
-
|
|
8237
|
-
}
|
|
8823
|
+
const healthHandler = async () => ({ status: "ok", service: "canonry", version: PKG_VERSION });
|
|
8824
|
+
app.get("/health", healthHandler);
|
|
8825
|
+
if (basePath) {
|
|
8826
|
+
app.get(`${basePath}health`, healthHandler);
|
|
8827
|
+
}
|
|
8238
8828
|
scheduler.start();
|
|
8239
8829
|
app.addHook("onClose", async () => {
|
|
8240
8830
|
scheduler.stop();
|