@ainyc/canonry 1.10.0 → 1.10.1
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-BDoQOjXO.js +243 -0
- package/assets/assets/index-Dhvjw6Lo.css +1 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-P4D4VWJQ.js → chunk-2DC7RBXJ.js} +920 -103
- package/dist/cli.js +420 -3
- package/dist/index.js +1 -1
- package/package.json +6 -5
- package/assets/assets/index-C710XAPy.js +0 -243
- package/assets/assets/index-Ca3ICz3n.css +0 -1
|
@@ -140,6 +140,7 @@ function trackEvent(event, properties) {
|
|
|
140
140
|
|
|
141
141
|
// src/server.ts
|
|
142
142
|
import { createRequire as createRequire2 } from "module";
|
|
143
|
+
import crypto16 from "crypto";
|
|
143
144
|
import fs2 from "fs";
|
|
144
145
|
import path2 from "path";
|
|
145
146
|
import { fileURLToPath } from "url";
|
|
@@ -161,6 +162,9 @@ __export(schema_exports, {
|
|
|
161
162
|
apiKeys: () => apiKeys,
|
|
162
163
|
auditLog: () => auditLog,
|
|
163
164
|
competitors: () => competitors,
|
|
165
|
+
googleConnections: () => googleConnections,
|
|
166
|
+
gscSearchData: () => gscSearchData,
|
|
167
|
+
gscUrlInspections: () => gscUrlInspections,
|
|
164
168
|
keywords: () => keywords,
|
|
165
169
|
notifications: () => notifications,
|
|
166
170
|
projects: () => projects,
|
|
@@ -286,6 +290,61 @@ var notifications = sqliteTable("notifications", {
|
|
|
286
290
|
}, (table) => [
|
|
287
291
|
index("idx_notifications_project").on(table.projectId)
|
|
288
292
|
]);
|
|
293
|
+
var googleConnections = sqliteTable("google_connections", {
|
|
294
|
+
id: text("id").primaryKey(),
|
|
295
|
+
domain: text("domain").notNull(),
|
|
296
|
+
connectionType: text("connection_type").notNull(),
|
|
297
|
+
propertyId: text("property_id"),
|
|
298
|
+
accessToken: text("access_token"),
|
|
299
|
+
refreshToken: text("refresh_token"),
|
|
300
|
+
tokenExpiresAt: text("token_expires_at"),
|
|
301
|
+
scopes: text("scopes").notNull().default("[]"),
|
|
302
|
+
createdAt: text("created_at").notNull(),
|
|
303
|
+
updatedAt: text("updated_at").notNull()
|
|
304
|
+
}, (table) => [
|
|
305
|
+
uniqueIndex("idx_google_conn_domain_type").on(table.domain, table.connectionType)
|
|
306
|
+
]);
|
|
307
|
+
var gscSearchData = sqliteTable("gsc_search_data", {
|
|
308
|
+
id: text("id").primaryKey(),
|
|
309
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
310
|
+
syncRunId: text("sync_run_id").notNull().references(() => runs.id, { onDelete: "cascade" }),
|
|
311
|
+
date: text("date").notNull(),
|
|
312
|
+
query: text("query").notNull(),
|
|
313
|
+
page: text("page").notNull(),
|
|
314
|
+
country: text("country"),
|
|
315
|
+
device: text("device"),
|
|
316
|
+
clicks: integer("clicks").notNull().default(0),
|
|
317
|
+
impressions: integer("impressions").notNull().default(0),
|
|
318
|
+
ctr: text("ctr").notNull().default("0"),
|
|
319
|
+
position: text("position").notNull().default("0"),
|
|
320
|
+
createdAt: text("created_at").notNull()
|
|
321
|
+
}, (table) => [
|
|
322
|
+
index("idx_gsc_search_project_date").on(table.projectId, table.date),
|
|
323
|
+
index("idx_gsc_search_query").on(table.query),
|
|
324
|
+
index("idx_gsc_search_run").on(table.syncRunId)
|
|
325
|
+
]);
|
|
326
|
+
var gscUrlInspections = sqliteTable("gsc_url_inspections", {
|
|
327
|
+
id: text("id").primaryKey(),
|
|
328
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
329
|
+
syncRunId: text("sync_run_id").references(() => runs.id, { onDelete: "cascade" }),
|
|
330
|
+
url: text("url").notNull(),
|
|
331
|
+
indexingState: text("indexing_state"),
|
|
332
|
+
verdict: text("verdict"),
|
|
333
|
+
coverageState: text("coverage_state"),
|
|
334
|
+
pageFetchState: text("page_fetch_state"),
|
|
335
|
+
robotsTxtState: text("robots_txt_state"),
|
|
336
|
+
crawlTime: text("crawl_time"),
|
|
337
|
+
lastCrawlResult: text("last_crawl_result"),
|
|
338
|
+
isMobileFriendly: integer("is_mobile_friendly"),
|
|
339
|
+
richResults: text("rich_results").notNull().default("[]"),
|
|
340
|
+
referringUrls: text("referring_urls").notNull().default("[]"),
|
|
341
|
+
inspectedAt: text("inspected_at").notNull(),
|
|
342
|
+
createdAt: text("created_at").notNull()
|
|
343
|
+
}, (table) => [
|
|
344
|
+
index("idx_gsc_inspect_project_url").on(table.projectId, table.url),
|
|
345
|
+
index("idx_gsc_inspect_run").on(table.syncRunId),
|
|
346
|
+
index("idx_gsc_inspect_url_time").on(table.url, table.inspectedAt)
|
|
347
|
+
]);
|
|
289
348
|
var usageCounters = sqliteTable("usage_counters", {
|
|
290
349
|
id: text("id").primaryKey(),
|
|
291
350
|
scope: text("scope").notNull(),
|
|
@@ -448,7 +507,62 @@ var MIGRATIONS = [
|
|
|
448
507
|
// v5: Add model column to query_snapshots for per-model scoring
|
|
449
508
|
`ALTER TABLE query_snapshots ADD COLUMN model TEXT`,
|
|
450
509
|
// v5b: Backfill model from rawResponse JSON for existing snapshots
|
|
451
|
-
`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
|
|
510
|
+
`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`,
|
|
511
|
+
// v6: Google Search Console integration — google_connections table (domain-scoped)
|
|
512
|
+
`CREATE TABLE IF NOT EXISTS google_connections (
|
|
513
|
+
id TEXT PRIMARY KEY,
|
|
514
|
+
domain TEXT NOT NULL,
|
|
515
|
+
connection_type TEXT NOT NULL,
|
|
516
|
+
property_id TEXT,
|
|
517
|
+
access_token TEXT,
|
|
518
|
+
refresh_token TEXT,
|
|
519
|
+
token_expires_at TEXT,
|
|
520
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
521
|
+
created_at TEXT NOT NULL,
|
|
522
|
+
updated_at TEXT NOT NULL
|
|
523
|
+
)`,
|
|
524
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_google_conn_domain_type ON google_connections(domain, connection_type)`,
|
|
525
|
+
// v6: Google Search Console integration — gsc_search_data table
|
|
526
|
+
`CREATE TABLE IF NOT EXISTS gsc_search_data (
|
|
527
|
+
id TEXT PRIMARY KEY,
|
|
528
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
529
|
+
sync_run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
530
|
+
date TEXT NOT NULL,
|
|
531
|
+
query TEXT NOT NULL,
|
|
532
|
+
page TEXT NOT NULL,
|
|
533
|
+
country TEXT,
|
|
534
|
+
device TEXT,
|
|
535
|
+
clicks INTEGER NOT NULL DEFAULT 0,
|
|
536
|
+
impressions INTEGER NOT NULL DEFAULT 0,
|
|
537
|
+
ctr TEXT NOT NULL DEFAULT '0',
|
|
538
|
+
position TEXT NOT NULL DEFAULT '0',
|
|
539
|
+
created_at TEXT NOT NULL
|
|
540
|
+
)`,
|
|
541
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_project_date ON gsc_search_data(project_id, date)`,
|
|
542
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_query ON gsc_search_data(query)`,
|
|
543
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_search_run ON gsc_search_data(sync_run_id)`,
|
|
544
|
+
// v6: Google Search Console integration — gsc_url_inspections table
|
|
545
|
+
`CREATE TABLE IF NOT EXISTS gsc_url_inspections (
|
|
546
|
+
id TEXT PRIMARY KEY,
|
|
547
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
548
|
+
sync_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
549
|
+
url TEXT NOT NULL,
|
|
550
|
+
indexing_state TEXT,
|
|
551
|
+
verdict TEXT,
|
|
552
|
+
coverage_state TEXT,
|
|
553
|
+
page_fetch_state TEXT,
|
|
554
|
+
robots_txt_state TEXT,
|
|
555
|
+
crawl_time TEXT,
|
|
556
|
+
last_crawl_result TEXT,
|
|
557
|
+
is_mobile_friendly INTEGER,
|
|
558
|
+
rich_results TEXT NOT NULL DEFAULT '[]',
|
|
559
|
+
referring_urls TEXT NOT NULL DEFAULT '[]',
|
|
560
|
+
inspected_at TEXT NOT NULL,
|
|
561
|
+
created_at TEXT NOT NULL
|
|
562
|
+
)`,
|
|
563
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_project_url ON gsc_url_inspections(project_id, url)`,
|
|
564
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_run ON gsc_url_inspections(sync_run_id)`,
|
|
565
|
+
`CREATE INDEX IF NOT EXISTS idx_gsc_inspect_url_time ON gsc_url_inspections(url, inspected_at)`
|
|
452
566
|
];
|
|
453
567
|
function migrate(db) {
|
|
454
568
|
const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
@@ -521,6 +635,15 @@ var configNotificationSchema = z3.object({
|
|
|
521
635
|
url: z3.string().url(),
|
|
522
636
|
events: z3.array(notificationEventSchema).min(1)
|
|
523
637
|
});
|
|
638
|
+
var configGoogleSchema = z3.object({
|
|
639
|
+
gsc: z3.object({
|
|
640
|
+
propertyUrl: z3.string()
|
|
641
|
+
}).optional(),
|
|
642
|
+
syncSchedule: z3.object({
|
|
643
|
+
preset: z3.string().optional(),
|
|
644
|
+
cron: z3.string().optional()
|
|
645
|
+
}).optional()
|
|
646
|
+
}).optional();
|
|
524
647
|
var configSpecSchema = z3.object({
|
|
525
648
|
displayName: z3.string().min(1),
|
|
526
649
|
canonicalDomain: z3.string().min(1),
|
|
@@ -531,7 +654,8 @@ var configSpecSchema = z3.object({
|
|
|
531
654
|
competitors: z3.array(z3.string().min(1)).optional().default([]),
|
|
532
655
|
providers: z3.array(providerNameSchema).optional().default([]),
|
|
533
656
|
schedule: configScheduleSchema,
|
|
534
|
-
notifications: z3.array(configNotificationSchema).optional().default([])
|
|
657
|
+
notifications: z3.array(configNotificationSchema).optional().default([]),
|
|
658
|
+
google: configGoogleSchema
|
|
535
659
|
});
|
|
536
660
|
var projectConfigSchema = z3.object({
|
|
537
661
|
apiVersion: z3.literal("canonry/v1"),
|
|
@@ -633,23 +757,62 @@ function unsupportedKind(kind) {
|
|
|
633
757
|
return new AppError("UNSUPPORTED_KIND", `Kind '${kind}' is not supported in this version`, 400);
|
|
634
758
|
}
|
|
635
759
|
|
|
636
|
-
// ../contracts/src/
|
|
760
|
+
// ../contracts/src/google.ts
|
|
637
761
|
import { z as z4 } from "zod";
|
|
638
|
-
var
|
|
639
|
-
var
|
|
762
|
+
var googleConnectionTypeSchema = z4.enum(["gsc", "ga4"]);
|
|
763
|
+
var googleConnectionDtoSchema = z4.object({
|
|
764
|
+
id: z4.string(),
|
|
765
|
+
domain: z4.string(),
|
|
766
|
+
connectionType: googleConnectionTypeSchema,
|
|
767
|
+
propertyId: z4.string().nullable().optional(),
|
|
768
|
+
scopes: z4.array(z4.string()).default([]),
|
|
769
|
+
createdAt: z4.string(),
|
|
770
|
+
updatedAt: z4.string()
|
|
771
|
+
});
|
|
772
|
+
var gscSearchDataDtoSchema = z4.object({
|
|
773
|
+
date: z4.string(),
|
|
774
|
+
query: z4.string(),
|
|
775
|
+
page: z4.string(),
|
|
776
|
+
country: z4.string().nullable().optional(),
|
|
777
|
+
device: z4.string().nullable().optional(),
|
|
778
|
+
clicks: z4.number(),
|
|
779
|
+
impressions: z4.number(),
|
|
780
|
+
ctr: z4.number(),
|
|
781
|
+
position: z4.number()
|
|
782
|
+
});
|
|
783
|
+
var gscUrlInspectionDtoSchema = z4.object({
|
|
640
784
|
id: z4.string(),
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
785
|
+
url: z4.string(),
|
|
786
|
+
indexingState: z4.string().nullable().optional(),
|
|
787
|
+
verdict: z4.string().nullable().optional(),
|
|
788
|
+
coverageState: z4.string().nullable().optional(),
|
|
789
|
+
pageFetchState: z4.string().nullable().optional(),
|
|
790
|
+
robotsTxtState: z4.string().nullable().optional(),
|
|
791
|
+
crawlTime: z4.string().nullable().optional(),
|
|
792
|
+
lastCrawlResult: z4.string().nullable().optional(),
|
|
793
|
+
isMobileFriendly: z4.boolean().nullable().optional(),
|
|
794
|
+
richResults: z4.array(z4.string()).default([]),
|
|
795
|
+
inspectedAt: z4.string()
|
|
796
|
+
});
|
|
797
|
+
var indexTransitionSchema = z4.enum(["stable", "reindexed", "deindexed", "still-missing", "new"]);
|
|
798
|
+
|
|
799
|
+
// ../contracts/src/project.ts
|
|
800
|
+
import { z as z5 } from "zod";
|
|
801
|
+
var configSourceSchema = z5.enum(["cli", "api", "config-file"]);
|
|
802
|
+
var projectDtoSchema = z5.object({
|
|
803
|
+
id: z5.string(),
|
|
804
|
+
name: z5.string(),
|
|
805
|
+
displayName: z5.string().optional(),
|
|
806
|
+
canonicalDomain: z5.string(),
|
|
807
|
+
ownedDomains: z5.array(z5.string()).default([]),
|
|
808
|
+
country: z5.string().length(2),
|
|
809
|
+
language: z5.string().min(2),
|
|
810
|
+
tags: z5.array(z5.string()).default([]),
|
|
811
|
+
labels: z5.record(z5.string(), z5.string()).default({}),
|
|
649
812
|
configSource: configSourceSchema.default("cli"),
|
|
650
|
-
configRevision:
|
|
651
|
-
createdAt:
|
|
652
|
-
updatedAt:
|
|
813
|
+
configRevision: z5.number().int().positive().default(1),
|
|
814
|
+
createdAt: z5.string().optional(),
|
|
815
|
+
updatedAt: z5.string().optional()
|
|
653
816
|
});
|
|
654
817
|
function normalizeProjectDomain(input) {
|
|
655
818
|
let domain = input.trim().toLowerCase();
|
|
@@ -677,68 +840,68 @@ function effectiveDomains(project) {
|
|
|
677
840
|
}
|
|
678
841
|
|
|
679
842
|
// ../contracts/src/run.ts
|
|
680
|
-
import { z as
|
|
681
|
-
var runStatusSchema =
|
|
682
|
-
var runKindSchema =
|
|
683
|
-
var runTriggerSchema =
|
|
684
|
-
var citationStateSchema =
|
|
685
|
-
var computedTransitionSchema =
|
|
686
|
-
var runDtoSchema =
|
|
687
|
-
id:
|
|
688
|
-
projectId:
|
|
843
|
+
import { z as z6 } from "zod";
|
|
844
|
+
var runStatusSchema = z6.enum(["queued", "running", "completed", "partial", "failed"]);
|
|
845
|
+
var runKindSchema = z6.enum(["answer-visibility", "site-audit", "gsc-sync"]);
|
|
846
|
+
var runTriggerSchema = z6.enum(["manual", "scheduled", "config-apply"]);
|
|
847
|
+
var citationStateSchema = z6.enum(["cited", "not-cited"]);
|
|
848
|
+
var computedTransitionSchema = z6.enum(["new", "cited", "lost", "emerging", "not-cited"]);
|
|
849
|
+
var runDtoSchema = z6.object({
|
|
850
|
+
id: z6.string(),
|
|
851
|
+
projectId: z6.string(),
|
|
689
852
|
kind: runKindSchema,
|
|
690
853
|
status: runStatusSchema,
|
|
691
854
|
trigger: runTriggerSchema.default("manual"),
|
|
692
|
-
startedAt:
|
|
693
|
-
finishedAt:
|
|
694
|
-
error:
|
|
695
|
-
createdAt:
|
|
855
|
+
startedAt: z6.string().nullable().optional(),
|
|
856
|
+
finishedAt: z6.string().nullable().optional(),
|
|
857
|
+
error: z6.string().nullable().optional(),
|
|
858
|
+
createdAt: z6.string()
|
|
696
859
|
});
|
|
697
|
-
var groundingSourceSchema =
|
|
698
|
-
uri:
|
|
699
|
-
title:
|
|
860
|
+
var groundingSourceSchema = z6.object({
|
|
861
|
+
uri: z6.string(),
|
|
862
|
+
title: z6.string()
|
|
700
863
|
});
|
|
701
|
-
var querySnapshotDtoSchema =
|
|
702
|
-
id:
|
|
703
|
-
runId:
|
|
704
|
-
keywordId:
|
|
705
|
-
keyword:
|
|
864
|
+
var querySnapshotDtoSchema = z6.object({
|
|
865
|
+
id: z6.string(),
|
|
866
|
+
runId: z6.string(),
|
|
867
|
+
keywordId: z6.string(),
|
|
868
|
+
keyword: z6.string().optional(),
|
|
706
869
|
provider: providerNameSchema,
|
|
707
870
|
citationState: citationStateSchema,
|
|
708
871
|
transition: computedTransitionSchema.optional(),
|
|
709
|
-
answerText:
|
|
710
|
-
citedDomains:
|
|
711
|
-
competitorOverlap:
|
|
712
|
-
groundingSources:
|
|
713
|
-
searchQueries:
|
|
714
|
-
model:
|
|
715
|
-
createdAt:
|
|
872
|
+
answerText: z6.string().nullable().optional(),
|
|
873
|
+
citedDomains: z6.array(z6.string()).default([]),
|
|
874
|
+
competitorOverlap: z6.array(z6.string()).default([]),
|
|
875
|
+
groundingSources: z6.array(groundingSourceSchema).default([]),
|
|
876
|
+
searchQueries: z6.array(z6.string()).default([]),
|
|
877
|
+
model: z6.string().nullable().optional(),
|
|
878
|
+
createdAt: z6.string()
|
|
716
879
|
});
|
|
717
|
-
var auditLogEntrySchema =
|
|
718
|
-
id:
|
|
719
|
-
projectId:
|
|
720
|
-
actor:
|
|
721
|
-
action:
|
|
722
|
-
entityType:
|
|
723
|
-
entityId:
|
|
724
|
-
diff:
|
|
725
|
-
createdAt:
|
|
880
|
+
var auditLogEntrySchema = z6.object({
|
|
881
|
+
id: z6.string(),
|
|
882
|
+
projectId: z6.string().nullable().optional(),
|
|
883
|
+
actor: z6.string(),
|
|
884
|
+
action: z6.string(),
|
|
885
|
+
entityType: z6.string(),
|
|
886
|
+
entityId: z6.string().nullable().optional(),
|
|
887
|
+
diff: z6.unknown().optional(),
|
|
888
|
+
createdAt: z6.string()
|
|
726
889
|
});
|
|
727
890
|
|
|
728
891
|
// ../contracts/src/schedule.ts
|
|
729
|
-
import { z as
|
|
730
|
-
var scheduleDtoSchema =
|
|
731
|
-
id:
|
|
732
|
-
projectId:
|
|
733
|
-
cronExpr:
|
|
734
|
-
preset:
|
|
735
|
-
timezone:
|
|
736
|
-
enabled:
|
|
737
|
-
providers:
|
|
738
|
-
lastRunAt:
|
|
739
|
-
nextRunAt:
|
|
740
|
-
createdAt:
|
|
741
|
-
updatedAt:
|
|
892
|
+
import { z as z7 } from "zod";
|
|
893
|
+
var scheduleDtoSchema = z7.object({
|
|
894
|
+
id: z7.string(),
|
|
895
|
+
projectId: z7.string(),
|
|
896
|
+
cronExpr: z7.string(),
|
|
897
|
+
preset: z7.string().nullable().optional(),
|
|
898
|
+
timezone: z7.string().default("UTC"),
|
|
899
|
+
enabled: z7.boolean().default(true),
|
|
900
|
+
providers: z7.array(providerNameSchema).default([]),
|
|
901
|
+
lastRunAt: z7.string().nullable().optional(),
|
|
902
|
+
nextRunAt: z7.string().nullable().optional(),
|
|
903
|
+
createdAt: z7.string(),
|
|
904
|
+
updatedAt: z7.string()
|
|
742
905
|
});
|
|
743
906
|
|
|
744
907
|
// ../api-routes/src/auth.ts
|
|
@@ -749,6 +912,7 @@ var SKIP_PATHS = ["/health"];
|
|
|
749
912
|
function shouldSkipAuth(url) {
|
|
750
913
|
if (SKIP_PATHS.includes(url)) return true;
|
|
751
914
|
if (url.endsWith("/openapi.json")) return true;
|
|
915
|
+
if (url.includes("/google/callback")) return true;
|
|
752
916
|
return false;
|
|
753
917
|
}
|
|
754
918
|
async function authPlugin(app) {
|
|
@@ -1384,7 +1548,7 @@ function resolveProjectSafe3(app, name, reply) {
|
|
|
1384
1548
|
|
|
1385
1549
|
// ../api-routes/src/apply.ts
|
|
1386
1550
|
import crypto9 from "crypto";
|
|
1387
|
-
import { eq as eq8 } from "drizzle-orm";
|
|
1551
|
+
import { eq as eq8, and as and3 } from "drizzle-orm";
|
|
1388
1552
|
|
|
1389
1553
|
// ../api-routes/src/schedule-utils.ts
|
|
1390
1554
|
var DAY_MAP = {
|
|
@@ -1821,6 +1985,13 @@ async function applyRoutes(app, opts) {
|
|
|
1821
1985
|
diff: { notifications: config.spec.notifications }
|
|
1822
1986
|
});
|
|
1823
1987
|
}
|
|
1988
|
+
if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
|
|
1989
|
+
const domain = config.spec.canonicalDomain;
|
|
1990
|
+
const conn = app.db.select().from(googleConnections).where(and3(eq8(googleConnections.domain, domain), eq8(googleConnections.connectionType, "gsc"))).get();
|
|
1991
|
+
if (conn) {
|
|
1992
|
+
app.db.update(googleConnections).set({ propertyId: config.spec.google.gsc.propertyUrl, updatedAt: now }).where(eq8(googleConnections.id, conn.id)).run();
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1824
1995
|
const project = app.db.select().from(projects).where(eq8(projects.id, projectId)).get();
|
|
1825
1996
|
return reply.status(200).send({
|
|
1826
1997
|
id: project.id,
|
|
@@ -3075,6 +3246,501 @@ function resolveProjectSafe6(app, name, reply) {
|
|
|
3075
3246
|
}
|
|
3076
3247
|
}
|
|
3077
3248
|
|
|
3249
|
+
// ../api-routes/src/google.ts
|
|
3250
|
+
import crypto12 from "crypto";
|
|
3251
|
+
import { eq as eq12, and as and4, desc as desc2, sql as sql2 } from "drizzle-orm";
|
|
3252
|
+
|
|
3253
|
+
// ../integration-google/src/constants.ts
|
|
3254
|
+
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
3255
|
+
var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
3256
|
+
var GSC_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly";
|
|
3257
|
+
var GSC_API_BASE = "https://www.googleapis.com/webmasters/v3";
|
|
3258
|
+
var URL_INSPECTION_API = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect";
|
|
3259
|
+
var GSC_MAX_ROWS_PER_REQUEST = 25e3;
|
|
3260
|
+
var GSC_DATA_LAG_DAYS = 3;
|
|
3261
|
+
|
|
3262
|
+
// ../integration-google/src/types.ts
|
|
3263
|
+
var GoogleAuthError = class extends Error {
|
|
3264
|
+
constructor(message) {
|
|
3265
|
+
super(message);
|
|
3266
|
+
this.name = "GoogleAuthError";
|
|
3267
|
+
}
|
|
3268
|
+
};
|
|
3269
|
+
var GoogleApiError = class extends Error {
|
|
3270
|
+
status;
|
|
3271
|
+
constructor(message, status) {
|
|
3272
|
+
super(message);
|
|
3273
|
+
this.name = "GoogleApiError";
|
|
3274
|
+
this.status = status;
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
|
|
3278
|
+
// ../integration-google/src/oauth.ts
|
|
3279
|
+
function getAuthUrl(clientId, redirectUri, scopes, state) {
|
|
3280
|
+
const params = new URLSearchParams({
|
|
3281
|
+
client_id: clientId,
|
|
3282
|
+
redirect_uri: redirectUri,
|
|
3283
|
+
response_type: "code",
|
|
3284
|
+
scope: scopes.join(" "),
|
|
3285
|
+
access_type: "offline",
|
|
3286
|
+
prompt: "consent"
|
|
3287
|
+
});
|
|
3288
|
+
if (state) params.set("state", state);
|
|
3289
|
+
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
3290
|
+
}
|
|
3291
|
+
async function exchangeCode(clientId, clientSecret, code, redirectUri) {
|
|
3292
|
+
const res = await fetch(GOOGLE_TOKEN_URL, {
|
|
3293
|
+
method: "POST",
|
|
3294
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
3295
|
+
body: new URLSearchParams({
|
|
3296
|
+
client_id: clientId,
|
|
3297
|
+
client_secret: clientSecret,
|
|
3298
|
+
code,
|
|
3299
|
+
redirect_uri: redirectUri,
|
|
3300
|
+
grant_type: "authorization_code"
|
|
3301
|
+
})
|
|
3302
|
+
});
|
|
3303
|
+
if (!res.ok) {
|
|
3304
|
+
const body = await res.text();
|
|
3305
|
+
throw new GoogleAuthError(`Token exchange failed (${res.status}): ${body}`);
|
|
3306
|
+
}
|
|
3307
|
+
return await res.json();
|
|
3308
|
+
}
|
|
3309
|
+
async function refreshAccessToken(clientId, clientSecret, currentRefreshToken) {
|
|
3310
|
+
const res = await fetch(GOOGLE_TOKEN_URL, {
|
|
3311
|
+
method: "POST",
|
|
3312
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
3313
|
+
body: new URLSearchParams({
|
|
3314
|
+
client_id: clientId,
|
|
3315
|
+
client_secret: clientSecret,
|
|
3316
|
+
refresh_token: currentRefreshToken,
|
|
3317
|
+
grant_type: "refresh_token"
|
|
3318
|
+
})
|
|
3319
|
+
});
|
|
3320
|
+
if (!res.ok) {
|
|
3321
|
+
const body = await res.text();
|
|
3322
|
+
throw new GoogleAuthError(`Token refresh failed (${res.status}): ${body}`);
|
|
3323
|
+
}
|
|
3324
|
+
return await res.json();
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
// ../integration-google/src/gsc-client.ts
|
|
3328
|
+
async function gscFetch(accessToken, url, opts) {
|
|
3329
|
+
const method = opts?.method ?? "GET";
|
|
3330
|
+
const headers = {
|
|
3331
|
+
Authorization: `Bearer ${accessToken}`,
|
|
3332
|
+
"Content-Type": "application/json"
|
|
3333
|
+
};
|
|
3334
|
+
const res = await fetch(url, {
|
|
3335
|
+
method,
|
|
3336
|
+
headers,
|
|
3337
|
+
body: opts?.body != null ? JSON.stringify(opts.body) : void 0
|
|
3338
|
+
});
|
|
3339
|
+
if (res.status === 401) {
|
|
3340
|
+
throw new GoogleApiError("Access token expired or revoked", 401);
|
|
3341
|
+
}
|
|
3342
|
+
if (res.status === 429) {
|
|
3343
|
+
throw new GoogleApiError("Google API rate limit exceeded", 429);
|
|
3344
|
+
}
|
|
3345
|
+
if (!res.ok) {
|
|
3346
|
+
const body = await res.text();
|
|
3347
|
+
throw new GoogleApiError(`GSC API error (${res.status}): ${body}`, res.status);
|
|
3348
|
+
}
|
|
3349
|
+
return await res.json();
|
|
3350
|
+
}
|
|
3351
|
+
async function listSites(accessToken) {
|
|
3352
|
+
const data = await gscFetch(
|
|
3353
|
+
accessToken,
|
|
3354
|
+
`${GSC_API_BASE}/sites`
|
|
3355
|
+
);
|
|
3356
|
+
return data.siteEntry ?? [];
|
|
3357
|
+
}
|
|
3358
|
+
async function fetchSearchAnalytics(accessToken, siteUrl, opts) {
|
|
3359
|
+
const allRows = [];
|
|
3360
|
+
let startRow = 0;
|
|
3361
|
+
const dimensions = opts.dimensions ?? ["query", "page", "country", "device", "date"];
|
|
3362
|
+
for (; ; ) {
|
|
3363
|
+
const requestBody = {
|
|
3364
|
+
startDate: opts.startDate,
|
|
3365
|
+
endDate: opts.endDate,
|
|
3366
|
+
dimensions,
|
|
3367
|
+
rowLimit: GSC_MAX_ROWS_PER_REQUEST,
|
|
3368
|
+
startRow
|
|
3369
|
+
};
|
|
3370
|
+
if (opts.query || opts.page) {
|
|
3371
|
+
const filters = [];
|
|
3372
|
+
const filterList = [];
|
|
3373
|
+
if (opts.query) filterList.push({ dimension: "query", operator: "contains", expression: opts.query });
|
|
3374
|
+
if (opts.page) filterList.push({ dimension: "page", operator: "contains", expression: opts.page });
|
|
3375
|
+
filters.push({ filters: filterList });
|
|
3376
|
+
requestBody.dimensionFilterGroups = filters;
|
|
3377
|
+
}
|
|
3378
|
+
const encodedSiteUrl = encodeURIComponent(siteUrl);
|
|
3379
|
+
const data = await gscFetch(
|
|
3380
|
+
accessToken,
|
|
3381
|
+
`${GSC_API_BASE}/sites/${encodedSiteUrl}/searchAnalytics/query`,
|
|
3382
|
+
{ method: "POST", body: requestBody }
|
|
3383
|
+
);
|
|
3384
|
+
const rows = data.rows ?? [];
|
|
3385
|
+
allRows.push(...rows);
|
|
3386
|
+
if (rows.length < GSC_MAX_ROWS_PER_REQUEST) {
|
|
3387
|
+
break;
|
|
3388
|
+
}
|
|
3389
|
+
startRow += rows.length;
|
|
3390
|
+
}
|
|
3391
|
+
return allRows;
|
|
3392
|
+
}
|
|
3393
|
+
async function inspectUrl(accessToken, inspectionUrl, siteUrl) {
|
|
3394
|
+
return gscFetch(
|
|
3395
|
+
accessToken,
|
|
3396
|
+
URL_INSPECTION_API,
|
|
3397
|
+
{
|
|
3398
|
+
method: "POST",
|
|
3399
|
+
body: {
|
|
3400
|
+
inspectionUrl,
|
|
3401
|
+
siteUrl
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// ../api-routes/src/google.ts
|
|
3408
|
+
function signState(payload, secret) {
|
|
3409
|
+
return crypto12.createHmac("sha256", secret).update(payload).digest("hex");
|
|
3410
|
+
}
|
|
3411
|
+
function buildSignedState(data, secret) {
|
|
3412
|
+
const payload = JSON.stringify(data);
|
|
3413
|
+
const sig = signState(payload, secret);
|
|
3414
|
+
return Buffer.from(JSON.stringify({ payload, sig })).toString("base64url");
|
|
3415
|
+
}
|
|
3416
|
+
function verifySignedState(encoded, secret) {
|
|
3417
|
+
try {
|
|
3418
|
+
const { payload, sig } = JSON.parse(Buffer.from(encoded, "base64url").toString());
|
|
3419
|
+
const expected = signState(payload, secret);
|
|
3420
|
+
if (!crypto12.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
|
|
3421
|
+
return JSON.parse(payload);
|
|
3422
|
+
} catch {
|
|
3423
|
+
return null;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
async function getValidToken(app, domain, connectionType, clientId, clientSecret) {
|
|
3427
|
+
const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, connectionType))).get();
|
|
3428
|
+
if (!conn) {
|
|
3429
|
+
throw notFound("Google connection", connectionType);
|
|
3430
|
+
}
|
|
3431
|
+
if (!conn.accessToken || !conn.refreshToken) {
|
|
3432
|
+
throw validationError("Google connection is incomplete \u2014 please reconnect");
|
|
3433
|
+
}
|
|
3434
|
+
const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
|
|
3435
|
+
const fiveMinutes = 5 * 60 * 1e3;
|
|
3436
|
+
if (Date.now() > expiresAt - fiveMinutes) {
|
|
3437
|
+
const tokens = await refreshAccessToken(clientId, clientSecret, conn.refreshToken);
|
|
3438
|
+
const newExpiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
3439
|
+
app.db.update(googleConnections).set({
|
|
3440
|
+
accessToken: tokens.access_token,
|
|
3441
|
+
tokenExpiresAt: newExpiresAt,
|
|
3442
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3443
|
+
}).where(eq12(googleConnections.id, conn.id)).run();
|
|
3444
|
+
return { accessToken: tokens.access_token, connectionId: conn.id, propertyId: conn.propertyId };
|
|
3445
|
+
}
|
|
3446
|
+
return { accessToken: conn.accessToken, connectionId: conn.id, propertyId: conn.propertyId };
|
|
3447
|
+
}
|
|
3448
|
+
async function googleRoutes(app, opts) {
|
|
3449
|
+
const { googleClientId, googleClientSecret } = opts;
|
|
3450
|
+
const stateSecret = opts.googleStateSecret ?? "insecure-default-secret";
|
|
3451
|
+
app.get("/projects/:name/google/connections", async (request) => {
|
|
3452
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3453
|
+
const conns = app.db.select({
|
|
3454
|
+
id: googleConnections.id,
|
|
3455
|
+
domain: googleConnections.domain,
|
|
3456
|
+
connectionType: googleConnections.connectionType,
|
|
3457
|
+
propertyId: googleConnections.propertyId,
|
|
3458
|
+
scopes: googleConnections.scopes,
|
|
3459
|
+
createdAt: googleConnections.createdAt,
|
|
3460
|
+
updatedAt: googleConnections.updatedAt
|
|
3461
|
+
}).from(googleConnections).where(eq12(googleConnections.domain, project.canonicalDomain)).all();
|
|
3462
|
+
return conns.map((c) => ({
|
|
3463
|
+
...c,
|
|
3464
|
+
scopes: JSON.parse(c.scopes)
|
|
3465
|
+
}));
|
|
3466
|
+
});
|
|
3467
|
+
app.post("/projects/:name/google/connect", async (request, reply) => {
|
|
3468
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3469
|
+
const err = validationError("Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.");
|
|
3470
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3471
|
+
}
|
|
3472
|
+
const { type, propertyId } = request.body ?? {};
|
|
3473
|
+
if (!type || type !== "gsc" && type !== "ga4") {
|
|
3474
|
+
const err = validationError('type must be "gsc" or "ga4"');
|
|
3475
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3476
|
+
}
|
|
3477
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3478
|
+
const proto = request.headers["x-forwarded-proto"] ?? "http";
|
|
3479
|
+
const host = request.headers.host ?? "localhost:4100";
|
|
3480
|
+
const redirectUri = `${proto}://${host}/api/v1/projects/${encodeURIComponent(request.params.name)}/google/callback`;
|
|
3481
|
+
const scopes = type === "gsc" ? [GSC_SCOPE] : [];
|
|
3482
|
+
const stateEncoded = buildSignedState(
|
|
3483
|
+
{ domain: project.canonicalDomain, type, propertyId, redirectUri },
|
|
3484
|
+
stateSecret
|
|
3485
|
+
);
|
|
3486
|
+
const authUrl = getAuthUrl(googleClientId, redirectUri, scopes, stateEncoded);
|
|
3487
|
+
return { authUrl };
|
|
3488
|
+
});
|
|
3489
|
+
app.get("/projects/:name/google/callback", async (request, reply) => {
|
|
3490
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3491
|
+
return reply.status(500).send("Google OAuth not configured");
|
|
3492
|
+
}
|
|
3493
|
+
const { code, state, error } = request.query;
|
|
3494
|
+
if (error) {
|
|
3495
|
+
const safeError = String(error).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
3496
|
+
return reply.type("text/html").send(`<html><body><h2>Authorization failed</h2><p>${safeError}</p><p>You can close this tab.</p></body></html>`);
|
|
3497
|
+
}
|
|
3498
|
+
if (!code || !state) {
|
|
3499
|
+
return reply.status(400).send("Missing code or state parameter");
|
|
3500
|
+
}
|
|
3501
|
+
const stateData = verifySignedState(state, stateSecret);
|
|
3502
|
+
if (!stateData) {
|
|
3503
|
+
return reply.status(400).send("Invalid or tampered state parameter");
|
|
3504
|
+
}
|
|
3505
|
+
const { domain, type, propertyId, redirectUri } = stateData;
|
|
3506
|
+
const tokens = await exchangeCode(googleClientId, googleClientSecret, code, redirectUri);
|
|
3507
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3508
|
+
const expiresAt = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
3509
|
+
const existing = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, domain), eq12(googleConnections.connectionType, type))).get();
|
|
3510
|
+
if (existing) {
|
|
3511
|
+
app.db.update(googleConnections).set({
|
|
3512
|
+
accessToken: tokens.access_token,
|
|
3513
|
+
refreshToken: tokens.refresh_token ?? existing.refreshToken,
|
|
3514
|
+
tokenExpiresAt: expiresAt,
|
|
3515
|
+
propertyId: propertyId ?? existing.propertyId,
|
|
3516
|
+
scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
|
|
3517
|
+
updatedAt: now
|
|
3518
|
+
}).where(eq12(googleConnections.id, existing.id)).run();
|
|
3519
|
+
} else {
|
|
3520
|
+
app.db.insert(googleConnections).values({
|
|
3521
|
+
id: crypto12.randomUUID(),
|
|
3522
|
+
domain,
|
|
3523
|
+
connectionType: type,
|
|
3524
|
+
propertyId: propertyId ?? null,
|
|
3525
|
+
accessToken: tokens.access_token,
|
|
3526
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
3527
|
+
tokenExpiresAt: expiresAt,
|
|
3528
|
+
scopes: JSON.stringify(tokens.scope?.split(" ") ?? []),
|
|
3529
|
+
createdAt: now,
|
|
3530
|
+
updatedAt: now
|
|
3531
|
+
}).run();
|
|
3532
|
+
}
|
|
3533
|
+
writeAuditLog(app.db, {
|
|
3534
|
+
projectId: null,
|
|
3535
|
+
actor: "oauth",
|
|
3536
|
+
action: "google.connected",
|
|
3537
|
+
entityType: "google_connection",
|
|
3538
|
+
entityId: type,
|
|
3539
|
+
diff: { domain, type, propertyId }
|
|
3540
|
+
});
|
|
3541
|
+
return reply.type("text/html").send(
|
|
3542
|
+
`<html><body style="font-family:system-ui;text-align:center;padding:60px">
|
|
3543
|
+
<h2>Connected successfully!</h2>
|
|
3544
|
+
<p>Google ${type.toUpperCase()} has been linked to your domain.</p>
|
|
3545
|
+
<p style="color:#888">You can close this tab.</p>
|
|
3546
|
+
</body></html>`
|
|
3547
|
+
);
|
|
3548
|
+
});
|
|
3549
|
+
app.delete("/projects/:name/google/connections/:type", async (request, reply) => {
|
|
3550
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3551
|
+
const deleted = app.db.delete(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).run();
|
|
3552
|
+
if (deleted.changes === 0) {
|
|
3553
|
+
const err = notFound("Google connection", request.params.type);
|
|
3554
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3555
|
+
}
|
|
3556
|
+
writeAuditLog(app.db, {
|
|
3557
|
+
projectId: project.id,
|
|
3558
|
+
actor: "api",
|
|
3559
|
+
action: "google.disconnected",
|
|
3560
|
+
entityType: "google_connection",
|
|
3561
|
+
entityId: request.params.type
|
|
3562
|
+
});
|
|
3563
|
+
return reply.status(204).send();
|
|
3564
|
+
});
|
|
3565
|
+
app.get("/projects/:name/google/properties", async (request, reply) => {
|
|
3566
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3567
|
+
const err = validationError("Google OAuth is not configured");
|
|
3568
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3569
|
+
}
|
|
3570
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3571
|
+
const { accessToken } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
3572
|
+
const sites = await listSites(accessToken);
|
|
3573
|
+
return { sites };
|
|
3574
|
+
});
|
|
3575
|
+
app.post("/projects/:name/google/gsc/sync", async (request, reply) => {
|
|
3576
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3577
|
+
const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, "gsc"))).get();
|
|
3578
|
+
if (!conn) {
|
|
3579
|
+
const err = validationError('No GSC connection found for this domain. Run "canonry google connect" first.');
|
|
3580
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3581
|
+
}
|
|
3582
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3583
|
+
const runId = crypto12.randomUUID();
|
|
3584
|
+
app.db.insert(runs).values({
|
|
3585
|
+
id: runId,
|
|
3586
|
+
projectId: project.id,
|
|
3587
|
+
kind: "gsc-sync",
|
|
3588
|
+
status: "queued",
|
|
3589
|
+
trigger: "manual",
|
|
3590
|
+
createdAt: now
|
|
3591
|
+
}).run();
|
|
3592
|
+
const { days, full } = request.body ?? {};
|
|
3593
|
+
if (opts.onGscSyncRequested) {
|
|
3594
|
+
opts.onGscSyncRequested(runId, project.id, { days, full });
|
|
3595
|
+
}
|
|
3596
|
+
const run = app.db.select().from(runs).where(eq12(runs.id, runId)).get();
|
|
3597
|
+
return run;
|
|
3598
|
+
});
|
|
3599
|
+
app.get("/projects/:name/google/gsc/performance", async (request) => {
|
|
3600
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3601
|
+
const { startDate, endDate, query, page, limit } = request.query;
|
|
3602
|
+
const conditions = [eq12(gscSearchData.projectId, project.id)];
|
|
3603
|
+
if (startDate) conditions.push(sql2`${gscSearchData.date} >= ${startDate}`);
|
|
3604
|
+
if (endDate) conditions.push(sql2`${gscSearchData.date} <= ${endDate}`);
|
|
3605
|
+
if (query) conditions.push(sql2`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
|
|
3606
|
+
if (page) conditions.push(sql2`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
|
|
3607
|
+
const rows = app.db.select().from(gscSearchData).where(and4(...conditions)).orderBy(desc2(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
|
|
3608
|
+
return rows.map((r) => ({
|
|
3609
|
+
date: r.date,
|
|
3610
|
+
query: r.query,
|
|
3611
|
+
page: r.page,
|
|
3612
|
+
country: r.country,
|
|
3613
|
+
device: r.device,
|
|
3614
|
+
clicks: r.clicks,
|
|
3615
|
+
impressions: r.impressions,
|
|
3616
|
+
ctr: parseFloat(r.ctr),
|
|
3617
|
+
position: parseFloat(r.position)
|
|
3618
|
+
}));
|
|
3619
|
+
});
|
|
3620
|
+
app.post("/projects/:name/google/gsc/inspect", async (request, reply) => {
|
|
3621
|
+
if (!googleClientId || !googleClientSecret) {
|
|
3622
|
+
const err = validationError("Google OAuth is not configured");
|
|
3623
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3624
|
+
}
|
|
3625
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3626
|
+
const { url } = request.body ?? {};
|
|
3627
|
+
if (!url) {
|
|
3628
|
+
const err = validationError("url is required");
|
|
3629
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3630
|
+
}
|
|
3631
|
+
const { accessToken, propertyId } = await getValidToken(app, project.canonicalDomain, "gsc", googleClientId, googleClientSecret);
|
|
3632
|
+
if (!propertyId) {
|
|
3633
|
+
const err = validationError("No GSC property configured for this connection");
|
|
3634
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3635
|
+
}
|
|
3636
|
+
const result = await inspectUrl(accessToken, url, propertyId);
|
|
3637
|
+
const ir = result.inspectionResult;
|
|
3638
|
+
const idx = ir.indexStatusResult;
|
|
3639
|
+
const mob = ir.mobileUsabilityResult;
|
|
3640
|
+
const rich = ir.richResultsResult;
|
|
3641
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3642
|
+
const id = crypto12.randomUUID();
|
|
3643
|
+
app.db.insert(gscUrlInspections).values({
|
|
3644
|
+
id,
|
|
3645
|
+
projectId: project.id,
|
|
3646
|
+
syncRunId: null,
|
|
3647
|
+
url,
|
|
3648
|
+
indexingState: idx?.indexingState ?? null,
|
|
3649
|
+
verdict: idx?.verdict ?? null,
|
|
3650
|
+
coverageState: idx?.coverageState ?? null,
|
|
3651
|
+
pageFetchState: idx?.pageFetchState ?? null,
|
|
3652
|
+
robotsTxtState: idx?.robotsTxtState ?? null,
|
|
3653
|
+
crawlTime: idx?.lastCrawlTime ?? null,
|
|
3654
|
+
lastCrawlResult: idx?.crawlResult ?? null,
|
|
3655
|
+
isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
|
|
3656
|
+
richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
|
|
3657
|
+
referringUrls: JSON.stringify(idx?.referringUrls ?? []),
|
|
3658
|
+
inspectedAt: now,
|
|
3659
|
+
createdAt: now
|
|
3660
|
+
}).run();
|
|
3661
|
+
return {
|
|
3662
|
+
id,
|
|
3663
|
+
url,
|
|
3664
|
+
indexingState: idx?.indexingState,
|
|
3665
|
+
verdict: idx?.verdict,
|
|
3666
|
+
coverageState: idx?.coverageState,
|
|
3667
|
+
pageFetchState: idx?.pageFetchState,
|
|
3668
|
+
robotsTxtState: idx?.robotsTxtState,
|
|
3669
|
+
crawlTime: idx?.lastCrawlTime,
|
|
3670
|
+
lastCrawlResult: idx?.crawlResult,
|
|
3671
|
+
isMobileFriendly: mob?.verdict === "PASS",
|
|
3672
|
+
richResults: rich?.detectedItems?.map((d) => d.richResultType) ?? [],
|
|
3673
|
+
referringUrls: idx?.referringUrls ?? [],
|
|
3674
|
+
inspectedAt: now
|
|
3675
|
+
};
|
|
3676
|
+
});
|
|
3677
|
+
app.get("/projects/:name/google/gsc/inspections", async (request) => {
|
|
3678
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3679
|
+
const { url, limit } = request.query;
|
|
3680
|
+
const conditions = [eq12(gscUrlInspections.projectId, project.id)];
|
|
3681
|
+
if (url) conditions.push(eq12(gscUrlInspections.url, url));
|
|
3682
|
+
const rows = app.db.select().from(gscUrlInspections).where(and4(...conditions)).orderBy(desc2(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
|
|
3683
|
+
return rows.map((r) => ({
|
|
3684
|
+
id: r.id,
|
|
3685
|
+
url: r.url,
|
|
3686
|
+
indexingState: r.indexingState,
|
|
3687
|
+
verdict: r.verdict,
|
|
3688
|
+
coverageState: r.coverageState,
|
|
3689
|
+
pageFetchState: r.pageFetchState,
|
|
3690
|
+
robotsTxtState: r.robotsTxtState,
|
|
3691
|
+
crawlTime: r.crawlTime,
|
|
3692
|
+
lastCrawlResult: r.lastCrawlResult,
|
|
3693
|
+
isMobileFriendly: r.isMobileFriendly === 1 ? true : r.isMobileFriendly === 0 ? false : null,
|
|
3694
|
+
richResults: JSON.parse(r.richResults),
|
|
3695
|
+
referringUrls: JSON.parse(r.referringUrls),
|
|
3696
|
+
inspectedAt: r.inspectedAt
|
|
3697
|
+
}));
|
|
3698
|
+
});
|
|
3699
|
+
app.get("/projects/:name/google/gsc/deindexed", async (request) => {
|
|
3700
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3701
|
+
const allInspections = app.db.select().from(gscUrlInspections).where(eq12(gscUrlInspections.projectId, project.id)).orderBy(desc2(gscUrlInspections.inspectedAt)).all();
|
|
3702
|
+
const byUrl = /* @__PURE__ */ new Map();
|
|
3703
|
+
for (const row of allInspections) {
|
|
3704
|
+
const existing = byUrl.get(row.url);
|
|
3705
|
+
if (existing) {
|
|
3706
|
+
existing.push(row);
|
|
3707
|
+
} else {
|
|
3708
|
+
byUrl.set(row.url, [row]);
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
const deindexed = [];
|
|
3712
|
+
for (const [url, inspections] of byUrl) {
|
|
3713
|
+
if (inspections.length < 2) continue;
|
|
3714
|
+
const latest = inspections[0];
|
|
3715
|
+
const previous = inspections[1];
|
|
3716
|
+
if (previous.indexingState?.toUpperCase() === "INDEXED" && latest.indexingState?.toUpperCase() !== "INDEXED") {
|
|
3717
|
+
deindexed.push({
|
|
3718
|
+
url,
|
|
3719
|
+
previousState: previous.indexingState,
|
|
3720
|
+
currentState: latest.indexingState,
|
|
3721
|
+
transitionDate: latest.inspectedAt
|
|
3722
|
+
});
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
return deindexed;
|
|
3726
|
+
});
|
|
3727
|
+
app.put("/projects/:name/google/connections/:type/property", async (request, reply) => {
|
|
3728
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3729
|
+
const { propertyId } = request.body ?? {};
|
|
3730
|
+
if (!propertyId) {
|
|
3731
|
+
const err = validationError("propertyId is required");
|
|
3732
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3733
|
+
}
|
|
3734
|
+
const conn = app.db.select().from(googleConnections).where(and4(eq12(googleConnections.domain, project.canonicalDomain), eq12(googleConnections.connectionType, request.params.type))).get();
|
|
3735
|
+
if (!conn) {
|
|
3736
|
+
const err = notFound("Google connection", request.params.type);
|
|
3737
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
3738
|
+
}
|
|
3739
|
+
app.db.update(googleConnections).set({ propertyId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq12(googleConnections.id, conn.id)).run();
|
|
3740
|
+
return { propertyId };
|
|
3741
|
+
});
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3078
3744
|
// ../api-routes/src/index.ts
|
|
3079
3745
|
async function apiRoutes(app, opts) {
|
|
3080
3746
|
app.decorate("db", opts.db);
|
|
@@ -3107,6 +3773,12 @@ async function apiRoutes(app, opts) {
|
|
|
3107
3773
|
getTelemetryStatus: opts.getTelemetryStatus,
|
|
3108
3774
|
setTelemetryEnabled: opts.setTelemetryEnabled
|
|
3109
3775
|
});
|
|
3776
|
+
await api.register(googleRoutes, {
|
|
3777
|
+
googleClientId: opts.googleClientId,
|
|
3778
|
+
googleClientSecret: opts.googleClientSecret,
|
|
3779
|
+
googleStateSecret: opts.googleStateSecret,
|
|
3780
|
+
onGscSyncRequested: opts.onGscSyncRequested
|
|
3781
|
+
});
|
|
3110
3782
|
}, { prefix: "/api/v1" });
|
|
3111
3783
|
}
|
|
3112
3784
|
|
|
@@ -4027,8 +4699,8 @@ var localAdapter = {
|
|
|
4027
4699
|
};
|
|
4028
4700
|
|
|
4029
4701
|
// src/job-runner.ts
|
|
4030
|
-
import
|
|
4031
|
-
import { eq as
|
|
4702
|
+
import crypto13 from "crypto";
|
|
4703
|
+
import { eq as eq13, inArray as inArray2 } from "drizzle-orm";
|
|
4032
4704
|
var JobRunner = class {
|
|
4033
4705
|
db;
|
|
4034
4706
|
registry;
|
|
@@ -4042,7 +4714,7 @@ var JobRunner = class {
|
|
|
4042
4714
|
if (stale.length === 0) return;
|
|
4043
4715
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4044
4716
|
for (const run of stale) {
|
|
4045
|
-
this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(
|
|
4717
|
+
this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq13(runs.id, run.id)).run();
|
|
4046
4718
|
console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
|
|
4047
4719
|
}
|
|
4048
4720
|
}
|
|
@@ -4050,8 +4722,8 @@ var JobRunner = class {
|
|
|
4050
4722
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4051
4723
|
const startTime = Date.now();
|
|
4052
4724
|
try {
|
|
4053
|
-
this.db.update(runs).set({ status: "running", startedAt: now }).where(
|
|
4054
|
-
const project = this.db.select().from(projects).where(
|
|
4725
|
+
this.db.update(runs).set({ status: "running", startedAt: now }).where(eq13(runs.id, runId)).run();
|
|
4726
|
+
const project = this.db.select().from(projects).where(eq13(projects.id, projectId)).get();
|
|
4055
4727
|
if (!project) {
|
|
4056
4728
|
throw new Error(`Project ${projectId} not found`);
|
|
4057
4729
|
}
|
|
@@ -4061,14 +4733,14 @@ var JobRunner = class {
|
|
|
4061
4733
|
throw new Error("No providers configured. Add at least one provider API key.");
|
|
4062
4734
|
}
|
|
4063
4735
|
console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map((p) => p.adapter.name).join(", ")}`);
|
|
4064
|
-
const projectKeywords = this.db.select().from(keywords).where(
|
|
4065
|
-
const projectCompetitors = this.db.select().from(competitors).where(
|
|
4736
|
+
const projectKeywords = this.db.select().from(keywords).where(eq13(keywords.projectId, projectId)).all();
|
|
4737
|
+
const projectCompetitors = this.db.select().from(competitors).where(eq13(competitors.projectId, projectId)).all();
|
|
4066
4738
|
const competitorDomains = projectCompetitors.map((c) => c.domain);
|
|
4067
4739
|
const queriesPerProvider = projectKeywords.length;
|
|
4068
4740
|
const todayPeriod = getCurrentPeriod();
|
|
4069
4741
|
for (const p of activeProviders) {
|
|
4070
4742
|
const providerScope = `${projectId}:${p.adapter.name}`;
|
|
4071
|
-
const providerUsage = this.db.select().from(usageCounters).where(
|
|
4743
|
+
const providerUsage = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, providerScope)).all().filter((r) => r.period === todayPeriod && r.metric === "queries").reduce((sum, r) => sum + r.count, 0);
|
|
4072
4744
|
const limit = p.config.quotaPolicy.maxRequestsPerDay;
|
|
4073
4745
|
if (providerUsage + queriesPerProvider > limit) {
|
|
4074
4746
|
throw new Error(
|
|
@@ -4108,7 +4780,7 @@ var JobRunner = class {
|
|
|
4108
4780
|
const citationState = determineCitationState(normalized, allDomains);
|
|
4109
4781
|
const overlap = computeCompetitorOverlap(normalized, competitorDomains);
|
|
4110
4782
|
this.db.insert(querySnapshots).values({
|
|
4111
|
-
id:
|
|
4783
|
+
id: crypto13.randomUUID(),
|
|
4112
4784
|
runId,
|
|
4113
4785
|
keywordId: kw.id,
|
|
4114
4786
|
provider: providerName,
|
|
@@ -4139,12 +4811,12 @@ var JobRunner = class {
|
|
|
4139
4811
|
const someFailed = providerErrors.size > 0;
|
|
4140
4812
|
if (allFailed) {
|
|
4141
4813
|
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
|
|
4142
|
-
this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(
|
|
4814
|
+
this.db.update(runs).set({ status: "failed", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq13(runs.id, runId)).run();
|
|
4143
4815
|
} else if (someFailed) {
|
|
4144
4816
|
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors));
|
|
4145
|
-
this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(
|
|
4817
|
+
this.db.update(runs).set({ status: "partial", finishedAt: (/* @__PURE__ */ new Date()).toISOString(), error: errorDetail }).where(eq13(runs.id, runId)).run();
|
|
4146
4818
|
} else {
|
|
4147
|
-
this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(
|
|
4819
|
+
this.db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq13(runs.id, runId)).run();
|
|
4148
4820
|
}
|
|
4149
4821
|
const finalStatus = allFailed ? "failed" : someFailed ? "partial" : "completed";
|
|
4150
4822
|
trackEvent("run.completed", {
|
|
@@ -4169,7 +4841,7 @@ var JobRunner = class {
|
|
|
4169
4841
|
status: "failed",
|
|
4170
4842
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4171
4843
|
error: errorMessage
|
|
4172
|
-
}).where(
|
|
4844
|
+
}).where(eq13(runs.id, runId)).run();
|
|
4173
4845
|
trackEvent("run.completed", {
|
|
4174
4846
|
status: "failed",
|
|
4175
4847
|
providerCount: 0,
|
|
@@ -4205,10 +4877,10 @@ var JobRunner = class {
|
|
|
4205
4877
|
incrementUsage(scope, metric, count) {
|
|
4206
4878
|
const now = /* @__PURE__ */ new Date();
|
|
4207
4879
|
const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
4208
|
-
const id =
|
|
4209
|
-
const existing = this.db.select().from(usageCounters).where(
|
|
4880
|
+
const id = crypto13.randomUUID();
|
|
4881
|
+
const existing = this.db.select().from(usageCounters).where(eq13(usageCounters.scope, scope)).all().find((r) => r.period === period && r.metric === metric);
|
|
4210
4882
|
if (existing) {
|
|
4211
|
-
this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(
|
|
4883
|
+
this.db.update(usageCounters).set({ count: existing.count + count, updatedAt: now.toISOString() }).where(eq13(usageCounters.id, existing.id)).run();
|
|
4212
4884
|
} else {
|
|
4213
4885
|
this.db.insert(usageCounters).values({
|
|
4214
4886
|
id,
|
|
@@ -4287,6 +4959,132 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
|
|
|
4287
4959
|
return [...overlapSet];
|
|
4288
4960
|
}
|
|
4289
4961
|
|
|
4962
|
+
// src/gsc-sync.ts
|
|
4963
|
+
import crypto14 from "crypto";
|
|
4964
|
+
import { eq as eq14, and as and5, sql as sql3 } from "drizzle-orm";
|
|
4965
|
+
function formatDate(d) {
|
|
4966
|
+
return d.toISOString().split("T")[0];
|
|
4967
|
+
}
|
|
4968
|
+
function daysAgo(n) {
|
|
4969
|
+
const d = /* @__PURE__ */ new Date();
|
|
4970
|
+
d.setDate(d.getDate() - n);
|
|
4971
|
+
return d;
|
|
4972
|
+
}
|
|
4973
|
+
async function executeGscSync(db, runId, projectId, opts) {
|
|
4974
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4975
|
+
db.update(runs).set({ status: "running", startedAt: now }).where(eq14(runs.id, runId)).run();
|
|
4976
|
+
try {
|
|
4977
|
+
const project = db.select().from(projects).where(eq14(projects.id, projectId)).get();
|
|
4978
|
+
if (!project) {
|
|
4979
|
+
throw new Error(`Project not found: ${projectId}`);
|
|
4980
|
+
}
|
|
4981
|
+
const conn = db.select().from(googleConnections).where(and5(eq14(googleConnections.domain, project.canonicalDomain), eq14(googleConnections.connectionType, "gsc"))).get();
|
|
4982
|
+
if (!conn || !conn.refreshToken) {
|
|
4983
|
+
throw new Error("No GSC connection found or connection is incomplete");
|
|
4984
|
+
}
|
|
4985
|
+
if (!conn.propertyId) {
|
|
4986
|
+
throw new Error('No GSC property selected. Use "canonry google properties" to list available sites, then set one with the API.');
|
|
4987
|
+
}
|
|
4988
|
+
let accessToken = conn.accessToken;
|
|
4989
|
+
const expiresAt = conn.tokenExpiresAt ? new Date(conn.tokenExpiresAt).getTime() : 0;
|
|
4990
|
+
if (Date.now() > expiresAt - 5 * 60 * 1e3) {
|
|
4991
|
+
const tokens = await refreshAccessToken(opts.googleClientId, opts.googleClientSecret, conn.refreshToken);
|
|
4992
|
+
accessToken = tokens.access_token;
|
|
4993
|
+
db.update(googleConnections).set({
|
|
4994
|
+
accessToken: tokens.access_token,
|
|
4995
|
+
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
|
|
4996
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4997
|
+
}).where(eq14(googleConnections.id, conn.id)).run();
|
|
4998
|
+
}
|
|
4999
|
+
const lagOffset = GSC_DATA_LAG_DAYS;
|
|
5000
|
+
const endDate = formatDate(daysAgo(lagOffset));
|
|
5001
|
+
const days = opts.full ? 480 : opts.days ?? 30;
|
|
5002
|
+
const startDate = formatDate(daysAgo(days + lagOffset));
|
|
5003
|
+
console.log(`[GSC Sync] Fetching search analytics for ${conn.propertyId} from ${startDate} to ${endDate}`);
|
|
5004
|
+
const rows = await fetchSearchAnalytics(accessToken, conn.propertyId, {
|
|
5005
|
+
startDate,
|
|
5006
|
+
endDate
|
|
5007
|
+
});
|
|
5008
|
+
console.log(`[GSC Sync] Received ${rows.length} rows`);
|
|
5009
|
+
db.delete(gscSearchData).where(
|
|
5010
|
+
and5(
|
|
5011
|
+
eq14(gscSearchData.projectId, projectId),
|
|
5012
|
+
sql3`${gscSearchData.date} >= ${startDate}`,
|
|
5013
|
+
sql3`${gscSearchData.date} <= ${endDate}`
|
|
5014
|
+
)
|
|
5015
|
+
).run();
|
|
5016
|
+
const batchSize = 500;
|
|
5017
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
5018
|
+
const batch = rows.slice(i, i + batchSize);
|
|
5019
|
+
const insertNow = (/* @__PURE__ */ new Date()).toISOString();
|
|
5020
|
+
for (const row of batch) {
|
|
5021
|
+
const [query, page, country, device, date] = row.keys;
|
|
5022
|
+
db.insert(gscSearchData).values({
|
|
5023
|
+
id: crypto14.randomUUID(),
|
|
5024
|
+
projectId,
|
|
5025
|
+
syncRunId: runId,
|
|
5026
|
+
date: date ?? "",
|
|
5027
|
+
query: query ?? "",
|
|
5028
|
+
page: page ?? "",
|
|
5029
|
+
country: country ?? null,
|
|
5030
|
+
device: device ?? null,
|
|
5031
|
+
clicks: row.clicks,
|
|
5032
|
+
impressions: row.impressions,
|
|
5033
|
+
ctr: String(row.ctr),
|
|
5034
|
+
position: String(row.position),
|
|
5035
|
+
createdAt: insertNow
|
|
5036
|
+
}).run();
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
5039
|
+
const pageClicks = /* @__PURE__ */ new Map();
|
|
5040
|
+
for (const row of rows) {
|
|
5041
|
+
const page = row.keys[1];
|
|
5042
|
+
if (page) {
|
|
5043
|
+
pageClicks.set(page, (pageClicks.get(page) ?? 0) + row.clicks);
|
|
5044
|
+
}
|
|
5045
|
+
}
|
|
5046
|
+
const topPages = [...pageClicks.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50).map(([page]) => page);
|
|
5047
|
+
console.log(`[GSC Sync] Inspecting ${topPages.length} URLs`);
|
|
5048
|
+
for (const pageUrl of topPages) {
|
|
5049
|
+
try {
|
|
5050
|
+
const result = await inspectUrl(accessToken, pageUrl, conn.propertyId);
|
|
5051
|
+
const ir = result.inspectionResult;
|
|
5052
|
+
const idx = ir.indexStatusResult;
|
|
5053
|
+
const mob = ir.mobileUsabilityResult;
|
|
5054
|
+
const rich = ir.richResultsResult;
|
|
5055
|
+
const inspectedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5056
|
+
db.insert(gscUrlInspections).values({
|
|
5057
|
+
id: crypto14.randomUUID(),
|
|
5058
|
+
projectId,
|
|
5059
|
+
syncRunId: runId,
|
|
5060
|
+
url: pageUrl,
|
|
5061
|
+
indexingState: idx?.indexingState ?? null,
|
|
5062
|
+
verdict: idx?.verdict ?? null,
|
|
5063
|
+
coverageState: idx?.coverageState ?? null,
|
|
5064
|
+
pageFetchState: idx?.pageFetchState ?? null,
|
|
5065
|
+
robotsTxtState: idx?.robotsTxtState ?? null,
|
|
5066
|
+
crawlTime: idx?.lastCrawlTime ?? null,
|
|
5067
|
+
lastCrawlResult: idx?.crawlResult ?? null,
|
|
5068
|
+
isMobileFriendly: mob?.verdict === "PASS" ? 1 : mob?.verdict === "FAIL" ? 0 : null,
|
|
5069
|
+
richResults: JSON.stringify(rich?.detectedItems?.map((d) => d.richResultType) ?? []),
|
|
5070
|
+
referringUrls: JSON.stringify(idx?.referringUrls ?? []),
|
|
5071
|
+
inspectedAt,
|
|
5072
|
+
createdAt: inspectedAt
|
|
5073
|
+
}).run();
|
|
5074
|
+
} catch (err) {
|
|
5075
|
+
console.error(`[GSC Sync] Failed to inspect ${pageUrl}:`, err instanceof Error ? err.message : err);
|
|
5076
|
+
}
|
|
5077
|
+
}
|
|
5078
|
+
db.update(runs).set({ status: "completed", finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
|
|
5079
|
+
console.log(`[GSC Sync] Completed. ${rows.length} search data rows, ${topPages.length} URL inspections.`);
|
|
5080
|
+
} catch (err) {
|
|
5081
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
5082
|
+
db.update(runs).set({ status: "failed", error: errorMsg, finishedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq14(runs.id, runId)).run();
|
|
5083
|
+
console.error(`[GSC Sync] Failed:`, errorMsg);
|
|
5084
|
+
throw err;
|
|
5085
|
+
}
|
|
5086
|
+
}
|
|
5087
|
+
|
|
4290
5088
|
// src/provider-registry.ts
|
|
4291
5089
|
var ProviderRegistry = class {
|
|
4292
5090
|
providers = /* @__PURE__ */ new Map();
|
|
@@ -4332,7 +5130,7 @@ var ProviderRegistry = class {
|
|
|
4332
5130
|
|
|
4333
5131
|
// src/scheduler.ts
|
|
4334
5132
|
import cron from "node-cron";
|
|
4335
|
-
import { eq as
|
|
5133
|
+
import { eq as eq15 } from "drizzle-orm";
|
|
4336
5134
|
var Scheduler = class {
|
|
4337
5135
|
db;
|
|
4338
5136
|
callbacks;
|
|
@@ -4343,7 +5141,7 @@ var Scheduler = class {
|
|
|
4343
5141
|
}
|
|
4344
5142
|
/** Load all enabled schedules from DB and register cron jobs. */
|
|
4345
5143
|
start() {
|
|
4346
|
-
const allSchedules = this.db.select().from(schedules).where(
|
|
5144
|
+
const allSchedules = this.db.select().from(schedules).where(eq15(schedules.enabled, 1)).all();
|
|
4347
5145
|
for (const schedule of allSchedules) {
|
|
4348
5146
|
const missedRunAt = schedule.nextRunAt;
|
|
4349
5147
|
this.registerCronTask(schedule);
|
|
@@ -4369,7 +5167,7 @@ var Scheduler = class {
|
|
|
4369
5167
|
existing.stop();
|
|
4370
5168
|
this.tasks.delete(projectId);
|
|
4371
5169
|
}
|
|
4372
|
-
const schedule = this.db.select().from(schedules).where(
|
|
5170
|
+
const schedule = this.db.select().from(schedules).where(eq15(schedules.projectId, projectId)).get();
|
|
4373
5171
|
if (schedule && schedule.enabled === 1) {
|
|
4374
5172
|
this.registerCronTask(schedule);
|
|
4375
5173
|
}
|
|
@@ -4398,13 +5196,13 @@ var Scheduler = class {
|
|
|
4398
5196
|
this.db.update(schedules).set({
|
|
4399
5197
|
nextRunAt: task.getNextRun()?.toISOString() ?? null,
|
|
4400
5198
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4401
|
-
}).where(
|
|
5199
|
+
}).where(eq15(schedules.id, scheduleId)).run();
|
|
4402
5200
|
const label = schedule.preset ?? cronExpr;
|
|
4403
5201
|
console.log(`[Scheduler] Registered "${label}" (${timezone}) for project ${projectId}`);
|
|
4404
5202
|
}
|
|
4405
5203
|
triggerRun(scheduleId, projectId) {
|
|
4406
5204
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4407
|
-
const currentSchedule = this.db.select().from(schedules).where(
|
|
5205
|
+
const currentSchedule = this.db.select().from(schedules).where(eq15(schedules.id, scheduleId)).get();
|
|
4408
5206
|
if (!currentSchedule || currentSchedule.enabled !== 1) {
|
|
4409
5207
|
console.log(`[Scheduler] Schedule ${scheduleId} no longer exists or is disabled, removing task for project ${projectId}`);
|
|
4410
5208
|
this.remove(projectId);
|
|
@@ -4412,7 +5210,7 @@ var Scheduler = class {
|
|
|
4412
5210
|
}
|
|
4413
5211
|
const task = this.tasks.get(projectId);
|
|
4414
5212
|
const nextRunAt = task?.getNextRun()?.toISOString() ?? null;
|
|
4415
|
-
const project = this.db.select().from(projects).where(
|
|
5213
|
+
const project = this.db.select().from(projects).where(eq15(projects.id, projectId)).get();
|
|
4416
5214
|
if (!project) {
|
|
4417
5215
|
console.error(`[Scheduler] Project ${projectId} not found, skipping scheduled run`);
|
|
4418
5216
|
this.remove(projectId);
|
|
@@ -4429,7 +5227,7 @@ var Scheduler = class {
|
|
|
4429
5227
|
this.db.update(schedules).set({
|
|
4430
5228
|
nextRunAt,
|
|
4431
5229
|
updatedAt: now
|
|
4432
|
-
}).where(
|
|
5230
|
+
}).where(eq15(schedules.id, currentSchedule.id)).run();
|
|
4433
5231
|
return;
|
|
4434
5232
|
}
|
|
4435
5233
|
const runId = queueResult.runId;
|
|
@@ -4437,7 +5235,7 @@ var Scheduler = class {
|
|
|
4437
5235
|
lastRunAt: now,
|
|
4438
5236
|
nextRunAt,
|
|
4439
5237
|
updatedAt: now
|
|
4440
|
-
}).where(
|
|
5238
|
+
}).where(eq15(schedules.id, currentSchedule.id)).run();
|
|
4441
5239
|
const scheduleProviders = JSON.parse(currentSchedule.providers);
|
|
4442
5240
|
const providers = scheduleProviders.length > 0 ? scheduleProviders : void 0;
|
|
4443
5241
|
console.log(`[Scheduler] Triggered scheduled run ${runId} for project ${project.name}`);
|
|
@@ -4446,8 +5244,8 @@ var Scheduler = class {
|
|
|
4446
5244
|
};
|
|
4447
5245
|
|
|
4448
5246
|
// src/notifier.ts
|
|
4449
|
-
import { eq as
|
|
4450
|
-
import
|
|
5247
|
+
import { eq as eq16, desc as desc3, and as and6, or as or2 } from "drizzle-orm";
|
|
5248
|
+
import crypto15 from "crypto";
|
|
4451
5249
|
var Notifier = class {
|
|
4452
5250
|
db;
|
|
4453
5251
|
serverUrl;
|
|
@@ -4458,18 +5256,18 @@ var Notifier = class {
|
|
|
4458
5256
|
/** Called after a run completes (success, partial, or failed). */
|
|
4459
5257
|
async onRunCompleted(runId, projectId) {
|
|
4460
5258
|
console.log(`[Notifier] onRunCompleted: runId=${runId} projectId=${projectId}`);
|
|
4461
|
-
const notifs = this.db.select().from(notifications).where(
|
|
5259
|
+
const notifs = this.db.select().from(notifications).where(eq16(notifications.projectId, projectId)).all().filter((n) => n.enabled === 1);
|
|
4462
5260
|
if (notifs.length === 0) {
|
|
4463
5261
|
console.log(`[Notifier] No enabled notifications for project ${projectId} \u2014 skipping`);
|
|
4464
5262
|
return;
|
|
4465
5263
|
}
|
|
4466
5264
|
console.log(`[Notifier] Found ${notifs.length} enabled notification(s) for project ${projectId}`);
|
|
4467
|
-
const run = this.db.select().from(runs).where(
|
|
5265
|
+
const run = this.db.select().from(runs).where(eq16(runs.id, runId)).get();
|
|
4468
5266
|
if (!run) {
|
|
4469
5267
|
console.error(`[Notifier] Run ${runId} not found \u2014 skipping notification dispatch`);
|
|
4470
5268
|
return;
|
|
4471
5269
|
}
|
|
4472
|
-
const project = this.db.select().from(projects).where(
|
|
5270
|
+
const project = this.db.select().from(projects).where(eq16(projects.id, projectId)).get();
|
|
4473
5271
|
if (!project) {
|
|
4474
5272
|
console.error(`[Notifier] Project ${projectId} not found \u2014 skipping notification dispatch`);
|
|
4475
5273
|
return;
|
|
@@ -4509,11 +5307,11 @@ var Notifier = class {
|
|
|
4509
5307
|
}
|
|
4510
5308
|
computeTransitions(runId, projectId) {
|
|
4511
5309
|
const recentRuns = this.db.select().from(runs).where(
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
or2(
|
|
5310
|
+
and6(
|
|
5311
|
+
eq16(runs.projectId, projectId),
|
|
5312
|
+
or2(eq16(runs.status, "completed"), eq16(runs.status, "partial"))
|
|
4515
5313
|
)
|
|
4516
|
-
).orderBy(
|
|
5314
|
+
).orderBy(desc3(runs.createdAt)).limit(2).all();
|
|
4517
5315
|
if (recentRuns.length < 2) return [];
|
|
4518
5316
|
const currentRunId = recentRuns[0].id;
|
|
4519
5317
|
const previousRunId = recentRuns[1].id;
|
|
@@ -4523,12 +5321,12 @@ var Notifier = class {
|
|
|
4523
5321
|
keyword: keywords.keyword,
|
|
4524
5322
|
provider: querySnapshots.provider,
|
|
4525
5323
|
citationState: querySnapshots.citationState
|
|
4526
|
-
}).from(querySnapshots).leftJoin(keywords,
|
|
5324
|
+
}).from(querySnapshots).leftJoin(keywords, eq16(querySnapshots.keywordId, keywords.id)).where(eq16(querySnapshots.runId, currentRunId)).all();
|
|
4527
5325
|
const previousSnapshots = this.db.select({
|
|
4528
5326
|
keywordId: querySnapshots.keywordId,
|
|
4529
5327
|
provider: querySnapshots.provider,
|
|
4530
5328
|
citationState: querySnapshots.citationState
|
|
4531
|
-
}).from(querySnapshots).where(
|
|
5329
|
+
}).from(querySnapshots).where(eq16(querySnapshots.runId, previousRunId)).all();
|
|
4532
5330
|
const prevMap = /* @__PURE__ */ new Map();
|
|
4533
5331
|
for (const s of previousSnapshots) {
|
|
4534
5332
|
prevMap.set(`${s.keywordId}:${s.provider}`, s.citationState);
|
|
@@ -4585,7 +5383,7 @@ var Notifier = class {
|
|
|
4585
5383
|
}
|
|
4586
5384
|
logDelivery(projectId, notificationId, event, status, error) {
|
|
4587
5385
|
this.db.insert(auditLog).values({
|
|
4588
|
-
id:
|
|
5386
|
+
id: crypto15.randomUUID(),
|
|
4589
5387
|
projectId,
|
|
4590
5388
|
actor: "scheduler",
|
|
4591
5389
|
action: `notification.${status}`,
|
|
@@ -4796,9 +5594,28 @@ async function createServer(opts) {
|
|
|
4796
5594
|
quota: registry.get(name)?.config.quotaPolicy
|
|
4797
5595
|
}));
|
|
4798
5596
|
const adapterMap = { gemini: geminiAdapter, openai: openaiAdapter, claude: claudeAdapter, local: localAdapter };
|
|
5597
|
+
const googleClientId = process.env.GOOGLE_CLIENT_ID;
|
|
5598
|
+
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET;
|
|
5599
|
+
const googleStateSecret = process.env.GOOGLE_STATE_SECRET ?? crypto16.randomBytes(32).toString("hex");
|
|
4799
5600
|
await app.register(apiRoutes, {
|
|
4800
5601
|
db: opts.db,
|
|
4801
5602
|
skipAuth: false,
|
|
5603
|
+
googleClientId,
|
|
5604
|
+
googleClientSecret,
|
|
5605
|
+
googleStateSecret,
|
|
5606
|
+
onGscSyncRequested: (runId, projectId, syncOpts) => {
|
|
5607
|
+
if (!googleClientId || !googleClientSecret) {
|
|
5608
|
+
app.log.error("GSC sync requested but GOOGLE_CLIENT_ID/SECRET not configured");
|
|
5609
|
+
return;
|
|
5610
|
+
}
|
|
5611
|
+
executeGscSync(opts.db, runId, projectId, {
|
|
5612
|
+
...syncOpts,
|
|
5613
|
+
googleClientId,
|
|
5614
|
+
googleClientSecret
|
|
5615
|
+
}).catch((err) => {
|
|
5616
|
+
app.log.error({ runId, err }, "GSC sync failed");
|
|
5617
|
+
});
|
|
5618
|
+
},
|
|
4802
5619
|
openApiInfo: {
|
|
4803
5620
|
title: "Canonry API",
|
|
4804
5621
|
version: PKG_VERSION
|