@ainyc/canonry 1.29.0 → 1.31.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-DmFB_uXa.js +246 -0
- package/assets/assets/{index-DwPC0zVy.css → index-r6biprHB.css} +1 -1
- package/assets/index.html +2 -2
- package/dist/{chunk-IWUQVYU3.js → chunk-YW4IZ34Z.js} +1362 -565
- package/dist/cli.js +379 -16
- package/dist/index.js +1 -1
- package/package.json +9 -9
- package/assets/assets/index-D1pCtUfW.js +0 -246
|
@@ -494,6 +494,9 @@ function unsupportedKind(kind) {
|
|
|
494
494
|
function notImplemented(message) {
|
|
495
495
|
return new AppError("NOT_IMPLEMENTED", message, 501);
|
|
496
496
|
}
|
|
497
|
+
function deliveryFailed(message) {
|
|
498
|
+
return new AppError("DELIVERY_FAILED", message, 502);
|
|
499
|
+
}
|
|
497
500
|
|
|
498
501
|
// ../contracts/src/google.ts
|
|
499
502
|
import { z as z5 } from "zod";
|
|
@@ -716,6 +719,49 @@ var wordpressAuditPageDtoSchema = z7.object({
|
|
|
716
719
|
schemaPresent: z7.boolean(),
|
|
717
720
|
issues: z7.array(wordpressAuditIssueDtoSchema).default([])
|
|
718
721
|
});
|
|
722
|
+
var wordpressBulkMetaEntryResultDtoSchema = z7.object({
|
|
723
|
+
slug: z7.string(),
|
|
724
|
+
status: z7.enum(["applied", "skipped", "manual"]),
|
|
725
|
+
error: z7.string().optional(),
|
|
726
|
+
manualAssist: wordpressManualAssistDtoSchema.optional()
|
|
727
|
+
});
|
|
728
|
+
var wordpressBulkMetaResultDtoSchema = z7.object({
|
|
729
|
+
env: wordpressEnvSchema,
|
|
730
|
+
strategy: z7.enum(["plugin", "manual"]),
|
|
731
|
+
results: z7.array(wordpressBulkMetaEntryResultDtoSchema)
|
|
732
|
+
});
|
|
733
|
+
var wordpressSchemaDeployEntryResultDtoSchema = z7.object({
|
|
734
|
+
slug: z7.string(),
|
|
735
|
+
status: z7.enum(["deployed", "stripped", "skipped", "failed"]),
|
|
736
|
+
schemasInjected: z7.array(z7.string()).optional(),
|
|
737
|
+
manualAssist: wordpressManualAssistDtoSchema.optional(),
|
|
738
|
+
error: z7.string().optional()
|
|
739
|
+
});
|
|
740
|
+
var wordpressSchemaDeployResultDtoSchema = z7.object({
|
|
741
|
+
env: wordpressEnvSchema,
|
|
742
|
+
results: z7.array(wordpressSchemaDeployEntryResultDtoSchema)
|
|
743
|
+
});
|
|
744
|
+
var wordpressSchemaStatusPageDtoSchema = z7.object({
|
|
745
|
+
slug: z7.string(),
|
|
746
|
+
title: z7.string(),
|
|
747
|
+
canonrySchemas: z7.array(z7.string()),
|
|
748
|
+
thirdPartySchemas: z7.array(z7.string()),
|
|
749
|
+
hasCanonrySchema: z7.boolean()
|
|
750
|
+
});
|
|
751
|
+
var wordpressSchemaStatusResultDtoSchema = z7.object({
|
|
752
|
+
env: wordpressEnvSchema,
|
|
753
|
+
pages: z7.array(wordpressSchemaStatusPageDtoSchema)
|
|
754
|
+
});
|
|
755
|
+
var wordpressOnboardStepDtoSchema = z7.object({
|
|
756
|
+
name: z7.string(),
|
|
757
|
+
status: z7.enum(["completed", "skipped", "failed"]),
|
|
758
|
+
summary: z7.string().optional(),
|
|
759
|
+
error: z7.string().optional()
|
|
760
|
+
});
|
|
761
|
+
var wordpressOnboardResultDtoSchema = z7.object({
|
|
762
|
+
projectName: z7.string(),
|
|
763
|
+
steps: z7.array(wordpressOnboardStepDtoSchema)
|
|
764
|
+
});
|
|
719
765
|
var wordpressDiffDtoSchema = z7.object({
|
|
720
766
|
slug: z7.string(),
|
|
721
767
|
live: wordpressDiffPageDtoSchema,
|
|
@@ -766,6 +812,7 @@ var querySnapshotDtoSchema = z8.object({
|
|
|
766
812
|
answerText: z8.string().nullable().optional(),
|
|
767
813
|
citedDomains: z8.array(z8.string()).default([]),
|
|
768
814
|
competitorOverlap: z8.array(z8.string()).default([]),
|
|
815
|
+
recommendedCompetitors: z8.array(z8.string()).default([]),
|
|
769
816
|
groundingSources: z8.array(groundingSourceSchema).default([]),
|
|
770
817
|
searchQueries: z8.array(z8.string()).default([]),
|
|
771
818
|
model: z8.string().nullable().optional(),
|
|
@@ -1004,6 +1051,12 @@ var ga4TrafficSnapshotDtoSchema = z11.object({
|
|
|
1004
1051
|
organicSessions: z11.number(),
|
|
1005
1052
|
users: z11.number()
|
|
1006
1053
|
});
|
|
1054
|
+
var ga4AiReferralDtoSchema = z11.object({
|
|
1055
|
+
source: z11.string(),
|
|
1056
|
+
medium: z11.string(),
|
|
1057
|
+
sessions: z11.number(),
|
|
1058
|
+
users: z11.number()
|
|
1059
|
+
});
|
|
1007
1060
|
var ga4TrafficSummaryDtoSchema = z11.object({
|
|
1008
1061
|
totalSessions: z11.number(),
|
|
1009
1062
|
totalOrganicSessions: z11.number(),
|
|
@@ -1014,6 +1067,7 @@ var ga4TrafficSummaryDtoSchema = z11.object({
|
|
|
1014
1067
|
organicSessions: z11.number(),
|
|
1015
1068
|
users: z11.number()
|
|
1016
1069
|
})),
|
|
1070
|
+
aiReferrals: z11.array(ga4AiReferralDtoSchema),
|
|
1017
1071
|
lastSyncedAt: z11.string().nullable()
|
|
1018
1072
|
});
|
|
1019
1073
|
|
|
@@ -1036,6 +1090,7 @@ __export(schema_exports, {
|
|
|
1036
1090
|
bingKeywordStats: () => bingKeywordStats,
|
|
1037
1091
|
bingUrlInspections: () => bingUrlInspections,
|
|
1038
1092
|
competitors: () => competitors,
|
|
1093
|
+
gaAiReferrals: () => gaAiReferrals,
|
|
1039
1094
|
gaConnections: () => gaConnections,
|
|
1040
1095
|
gaTrafficSnapshots: () => gaTrafficSnapshots,
|
|
1041
1096
|
gaTrafficSummaries: () => gaTrafficSummaries,
|
|
@@ -1113,6 +1168,7 @@ var querySnapshots = sqliteTable("query_snapshots", {
|
|
|
1113
1168
|
answerText: text("answer_text"),
|
|
1114
1169
|
citedDomains: text("cited_domains").notNull().default("[]"),
|
|
1115
1170
|
competitorOverlap: text("competitor_overlap").notNull().default("[]"),
|
|
1171
|
+
recommendedCompetitors: text("recommended_competitors").notNull().default("[]"),
|
|
1116
1172
|
location: text("location"),
|
|
1117
1173
|
screenshotPath: text("screenshot_path"),
|
|
1118
1174
|
rawResponse: text("raw_response"),
|
|
@@ -1306,6 +1362,20 @@ var gaTrafficSnapshots = sqliteTable("ga_traffic_snapshots", {
|
|
|
1306
1362
|
index("idx_ga_traffic_project_date").on(table.projectId, table.date),
|
|
1307
1363
|
index("idx_ga_traffic_page").on(table.landingPage)
|
|
1308
1364
|
]);
|
|
1365
|
+
var gaAiReferrals = sqliteTable("ga_ai_referrals", {
|
|
1366
|
+
id: text("id").primaryKey(),
|
|
1367
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
1368
|
+
date: text("date").notNull(),
|
|
1369
|
+
source: text("source").notNull(),
|
|
1370
|
+
medium: text("medium").notNull(),
|
|
1371
|
+
sessions: integer("sessions").notNull().default(0),
|
|
1372
|
+
users: integer("users").notNull().default(0),
|
|
1373
|
+
syncedAt: text("synced_at").notNull()
|
|
1374
|
+
}, (table) => [
|
|
1375
|
+
index("idx_ga_ai_ref_project_date").on(table.projectId, table.date),
|
|
1376
|
+
index("idx_ga_ai_ref_source").on(table.source),
|
|
1377
|
+
uniqueIndex("idx_ga_ai_ref_unique").on(table.projectId, table.date, table.source, table.medium)
|
|
1378
|
+
]);
|
|
1309
1379
|
var gaTrafficSummaries = sqliteTable("ga_traffic_summaries", {
|
|
1310
1380
|
id: text("id").primaryKey(),
|
|
1311
1381
|
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
@@ -1339,6 +1409,16 @@ function createClient(databasePath) {
|
|
|
1339
1409
|
return drizzle(sqlite, { schema: schema_exports });
|
|
1340
1410
|
}
|
|
1341
1411
|
|
|
1412
|
+
// ../db/src/json.ts
|
|
1413
|
+
function parseJsonColumn(value, fallback) {
|
|
1414
|
+
if (value == null || value === "") return fallback;
|
|
1415
|
+
try {
|
|
1416
|
+
return JSON.parse(value);
|
|
1417
|
+
} catch {
|
|
1418
|
+
return fallback;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1342
1422
|
// ../db/src/migrate.ts
|
|
1343
1423
|
import { sql } from "drizzle-orm";
|
|
1344
1424
|
var MIGRATION_SQL = `
|
|
@@ -1635,7 +1715,23 @@ var MIGRATIONS = [
|
|
|
1635
1715
|
// v15: Bing URL inspections — document_size, anchor_count, discovery_date columns
|
|
1636
1716
|
`ALTER TABLE bing_url_inspections ADD COLUMN document_size INTEGER`,
|
|
1637
1717
|
`ALTER TABLE bing_url_inspections ADD COLUMN anchor_count INTEGER`,
|
|
1638
|
-
`ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT
|
|
1718
|
+
`ALTER TABLE bing_url_inspections ADD COLUMN discovery_date TEXT`,
|
|
1719
|
+
// v16: Recommended competitor names extracted from run answers
|
|
1720
|
+
`ALTER TABLE query_snapshots ADD COLUMN recommended_competitors TEXT NOT NULL DEFAULT '[]'`,
|
|
1721
|
+
// v17: GA4 AI referral tracking — ga_ai_referrals table
|
|
1722
|
+
`CREATE TABLE IF NOT EXISTS ga_ai_referrals (
|
|
1723
|
+
id TEXT PRIMARY KEY,
|
|
1724
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
1725
|
+
date TEXT NOT NULL,
|
|
1726
|
+
source TEXT NOT NULL,
|
|
1727
|
+
medium TEXT NOT NULL,
|
|
1728
|
+
sessions INTEGER NOT NULL DEFAULT 0,
|
|
1729
|
+
users INTEGER NOT NULL DEFAULT 0,
|
|
1730
|
+
synced_at TEXT NOT NULL
|
|
1731
|
+
)`,
|
|
1732
|
+
`CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_project_date ON ga_ai_referrals(project_id, date)`,
|
|
1733
|
+
`CREATE INDEX IF NOT EXISTS idx_ga_ai_ref_source ON ga_ai_referrals(source)`,
|
|
1734
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_ga_ai_ref_unique ON ga_ai_referrals(project_id, date, source, medium)`
|
|
1639
1735
|
];
|
|
1640
1736
|
function migrate(db) {
|
|
1641
1737
|
const statements = MIGRATION_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
@@ -1723,7 +1819,7 @@ import { eq as eq3 } from "drizzle-orm";
|
|
|
1723
1819
|
|
|
1724
1820
|
// ../api-routes/src/helpers.ts
|
|
1725
1821
|
import crypto3 from "crypto";
|
|
1726
|
-
import { eq as eq2,
|
|
1822
|
+
import { eq as eq2, sql as sql2 } from "drizzle-orm";
|
|
1727
1823
|
function resolveProject(db, name) {
|
|
1728
1824
|
const project = db.select().from(projects).where(eq2(projects.name, name)).get();
|
|
1729
1825
|
if (!project) {
|
|
@@ -1751,43 +1847,39 @@ async function projectRoutes(app, opts) {
|
|
|
1751
1847
|
const { name } = request.params;
|
|
1752
1848
|
const parsedBody = projectUpsertRequestSchema.safeParse(request.body);
|
|
1753
1849
|
if (!parsedBody.success) {
|
|
1754
|
-
|
|
1850
|
+
throw validationError("Invalid project payload", {
|
|
1755
1851
|
issues: parsedBody.error.issues.map((issue) => ({
|
|
1756
1852
|
path: issue.path.join("."),
|
|
1757
1853
|
message: issue.message
|
|
1758
1854
|
}))
|
|
1759
1855
|
});
|
|
1760
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1761
1856
|
}
|
|
1762
1857
|
const body = parsedBody.data;
|
|
1763
1858
|
const validNames = opts.validProviderNames ?? [];
|
|
1764
1859
|
if (validNames.length && body.providers?.length) {
|
|
1765
1860
|
const invalid = body.providers.filter((p) => !validNames.includes(p));
|
|
1766
1861
|
if (invalid.length) {
|
|
1767
|
-
|
|
1862
|
+
throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
|
|
1768
1863
|
invalidProviders: invalid,
|
|
1769
1864
|
validProviders: validNames
|
|
1770
1865
|
});
|
|
1771
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1772
1866
|
}
|
|
1773
1867
|
}
|
|
1774
1868
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1775
1869
|
const existing = app.db.select().from(projects).where(eq3(projects.name, name)).get();
|
|
1776
|
-
const existingLocations = existing ?
|
|
1870
|
+
const existingLocations = existing ? parseJsonColumn(existing.locations, []) : [];
|
|
1777
1871
|
const nextLocations = body.locations ?? existingLocations;
|
|
1778
1872
|
const duplicateLabels = findDuplicateLocationLabels(nextLocations);
|
|
1779
1873
|
if (duplicateLabels.length > 0) {
|
|
1780
|
-
|
|
1874
|
+
throw validationError(`Duplicate location labels are not allowed: ${duplicateLabels.join(", ")}`, {
|
|
1781
1875
|
duplicateLabels
|
|
1782
1876
|
});
|
|
1783
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1784
1877
|
}
|
|
1785
1878
|
const nextDefaultLocation = body.defaultLocation !== void 0 ? body.defaultLocation ?? null : existing?.defaultLocation ?? null;
|
|
1786
1879
|
if (!hasLocationLabel(nextLocations, nextDefaultLocation)) {
|
|
1787
|
-
|
|
1880
|
+
throw validationError(`defaultLocation "${nextDefaultLocation}" must match a configured location label`, {
|
|
1788
1881
|
defaultLocation: nextDefaultLocation
|
|
1789
1882
|
});
|
|
1790
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1791
1883
|
}
|
|
1792
1884
|
if (existing) {
|
|
1793
1885
|
app.db.update(projects).set({
|
|
@@ -1849,28 +1941,11 @@ async function projectRoutes(app, opts) {
|
|
|
1849
1941
|
return reply.send(rows.map(formatProject));
|
|
1850
1942
|
});
|
|
1851
1943
|
app.get("/projects/:name", async (request, reply) => {
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
return reply.send(formatProject(project));
|
|
1855
|
-
} catch (e) {
|
|
1856
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
1857
|
-
const err = e;
|
|
1858
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1859
|
-
}
|
|
1860
|
-
throw e;
|
|
1861
|
-
}
|
|
1944
|
+
const project = resolveProject(app.db, request.params.name);
|
|
1945
|
+
return reply.send(formatProject(project));
|
|
1862
1946
|
});
|
|
1863
1947
|
app.delete("/projects/:name", async (request, reply) => {
|
|
1864
|
-
|
|
1865
|
-
try {
|
|
1866
|
-
project = resolveProject(app.db, request.params.name);
|
|
1867
|
-
} catch (e) {
|
|
1868
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
1869
|
-
const err = e;
|
|
1870
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1871
|
-
}
|
|
1872
|
-
throw e;
|
|
1873
|
-
}
|
|
1948
|
+
const project = resolveProject(app.db, request.params.name);
|
|
1874
1949
|
writeAuditLog(app.db, {
|
|
1875
1950
|
projectId: project.id,
|
|
1876
1951
|
actor: "api",
|
|
@@ -1883,26 +1958,15 @@ async function projectRoutes(app, opts) {
|
|
|
1883
1958
|
return reply.status(204).send();
|
|
1884
1959
|
});
|
|
1885
1960
|
app.post("/projects/:name/locations", async (request, reply) => {
|
|
1886
|
-
|
|
1887
|
-
try {
|
|
1888
|
-
project = resolveProject(app.db, request.params.name);
|
|
1889
|
-
} catch (e) {
|
|
1890
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
1891
|
-
const err = e;
|
|
1892
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1893
|
-
}
|
|
1894
|
-
throw e;
|
|
1895
|
-
}
|
|
1961
|
+
const project = resolveProject(app.db, request.params.name);
|
|
1896
1962
|
const parsed = locationContextSchema.safeParse(request.body);
|
|
1897
1963
|
if (!parsed.success) {
|
|
1898
|
-
|
|
1899
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1964
|
+
throw validationError(parsed.error.issues.map((i) => i.message).join(", "));
|
|
1900
1965
|
}
|
|
1901
1966
|
const location = parsed.data;
|
|
1902
|
-
const existing =
|
|
1967
|
+
const existing = parseJsonColumn(project.locations, []);
|
|
1903
1968
|
if (existing.some((l) => l.label === location.label)) {
|
|
1904
|
-
|
|
1905
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1969
|
+
throw validationError(`Location "${location.label}" already exists`);
|
|
1906
1970
|
}
|
|
1907
1971
|
existing.push(location);
|
|
1908
1972
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1920,39 +1984,20 @@ async function projectRoutes(app, opts) {
|
|
|
1920
1984
|
return reply.status(201).send(location);
|
|
1921
1985
|
});
|
|
1922
1986
|
app.get("/projects/:name/locations", async (request, reply) => {
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
project = resolveProject(app.db, request.params.name);
|
|
1926
|
-
} catch (e) {
|
|
1927
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
1928
|
-
const err = e;
|
|
1929
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1930
|
-
}
|
|
1931
|
-
throw e;
|
|
1932
|
-
}
|
|
1933
|
-
const locations = JSON.parse(project.locations || "[]");
|
|
1987
|
+
const project = resolveProject(app.db, request.params.name);
|
|
1988
|
+
const locations = parseJsonColumn(project.locations, []);
|
|
1934
1989
|
return reply.send({
|
|
1935
1990
|
locations,
|
|
1936
1991
|
defaultLocation: project.defaultLocation
|
|
1937
1992
|
});
|
|
1938
1993
|
});
|
|
1939
1994
|
app.delete("/projects/:name/locations/:label", async (request, reply) => {
|
|
1940
|
-
|
|
1941
|
-
try {
|
|
1942
|
-
project = resolveProject(app.db, request.params.name);
|
|
1943
|
-
} catch (e) {
|
|
1944
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
1945
|
-
const err = e;
|
|
1946
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1947
|
-
}
|
|
1948
|
-
throw e;
|
|
1949
|
-
}
|
|
1995
|
+
const project = resolveProject(app.db, request.params.name);
|
|
1950
1996
|
const label = decodeURIComponent(request.params.label);
|
|
1951
|
-
const existing =
|
|
1997
|
+
const existing = parseJsonColumn(project.locations, []);
|
|
1952
1998
|
const filtered = existing.filter((l) => l.label !== label);
|
|
1953
1999
|
if (filtered.length === existing.length) {
|
|
1954
|
-
|
|
1955
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2000
|
+
throw validationError(`Location "${label}" not found`);
|
|
1956
2001
|
}
|
|
1957
2002
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1958
2003
|
const updates = {
|
|
@@ -1973,25 +2018,14 @@ async function projectRoutes(app, opts) {
|
|
|
1973
2018
|
return reply.status(204).send();
|
|
1974
2019
|
});
|
|
1975
2020
|
app.put("/projects/:name/locations/default", async (request, reply) => {
|
|
1976
|
-
|
|
1977
|
-
try {
|
|
1978
|
-
project = resolveProject(app.db, request.params.name);
|
|
1979
|
-
} catch (e) {
|
|
1980
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
1981
|
-
const err = e;
|
|
1982
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
1983
|
-
}
|
|
1984
|
-
throw e;
|
|
1985
|
-
}
|
|
2021
|
+
const project = resolveProject(app.db, request.params.name);
|
|
1986
2022
|
const label = request.body?.label;
|
|
1987
2023
|
if (!label) {
|
|
1988
|
-
|
|
1989
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2024
|
+
throw validationError("label is required");
|
|
1990
2025
|
}
|
|
1991
|
-
const existing =
|
|
2026
|
+
const existing = parseJsonColumn(project.locations, []);
|
|
1992
2027
|
if (!existing.some((l) => l.label === label)) {
|
|
1993
|
-
|
|
1994
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2028
|
+
throw validationError(`Location "${label}" not found. Add it first.`);
|
|
1995
2029
|
}
|
|
1996
2030
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1997
2031
|
app.db.update(projects).set({
|
|
@@ -2008,16 +2042,7 @@ async function projectRoutes(app, opts) {
|
|
|
2008
2042
|
return reply.send({ defaultLocation: label });
|
|
2009
2043
|
});
|
|
2010
2044
|
app.get("/projects/:name/export", async (request, reply) => {
|
|
2011
|
-
|
|
2012
|
-
try {
|
|
2013
|
-
project = resolveProject(app.db, request.params.name);
|
|
2014
|
-
} catch (e) {
|
|
2015
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
2016
|
-
const err = e;
|
|
2017
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2018
|
-
}
|
|
2019
|
-
throw e;
|
|
2020
|
-
}
|
|
2045
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2021
2046
|
const kws = app.db.select().from(keywords).where(eq3(keywords.projectId, project.id)).all();
|
|
2022
2047
|
const comps = app.db.select().from(competitors).where(eq3(competitors.projectId, project.id)).all();
|
|
2023
2048
|
const schedule = app.db.select().from(schedules).where(eq3(schedules.projectId, project.id)).get();
|
|
@@ -2027,21 +2052,21 @@ async function projectRoutes(app, opts) {
|
|
|
2027
2052
|
kind: "Project",
|
|
2028
2053
|
metadata: {
|
|
2029
2054
|
name: project.name,
|
|
2030
|
-
labels:
|
|
2055
|
+
labels: parseJsonColumn(project.labels, {})
|
|
2031
2056
|
},
|
|
2032
2057
|
spec: {
|
|
2033
2058
|
displayName: project.displayName,
|
|
2034
2059
|
canonicalDomain: project.canonicalDomain,
|
|
2035
|
-
ownedDomains:
|
|
2060
|
+
ownedDomains: parseJsonColumn(project.ownedDomains, []),
|
|
2036
2061
|
country: project.country,
|
|
2037
2062
|
language: project.language,
|
|
2038
2063
|
keywords: kws.map((k) => k.keyword),
|
|
2039
2064
|
competitors: comps.map((c) => c.domain),
|
|
2040
|
-
providers:
|
|
2041
|
-
locations:
|
|
2065
|
+
providers: parseJsonColumn(project.providers, []),
|
|
2066
|
+
locations: parseJsonColumn(project.locations, []),
|
|
2042
2067
|
...project.defaultLocation ? { defaultLocation: project.defaultLocation } : {},
|
|
2043
2068
|
notifications: notificationRows.map((row) => {
|
|
2044
|
-
const cfg =
|
|
2069
|
+
const cfg = parseJsonColumn(row.config, { url: "", events: [] });
|
|
2045
2070
|
return {
|
|
2046
2071
|
channel: row.channel,
|
|
2047
2072
|
url: cfg.url,
|
|
@@ -2052,7 +2077,7 @@ async function projectRoutes(app, opts) {
|
|
|
2052
2077
|
schedule: {
|
|
2053
2078
|
...schedule.preset ? { preset: schedule.preset } : { cron: schedule.cronExpr },
|
|
2054
2079
|
timezone: schedule.timezone,
|
|
2055
|
-
providers:
|
|
2080
|
+
providers: parseJsonColumn(schedule.providers, [])
|
|
2056
2081
|
}
|
|
2057
2082
|
} : {}
|
|
2058
2083
|
}
|
|
@@ -2066,13 +2091,13 @@ function formatProject(row) {
|
|
|
2066
2091
|
name: row.name,
|
|
2067
2092
|
displayName: row.displayName,
|
|
2068
2093
|
canonicalDomain: row.canonicalDomain,
|
|
2069
|
-
ownedDomains:
|
|
2094
|
+
ownedDomains: parseJsonColumn(row.ownedDomains, []),
|
|
2070
2095
|
country: row.country,
|
|
2071
2096
|
language: row.language,
|
|
2072
|
-
tags:
|
|
2073
|
-
labels:
|
|
2074
|
-
providers:
|
|
2075
|
-
locations:
|
|
2097
|
+
tags: parseJsonColumn(row.tags, []),
|
|
2098
|
+
labels: parseJsonColumn(row.labels, {}),
|
|
2099
|
+
providers: parseJsonColumn(row.providers, []),
|
|
2100
|
+
locations: parseJsonColumn(row.locations, []),
|
|
2076
2101
|
defaultLocation: row.defaultLocation,
|
|
2077
2102
|
configSource: row.configSource,
|
|
2078
2103
|
configRevision: row.configRevision,
|
|
@@ -2086,18 +2111,15 @@ import crypto5 from "crypto";
|
|
|
2086
2111
|
import { eq as eq4 } from "drizzle-orm";
|
|
2087
2112
|
async function keywordRoutes(app, opts) {
|
|
2088
2113
|
app.get("/projects/:name/keywords", async (request, reply) => {
|
|
2089
|
-
const project =
|
|
2090
|
-
if (!project) return;
|
|
2114
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2091
2115
|
const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
|
|
2092
2116
|
return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
|
|
2093
2117
|
});
|
|
2094
2118
|
app.put("/projects/:name/keywords", async (request, reply) => {
|
|
2095
|
-
const project =
|
|
2096
|
-
if (!project) return;
|
|
2119
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2097
2120
|
const body = request.body;
|
|
2098
2121
|
if (!body || !Array.isArray(body.keywords)) {
|
|
2099
|
-
|
|
2100
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2122
|
+
throw validationError('Body must contain a "keywords" array');
|
|
2101
2123
|
}
|
|
2102
2124
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2103
2125
|
app.db.transaction((tx) => {
|
|
@@ -2122,12 +2144,10 @@ async function keywordRoutes(app, opts) {
|
|
|
2122
2144
|
return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
|
|
2123
2145
|
});
|
|
2124
2146
|
app.delete("/projects/:name/keywords", async (request, reply) => {
|
|
2125
|
-
const project =
|
|
2126
|
-
if (!project) return;
|
|
2147
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2127
2148
|
const body = request.body;
|
|
2128
2149
|
if (!body || !Array.isArray(body.keywords) || body.keywords.length === 0) {
|
|
2129
|
-
|
|
2130
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2150
|
+
throw validationError('Body must contain a non-empty "keywords" array');
|
|
2131
2151
|
}
|
|
2132
2152
|
const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
|
|
2133
2153
|
const toDelete = new Set(body.keywords);
|
|
@@ -2150,12 +2170,10 @@ async function keywordRoutes(app, opts) {
|
|
|
2150
2170
|
return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
|
|
2151
2171
|
});
|
|
2152
2172
|
app.post("/projects/:name/keywords", async (request, reply) => {
|
|
2153
|
-
const project =
|
|
2154
|
-
if (!project) return;
|
|
2173
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2155
2174
|
const body = request.body;
|
|
2156
2175
|
if (!body || !Array.isArray(body.keywords)) {
|
|
2157
|
-
|
|
2158
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2176
|
+
throw validationError('Body must contain a "keywords" array');
|
|
2159
2177
|
}
|
|
2160
2178
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2161
2179
|
const existing = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
|
|
@@ -2186,30 +2204,25 @@ async function keywordRoutes(app, opts) {
|
|
|
2186
2204
|
return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
|
|
2187
2205
|
});
|
|
2188
2206
|
app.post("/projects/:name/keywords/generate", async (request, reply) => {
|
|
2189
|
-
const project =
|
|
2190
|
-
if (!project) return;
|
|
2207
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2191
2208
|
const body = request.body;
|
|
2192
2209
|
if (!body?.provider || typeof body.provider !== "string") {
|
|
2193
|
-
|
|
2194
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2210
|
+
throw validationError('Body must contain a "provider" string');
|
|
2195
2211
|
}
|
|
2196
2212
|
const provider = body.provider.trim().toLowerCase();
|
|
2197
2213
|
const validNames = opts.validProviderNames ?? [];
|
|
2198
2214
|
if (validNames.length && !validNames.includes(provider)) {
|
|
2199
|
-
|
|
2215
|
+
throw validationError(`Unknown provider "${body.provider}". Valid providers: ${validNames.join(", ")}`, {
|
|
2200
2216
|
provider: body.provider,
|
|
2201
2217
|
validProviders: validNames
|
|
2202
2218
|
});
|
|
2203
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2204
2219
|
}
|
|
2205
2220
|
if (body.count !== void 0 && (typeof body.count !== "number" || !Number.isFinite(body.count) || !Number.isInteger(body.count))) {
|
|
2206
|
-
|
|
2207
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2221
|
+
throw validationError('"count" must be an integer');
|
|
2208
2222
|
}
|
|
2209
2223
|
const count = Math.min(Math.max(body.count ?? 5, 1), 20);
|
|
2210
2224
|
if (!opts.onGenerateKeywords) {
|
|
2211
|
-
|
|
2212
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2225
|
+
throw notImplemented("Key phrase generation is not supported in this deployment");
|
|
2213
2226
|
}
|
|
2214
2227
|
const existingRows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
|
|
2215
2228
|
const existingKeywords = existingRows.map((r) => r.keyword);
|
|
@@ -2233,36 +2246,21 @@ async function keywordRoutes(app, opts) {
|
|
|
2233
2246
|
}
|
|
2234
2247
|
});
|
|
2235
2248
|
}
|
|
2236
|
-
function resolveProjectSafe(app, name, reply) {
|
|
2237
|
-
try {
|
|
2238
|
-
return resolveProject(app.db, name);
|
|
2239
|
-
} catch (e) {
|
|
2240
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
2241
|
-
const err = e;
|
|
2242
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
2243
|
-
return null;
|
|
2244
|
-
}
|
|
2245
|
-
throw e;
|
|
2246
|
-
}
|
|
2247
|
-
}
|
|
2248
2249
|
|
|
2249
2250
|
// ../api-routes/src/competitors.ts
|
|
2250
2251
|
import crypto6 from "crypto";
|
|
2251
2252
|
import { eq as eq5 } from "drizzle-orm";
|
|
2252
2253
|
async function competitorRoutes(app) {
|
|
2253
2254
|
app.get("/projects/:name/competitors", async (request, reply) => {
|
|
2254
|
-
const project =
|
|
2255
|
-
if (!project) return;
|
|
2255
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2256
2256
|
const rows = app.db.select().from(competitors).where(eq5(competitors.projectId, project.id)).all();
|
|
2257
2257
|
return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
|
|
2258
2258
|
});
|
|
2259
2259
|
app.put("/projects/:name/competitors", async (request, reply) => {
|
|
2260
|
-
const project =
|
|
2261
|
-
if (!project) return;
|
|
2260
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2262
2261
|
const body = request.body;
|
|
2263
2262
|
if (!body || !Array.isArray(body.competitors)) {
|
|
2264
|
-
|
|
2265
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2263
|
+
throw validationError('Body must contain a "competitors" array');
|
|
2266
2264
|
}
|
|
2267
2265
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2268
2266
|
app.db.transaction((tx) => {
|
|
@@ -2287,18 +2285,6 @@ async function competitorRoutes(app) {
|
|
|
2287
2285
|
return reply.send(rows.map((r) => ({ id: r.id, domain: r.domain, createdAt: r.createdAt })));
|
|
2288
2286
|
});
|
|
2289
2287
|
}
|
|
2290
|
-
function resolveProjectSafe2(app, name, reply) {
|
|
2291
|
-
try {
|
|
2292
|
-
return resolveProject(app.db, name);
|
|
2293
|
-
} catch (e) {
|
|
2294
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
2295
|
-
const err = e;
|
|
2296
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
2297
|
-
return null;
|
|
2298
|
-
}
|
|
2299
|
-
throw e;
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
2288
|
|
|
2303
2289
|
// ../api-routes/src/runs.ts
|
|
2304
2290
|
import crypto8 from "crypto";
|
|
@@ -2306,7 +2292,7 @@ import { eq as eq7, asc, desc } from "drizzle-orm";
|
|
|
2306
2292
|
|
|
2307
2293
|
// ../api-routes/src/run-queue.ts
|
|
2308
2294
|
import crypto7 from "crypto";
|
|
2309
|
-
import { and
|
|
2295
|
+
import { and, eq as eq6, or } from "drizzle-orm";
|
|
2310
2296
|
function queueRunIfProjectIdle(db, params) {
|
|
2311
2297
|
const createdAt = params.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2312
2298
|
const kind = params.kind ?? "answer-visibility";
|
|
@@ -2314,7 +2300,7 @@ function queueRunIfProjectIdle(db, params) {
|
|
|
2314
2300
|
const runId = crypto7.randomUUID();
|
|
2315
2301
|
return db.transaction((tx) => {
|
|
2316
2302
|
const activeRun = tx.select().from(runs).where(
|
|
2317
|
-
|
|
2303
|
+
and(
|
|
2318
2304
|
eq6(runs.projectId, params.projectId),
|
|
2319
2305
|
or(eq6(runs.status, "queued"), eq6(runs.status, "running"))
|
|
2320
2306
|
)
|
|
@@ -2338,13 +2324,9 @@ function queueRunIfProjectIdle(db, params) {
|
|
|
2338
2324
|
// ../api-routes/src/runs.ts
|
|
2339
2325
|
async function runRoutes(app, opts) {
|
|
2340
2326
|
app.post("/projects/:name/runs", async (request, reply) => {
|
|
2341
|
-
const project =
|
|
2342
|
-
if (!project) return;
|
|
2327
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2343
2328
|
const kind = request.body?.kind ?? "answer-visibility";
|
|
2344
|
-
if (kind !== "answer-visibility")
|
|
2345
|
-
const err = unsupportedKind(kind);
|
|
2346
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2347
|
-
}
|
|
2329
|
+
if (kind !== "answer-visibility") throw unsupportedKind(kind);
|
|
2348
2330
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2349
2331
|
const trigger = request.body?.trigger ?? "manual";
|
|
2350
2332
|
const rawProviders = request.body?.providers;
|
|
@@ -2354,31 +2336,30 @@ async function runRoutes(app, opts) {
|
|
|
2354
2336
|
if (validNames.length) {
|
|
2355
2337
|
const invalid = normalized.filter((p) => !validNames.includes(p));
|
|
2356
2338
|
if (invalid.length) {
|
|
2357
|
-
|
|
2339
|
+
throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
|
|
2358
2340
|
invalidProviders: invalid,
|
|
2359
2341
|
validProviders: validNames
|
|
2360
2342
|
});
|
|
2361
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2362
2343
|
}
|
|
2363
2344
|
}
|
|
2364
2345
|
rawProviders.splice(0, rawProviders.length, ...normalized);
|
|
2365
2346
|
}
|
|
2366
2347
|
const providers = rawProviders?.length ? rawProviders : void 0;
|
|
2367
2348
|
let resolvedLocation;
|
|
2368
|
-
const projectLocations =
|
|
2349
|
+
const projectLocations = parseJsonColumn(project.locations, []);
|
|
2369
2350
|
if (request.body?.noLocation) {
|
|
2370
2351
|
resolvedLocation = null;
|
|
2371
2352
|
} else if (request.body?.allLocations) {
|
|
2372
2353
|
} else if (request.body?.location) {
|
|
2373
2354
|
const loc = projectLocations.find((l) => l.label === request.body.location);
|
|
2374
2355
|
if (!loc) {
|
|
2375
|
-
|
|
2356
|
+
throw validationError(`Location "${request.body.location}" not found. Configure it first.`);
|
|
2376
2357
|
}
|
|
2377
2358
|
resolvedLocation = loc;
|
|
2378
2359
|
}
|
|
2379
2360
|
if (request.body?.allLocations) {
|
|
2380
2361
|
if (projectLocations.length === 0) {
|
|
2381
|
-
|
|
2362
|
+
throw validationError("No locations configured for this project");
|
|
2382
2363
|
}
|
|
2383
2364
|
const newRuns = [];
|
|
2384
2365
|
for (const loc of projectLocations) {
|
|
@@ -2419,10 +2400,7 @@ async function runRoutes(app, opts) {
|
|
|
2419
2400
|
trigger,
|
|
2420
2401
|
location: locationLabel
|
|
2421
2402
|
});
|
|
2422
|
-
if (queueResult.conflict)
|
|
2423
|
-
const err = runInProgress(project.name);
|
|
2424
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2425
|
-
}
|
|
2403
|
+
if (queueResult.conflict) throw runInProgress(project.name);
|
|
2426
2404
|
const runId = queueResult.runId;
|
|
2427
2405
|
writeAuditLog(app.db, {
|
|
2428
2406
|
projectId: project.id,
|
|
@@ -2438,8 +2416,7 @@ async function runRoutes(app, opts) {
|
|
|
2438
2416
|
return reply.status(201).send(formatRun(run));
|
|
2439
2417
|
});
|
|
2440
2418
|
app.get("/projects/:name/runs", async (request, reply) => {
|
|
2441
|
-
const project =
|
|
2442
|
-
if (!project) return;
|
|
2419
|
+
const project = resolveProject(app.db, request.params.name);
|
|
2443
2420
|
const parsedLimit = parseInt(request.query.limit ?? "", 10);
|
|
2444
2421
|
const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? void 0 : parsedLimit;
|
|
2445
2422
|
const rows = limit == null ? app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(asc(runs.createdAt)).all() : app.db.select().from(runs).where(eq7(runs.projectId, project.id)).orderBy(desc(runs.createdAt)).limit(limit).all().reverse();
|
|
@@ -2455,10 +2432,7 @@ async function runRoutes(app, opts) {
|
|
|
2455
2432
|
return reply.status(207).send([]);
|
|
2456
2433
|
}
|
|
2457
2434
|
const kind = request.body?.kind ?? "answer-visibility";
|
|
2458
|
-
if (kind !== "answer-visibility")
|
|
2459
|
-
const err = unsupportedKind(kind);
|
|
2460
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2461
|
-
}
|
|
2435
|
+
if (kind !== "answer-visibility") throw unsupportedKind(kind);
|
|
2462
2436
|
const rawProviders = request.body?.providers;
|
|
2463
2437
|
if (rawProviders?.length) {
|
|
2464
2438
|
const normalized = rawProviders.map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
@@ -2466,11 +2440,10 @@ async function runRoutes(app, opts) {
|
|
|
2466
2440
|
if (validNames.length) {
|
|
2467
2441
|
const invalid = normalized.filter((p) => !validNames.includes(p));
|
|
2468
2442
|
if (invalid.length) {
|
|
2469
|
-
|
|
2443
|
+
throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
|
|
2470
2444
|
invalidProviders: invalid,
|
|
2471
2445
|
validProviders: validNames
|
|
2472
2446
|
});
|
|
2473
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2474
2447
|
}
|
|
2475
2448
|
}
|
|
2476
2449
|
rawProviders.splice(0, rawProviders.length, ...normalized);
|
|
@@ -2507,15 +2480,9 @@ async function runRoutes(app, opts) {
|
|
|
2507
2480
|
});
|
|
2508
2481
|
app.post("/runs/:id/cancel", async (request, reply) => {
|
|
2509
2482
|
const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
|
|
2510
|
-
if (!run)
|
|
2511
|
-
const err = notFound("Run", request.params.id);
|
|
2512
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2513
|
-
}
|
|
2483
|
+
if (!run) throw notFound("Run", request.params.id);
|
|
2514
2484
|
const terminalStatuses = /* @__PURE__ */ new Set(["completed", "partial", "failed", "cancelled"]);
|
|
2515
|
-
if (terminalStatuses.has(run.status))
|
|
2516
|
-
const err = runNotCancellable(run.id, run.status);
|
|
2517
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2518
|
-
}
|
|
2485
|
+
if (terminalStatuses.has(run.status)) throw runNotCancellable(run.id, run.status);
|
|
2519
2486
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2520
2487
|
app.db.update(runs).set({ status: "cancelled", finishedAt: now, error: "Cancelled by user" }).where(eq7(runs.id, run.id)).run();
|
|
2521
2488
|
writeAuditLog(app.db, {
|
|
@@ -2530,9 +2497,7 @@ async function runRoutes(app, opts) {
|
|
|
2530
2497
|
});
|
|
2531
2498
|
app.get("/runs/:id", async (request, reply) => {
|
|
2532
2499
|
const run = app.db.select().from(runs).where(eq7(runs.id, request.params.id)).get();
|
|
2533
|
-
if (!run)
|
|
2534
|
-
return reply.status(404).send({ error: { code: "NOT_FOUND", message: `Run '${request.params.id}' not found` } });
|
|
2535
|
-
}
|
|
2500
|
+
if (!run) throw notFound("Run", request.params.id);
|
|
2536
2501
|
const snapshots = app.db.select({
|
|
2537
2502
|
id: querySnapshots.id,
|
|
2538
2503
|
runId: querySnapshots.runId,
|
|
@@ -2544,6 +2509,7 @@ async function runRoutes(app, opts) {
|
|
|
2544
2509
|
answerText: querySnapshots.answerText,
|
|
2545
2510
|
citedDomains: querySnapshots.citedDomains,
|
|
2546
2511
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
2512
|
+
recommendedCompetitors: querySnapshots.recommendedCompetitors,
|
|
2547
2513
|
location: querySnapshots.location,
|
|
2548
2514
|
rawResponse: querySnapshots.rawResponse,
|
|
2549
2515
|
createdAt: querySnapshots.createdAt
|
|
@@ -2560,8 +2526,9 @@ async function runRoutes(app, opts) {
|
|
|
2560
2526
|
provider: s.provider,
|
|
2561
2527
|
citationState: s.citationState,
|
|
2562
2528
|
answerText: s.answerText,
|
|
2563
|
-
citedDomains:
|
|
2564
|
-
competitorOverlap:
|
|
2529
|
+
citedDomains: parseJsonColumn(s.citedDomains, []),
|
|
2530
|
+
competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
|
|
2531
|
+
recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
|
|
2565
2532
|
model: s.model ?? rawParsed.model,
|
|
2566
2533
|
location: s.location,
|
|
2567
2534
|
groundingSources: rawParsed.groundingSources,
|
|
@@ -2587,32 +2554,13 @@ function formatRun(row) {
|
|
|
2587
2554
|
};
|
|
2588
2555
|
}
|
|
2589
2556
|
function parseSnapshotRawResponse(raw) {
|
|
2590
|
-
const parsed =
|
|
2557
|
+
const parsed = parseJsonColumn(raw, {});
|
|
2591
2558
|
return {
|
|
2592
2559
|
groundingSources: parsed.groundingSources ?? [],
|
|
2593
2560
|
searchQueries: parsed.searchQueries ?? [],
|
|
2594
2561
|
model: parsed.model ?? null
|
|
2595
2562
|
};
|
|
2596
2563
|
}
|
|
2597
|
-
function tryParseJson(value, fallback) {
|
|
2598
|
-
try {
|
|
2599
|
-
return JSON.parse(value);
|
|
2600
|
-
} catch {
|
|
2601
|
-
return fallback;
|
|
2602
|
-
}
|
|
2603
|
-
}
|
|
2604
|
-
function resolveProjectSafe3(app, name, reply) {
|
|
2605
|
-
try {
|
|
2606
|
-
return resolveProject(app.db, name);
|
|
2607
|
-
} catch (e) {
|
|
2608
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
2609
|
-
const err = e;
|
|
2610
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
2611
|
-
return null;
|
|
2612
|
-
}
|
|
2613
|
-
throw e;
|
|
2614
|
-
}
|
|
2615
|
-
}
|
|
2616
2564
|
|
|
2617
2565
|
// ../api-routes/src/apply.ts
|
|
2618
2566
|
import crypto10 from "crypto";
|
|
@@ -2870,10 +2818,9 @@ async function applyRoutes(app, opts) {
|
|
|
2870
2818
|
app.post("/apply", async (request, reply) => {
|
|
2871
2819
|
const parsed = projectConfigSchema.safeParse(request.body);
|
|
2872
2820
|
if (!parsed.success) {
|
|
2873
|
-
|
|
2821
|
+
throw validationError("Invalid project config", {
|
|
2874
2822
|
issues: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
|
|
2875
2823
|
});
|
|
2876
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2877
2824
|
}
|
|
2878
2825
|
const config = parsed.data;
|
|
2879
2826
|
const validNames = opts?.validProviderNames ?? [];
|
|
@@ -2885,70 +2832,104 @@ async function applyRoutes(app, opts) {
|
|
|
2885
2832
|
if (allProviders.length) {
|
|
2886
2833
|
const invalid = allProviders.filter((p) => !validNames.includes(p));
|
|
2887
2834
|
if (invalid.length) {
|
|
2888
|
-
|
|
2835
|
+
throw validationError(`Invalid provider(s): ${[...new Set(invalid)].join(", ")}. Must be one of: ${validNames.join(", ")}`, {
|
|
2889
2836
|
invalidProviders: [...new Set(invalid)],
|
|
2890
2837
|
validProviders: validNames
|
|
2891
2838
|
});
|
|
2892
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
2893
2839
|
}
|
|
2894
2840
|
}
|
|
2895
2841
|
}
|
|
2842
|
+
let resolvedSchedule = null;
|
|
2843
|
+
let deleteSchedule = false;
|
|
2844
|
+
if (config.spec.schedule) {
|
|
2845
|
+
const schedSpec = config.spec.schedule;
|
|
2846
|
+
let cronExpr;
|
|
2847
|
+
let preset = null;
|
|
2848
|
+
if (schedSpec.preset) {
|
|
2849
|
+
preset = schedSpec.preset;
|
|
2850
|
+
try {
|
|
2851
|
+
cronExpr = resolvePreset(schedSpec.preset);
|
|
2852
|
+
} catch (err) {
|
|
2853
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2854
|
+
throw validationError(msg);
|
|
2855
|
+
}
|
|
2856
|
+
} else if (schedSpec.cron) {
|
|
2857
|
+
cronExpr = schedSpec.cron;
|
|
2858
|
+
if (!validateCron(cronExpr)) throw validationError(`Invalid cron expression in schedule: ${cronExpr}`);
|
|
2859
|
+
} else {
|
|
2860
|
+
throw validationError('Schedule requires either "preset" or "cron"');
|
|
2861
|
+
}
|
|
2862
|
+
const timezone = schedSpec.timezone ?? "UTC";
|
|
2863
|
+
if (!isValidTimezone(timezone)) throw validationError(`Invalid timezone: ${timezone}`);
|
|
2864
|
+
resolvedSchedule = { cronExpr, preset, timezone };
|
|
2865
|
+
} else {
|
|
2866
|
+
deleteSchedule = true;
|
|
2867
|
+
}
|
|
2868
|
+
const rawSpec = request.body?.spec ?? {};
|
|
2869
|
+
const hasNotifications = "notifications" in rawSpec;
|
|
2870
|
+
if (hasNotifications) {
|
|
2871
|
+
for (const notif of config.spec.notifications) {
|
|
2872
|
+
const urlCheck = await resolveWebhookTarget(notif.url ?? "");
|
|
2873
|
+
if (!urlCheck.ok) throw validationError(`Notification URL invalid: ${urlCheck.message}`);
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2896
2876
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2897
2877
|
const name = config.metadata.name;
|
|
2898
|
-
const existing = app.db.select().from(projects).where(eq8(projects.name, name)).get();
|
|
2899
2878
|
let projectId;
|
|
2900
|
-
|
|
2901
|
-
projectId = existing.id;
|
|
2902
|
-
app.db.update(projects).set({
|
|
2903
|
-
displayName: config.spec.displayName,
|
|
2904
|
-
canonicalDomain: config.spec.canonicalDomain,
|
|
2905
|
-
ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
|
|
2906
|
-
country: config.spec.country,
|
|
2907
|
-
language: config.spec.language,
|
|
2908
|
-
labels: JSON.stringify(config.metadata.labels),
|
|
2909
|
-
providers: JSON.stringify(config.spec.providers ?? []),
|
|
2910
|
-
locations: JSON.stringify(config.spec.locations ?? []),
|
|
2911
|
-
defaultLocation: config.spec.defaultLocation ?? null,
|
|
2912
|
-
configSource: "config-file",
|
|
2913
|
-
configRevision: existing.configRevision + 1,
|
|
2914
|
-
updatedAt: now
|
|
2915
|
-
}).where(eq8(projects.id, existing.id)).run();
|
|
2916
|
-
writeAuditLog(app.db, {
|
|
2917
|
-
projectId,
|
|
2918
|
-
actor: "api",
|
|
2919
|
-
action: "project.applied",
|
|
2920
|
-
entityType: "project",
|
|
2921
|
-
entityId: projectId
|
|
2922
|
-
});
|
|
2923
|
-
} else {
|
|
2924
|
-
projectId = crypto10.randomUUID();
|
|
2925
|
-
app.db.insert(projects).values({
|
|
2926
|
-
id: projectId,
|
|
2927
|
-
name,
|
|
2928
|
-
displayName: config.spec.displayName,
|
|
2929
|
-
canonicalDomain: config.spec.canonicalDomain,
|
|
2930
|
-
ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
|
|
2931
|
-
country: config.spec.country,
|
|
2932
|
-
language: config.spec.language,
|
|
2933
|
-
tags: "[]",
|
|
2934
|
-
labels: JSON.stringify(config.metadata.labels),
|
|
2935
|
-
providers: JSON.stringify(config.spec.providers ?? []),
|
|
2936
|
-
locations: JSON.stringify(config.spec.locations ?? []),
|
|
2937
|
-
defaultLocation: config.spec.defaultLocation ?? null,
|
|
2938
|
-
configSource: "config-file",
|
|
2939
|
-
configRevision: 1,
|
|
2940
|
-
createdAt: now,
|
|
2941
|
-
updatedAt: now
|
|
2942
|
-
}).run();
|
|
2943
|
-
writeAuditLog(app.db, {
|
|
2944
|
-
projectId,
|
|
2945
|
-
actor: "api",
|
|
2946
|
-
action: "project.created",
|
|
2947
|
-
entityType: "project",
|
|
2948
|
-
entityId: projectId
|
|
2949
|
-
});
|
|
2950
|
-
}
|
|
2879
|
+
let scheduleAction = null;
|
|
2951
2880
|
app.db.transaction((tx) => {
|
|
2881
|
+
const existing = tx.select().from(projects).where(eq8(projects.name, name)).get();
|
|
2882
|
+
if (existing) {
|
|
2883
|
+
projectId = existing.id;
|
|
2884
|
+
tx.update(projects).set({
|
|
2885
|
+
displayName: config.spec.displayName,
|
|
2886
|
+
canonicalDomain: config.spec.canonicalDomain,
|
|
2887
|
+
ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
|
|
2888
|
+
country: config.spec.country,
|
|
2889
|
+
language: config.spec.language,
|
|
2890
|
+
labels: JSON.stringify(config.metadata.labels),
|
|
2891
|
+
providers: JSON.stringify(config.spec.providers ?? []),
|
|
2892
|
+
locations: JSON.stringify(config.spec.locations ?? []),
|
|
2893
|
+
defaultLocation: config.spec.defaultLocation ?? null,
|
|
2894
|
+
configSource: "config-file",
|
|
2895
|
+
configRevision: existing.configRevision + 1,
|
|
2896
|
+
updatedAt: now
|
|
2897
|
+
}).where(eq8(projects.id, existing.id)).run();
|
|
2898
|
+
writeAuditLog(tx, {
|
|
2899
|
+
projectId,
|
|
2900
|
+
actor: "api",
|
|
2901
|
+
action: "project.applied",
|
|
2902
|
+
entityType: "project",
|
|
2903
|
+
entityId: projectId
|
|
2904
|
+
});
|
|
2905
|
+
} else {
|
|
2906
|
+
projectId = crypto10.randomUUID();
|
|
2907
|
+
tx.insert(projects).values({
|
|
2908
|
+
id: projectId,
|
|
2909
|
+
name,
|
|
2910
|
+
displayName: config.spec.displayName,
|
|
2911
|
+
canonicalDomain: config.spec.canonicalDomain,
|
|
2912
|
+
ownedDomains: JSON.stringify(config.spec.ownedDomains ?? []),
|
|
2913
|
+
country: config.spec.country,
|
|
2914
|
+
language: config.spec.language,
|
|
2915
|
+
tags: "[]",
|
|
2916
|
+
labels: JSON.stringify(config.metadata.labels),
|
|
2917
|
+
providers: JSON.stringify(config.spec.providers ?? []),
|
|
2918
|
+
locations: JSON.stringify(config.spec.locations ?? []),
|
|
2919
|
+
defaultLocation: config.spec.defaultLocation ?? null,
|
|
2920
|
+
configSource: "config-file",
|
|
2921
|
+
configRevision: 1,
|
|
2922
|
+
createdAt: now,
|
|
2923
|
+
updatedAt: now
|
|
2924
|
+
}).run();
|
|
2925
|
+
writeAuditLog(tx, {
|
|
2926
|
+
projectId,
|
|
2927
|
+
actor: "api",
|
|
2928
|
+
action: "project.created",
|
|
2929
|
+
entityType: "project",
|
|
2930
|
+
entityId: projectId
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2952
2933
|
tx.delete(keywords).where(eq8(keywords.projectId, projectId)).run();
|
|
2953
2934
|
for (const kw of config.spec.keywords) {
|
|
2954
2935
|
tx.insert(keywords).values({
|
|
@@ -2981,98 +2962,63 @@ async function applyRoutes(app, opts) {
|
|
|
2981
2962
|
entityType: "competitor",
|
|
2982
2963
|
diff: { competitors: config.spec.competitors }
|
|
2983
2964
|
});
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2965
|
+
if (resolvedSchedule) {
|
|
2966
|
+
const existingSched = tx.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
|
|
2967
|
+
if (existingSched) {
|
|
2968
|
+
tx.update(schedules).set({
|
|
2969
|
+
cronExpr: resolvedSchedule.cronExpr,
|
|
2970
|
+
preset: resolvedSchedule.preset,
|
|
2971
|
+
timezone: resolvedSchedule.timezone,
|
|
2972
|
+
providers: JSON.stringify(config.spec.schedule?.providers ?? []),
|
|
2973
|
+
enabled: 1,
|
|
2974
|
+
updatedAt: now
|
|
2975
|
+
}).where(eq8(schedules.id, existingSched.id)).run();
|
|
2976
|
+
} else {
|
|
2977
|
+
tx.insert(schedules).values({
|
|
2978
|
+
id: crypto10.randomUUID(),
|
|
2979
|
+
projectId,
|
|
2980
|
+
cronExpr: resolvedSchedule.cronExpr,
|
|
2981
|
+
preset: resolvedSchedule.preset,
|
|
2982
|
+
timezone: resolvedSchedule.timezone,
|
|
2983
|
+
enabled: 1,
|
|
2984
|
+
providers: JSON.stringify(config.spec.schedule?.providers ?? []),
|
|
2985
|
+
createdAt: now,
|
|
2986
|
+
updatedAt: now
|
|
2987
|
+
}).run();
|
|
2996
2988
|
}
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
2989
|
+
scheduleAction = "upsert";
|
|
2990
|
+
} else if (deleteSchedule) {
|
|
2991
|
+
const existingSched = tx.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
|
|
2992
|
+
if (existingSched) {
|
|
2993
|
+
tx.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
|
|
2994
|
+
scheduleAction = "delete";
|
|
3003
2995
|
}
|
|
3004
|
-
} else {
|
|
3005
|
-
return reply.status(400).send({
|
|
3006
|
-
error: { code: "VALIDATION_ERROR", message: 'Schedule requires either "preset" or "cron"' }
|
|
3007
|
-
});
|
|
3008
2996
|
}
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
enabled: 1,
|
|
3023
|
-
updatedAt: now
|
|
3024
|
-
}).where(eq8(schedules.id, existingSched.id)).run();
|
|
3025
|
-
} else {
|
|
3026
|
-
app.db.insert(schedules).values({
|
|
3027
|
-
id: crypto10.randomUUID(),
|
|
3028
|
-
projectId,
|
|
3029
|
-
cronExpr,
|
|
3030
|
-
preset,
|
|
3031
|
-
timezone,
|
|
3032
|
-
enabled: 1,
|
|
3033
|
-
providers: JSON.stringify(schedSpec.providers ?? []),
|
|
3034
|
-
createdAt: now,
|
|
3035
|
-
updatedAt: now
|
|
3036
|
-
}).run();
|
|
3037
|
-
}
|
|
3038
|
-
opts?.onScheduleUpdated?.("upsert", projectId);
|
|
3039
|
-
} else {
|
|
3040
|
-
const existingSched = app.db.select().from(schedules).where(eq8(schedules.projectId, projectId)).get();
|
|
3041
|
-
if (existingSched) {
|
|
3042
|
-
app.db.delete(schedules).where(eq8(schedules.projectId, projectId)).run();
|
|
3043
|
-
opts?.onScheduleUpdated?.("delete", projectId);
|
|
3044
|
-
}
|
|
3045
|
-
}
|
|
3046
|
-
const rawSpec = request.body?.spec ?? {};
|
|
3047
|
-
if ("notifications" in rawSpec) {
|
|
3048
|
-
for (const notif of config.spec.notifications) {
|
|
3049
|
-
const urlCheck = await resolveWebhookTarget(notif.url ?? "");
|
|
3050
|
-
if (!urlCheck.ok) {
|
|
3051
|
-
return reply.status(400).send({
|
|
3052
|
-
error: { code: "VALIDATION_ERROR", message: `Notification URL invalid: ${urlCheck.message}` }
|
|
3053
|
-
});
|
|
2997
|
+
if (hasNotifications) {
|
|
2998
|
+
tx.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
|
|
2999
|
+
for (const notif of config.spec.notifications) {
|
|
3000
|
+
tx.insert(notifications).values({
|
|
3001
|
+
id: crypto10.randomUUID(),
|
|
3002
|
+
projectId,
|
|
3003
|
+
channel: notif.channel,
|
|
3004
|
+
config: JSON.stringify({ url: notif.url, events: notif.events }),
|
|
3005
|
+
webhookSecret: crypto10.randomBytes(32).toString("hex"),
|
|
3006
|
+
enabled: 1,
|
|
3007
|
+
createdAt: now,
|
|
3008
|
+
updatedAt: now
|
|
3009
|
+
}).run();
|
|
3054
3010
|
}
|
|
3055
|
-
|
|
3056
|
-
app.db.delete(notifications).where(eq8(notifications.projectId, projectId)).run();
|
|
3057
|
-
for (const notif of config.spec.notifications) {
|
|
3058
|
-
app.db.insert(notifications).values({
|
|
3059
|
-
id: crypto10.randomUUID(),
|
|
3011
|
+
writeAuditLog(tx, {
|
|
3060
3012
|
projectId,
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
updatedAt: now
|
|
3067
|
-
}).run();
|
|
3013
|
+
actor: "api",
|
|
3014
|
+
action: "notifications.replaced",
|
|
3015
|
+
entityType: "notification",
|
|
3016
|
+
diff: { notifications: config.spec.notifications }
|
|
3017
|
+
});
|
|
3068
3018
|
}
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
action: "notifications.replaced",
|
|
3073
|
-
entityType: "notification",
|
|
3074
|
-
diff: { notifications: config.spec.notifications }
|
|
3075
|
-
});
|
|
3019
|
+
});
|
|
3020
|
+
if (scheduleAction) {
|
|
3021
|
+
opts?.onScheduleUpdated?.(scheduleAction, projectId);
|
|
3076
3022
|
}
|
|
3077
3023
|
if ("google" in rawSpec && config.spec.google?.gsc?.propertyUrl) {
|
|
3078
3024
|
opts?.onGoogleConnectionPropertyUpdated?.(config.spec.canonicalDomain, "gsc", config.spec.google.gsc.propertyUrl);
|
|
@@ -3083,13 +3029,13 @@ async function applyRoutes(app, opts) {
|
|
|
3083
3029
|
name: project.name,
|
|
3084
3030
|
displayName: project.displayName,
|
|
3085
3031
|
canonicalDomain: project.canonicalDomain,
|
|
3086
|
-
ownedDomains:
|
|
3032
|
+
ownedDomains: parseJsonColumn(project.ownedDomains, []),
|
|
3087
3033
|
country: project.country,
|
|
3088
3034
|
language: project.language,
|
|
3089
|
-
tags:
|
|
3090
|
-
labels:
|
|
3091
|
-
providers:
|
|
3092
|
-
locations:
|
|
3035
|
+
tags: parseJsonColumn(project.tags, []),
|
|
3036
|
+
labels: parseJsonColumn(project.labels, {}),
|
|
3037
|
+
providers: parseJsonColumn(project.providers, []),
|
|
3038
|
+
locations: parseJsonColumn(project.locations, []),
|
|
3093
3039
|
defaultLocation: project.defaultLocation,
|
|
3094
3040
|
configSource: project.configSource,
|
|
3095
3041
|
configRevision: project.configRevision,
|
|
@@ -3145,8 +3091,7 @@ function redactNotificationDiff(value) {
|
|
|
3145
3091
|
// ../api-routes/src/history.ts
|
|
3146
3092
|
async function historyRoutes(app) {
|
|
3147
3093
|
app.get("/projects/:name/history", async (request, reply) => {
|
|
3148
|
-
const project =
|
|
3149
|
-
if (!project) return;
|
|
3094
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3150
3095
|
const rows = app.db.select().from(auditLog).where(eq9(auditLog.projectId, project.id)).orderBy(desc2(auditLog.createdAt)).all();
|
|
3151
3096
|
return reply.send(rows.map(formatAuditEntry));
|
|
3152
3097
|
});
|
|
@@ -3155,8 +3100,7 @@ async function historyRoutes(app) {
|
|
|
3155
3100
|
return reply.send(rows.map(formatAuditEntry));
|
|
3156
3101
|
});
|
|
3157
3102
|
app.get("/projects/:name/snapshots", async (request, reply) => {
|
|
3158
|
-
const project =
|
|
3159
|
-
if (!project) return;
|
|
3103
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3160
3104
|
const limit = parseInt(request.query.limit ?? "50", 10);
|
|
3161
3105
|
const offset = parseInt(request.query.offset ?? "0", 10);
|
|
3162
3106
|
const projectRuns = app.db.select({ id: runs.id }).from(runs).where(eq9(runs.projectId, project.id)).all();
|
|
@@ -3174,6 +3118,7 @@ async function historyRoutes(app) {
|
|
|
3174
3118
|
answerText: querySnapshots.answerText,
|
|
3175
3119
|
citedDomains: querySnapshots.citedDomains,
|
|
3176
3120
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
3121
|
+
recommendedCompetitors: querySnapshots.recommendedCompetitors,
|
|
3177
3122
|
location: querySnapshots.location,
|
|
3178
3123
|
createdAt: querySnapshots.createdAt
|
|
3179
3124
|
}).from(querySnapshots).leftJoin(keywords, eq9(querySnapshots.keywordId, keywords.id)).where(inArray(querySnapshots.runId, projectRuns.map((r) => r.id))).orderBy(desc2(querySnapshots.createdAt)).all();
|
|
@@ -3191,8 +3136,9 @@ async function historyRoutes(app) {
|
|
|
3191
3136
|
model: s.model,
|
|
3192
3137
|
citationState: s.citationState,
|
|
3193
3138
|
answerText: s.answerText,
|
|
3194
|
-
citedDomains:
|
|
3195
|
-
competitorOverlap:
|
|
3139
|
+
citedDomains: parseJsonColumn(s.citedDomains, []),
|
|
3140
|
+
competitorOverlap: parseJsonColumn(s.competitorOverlap, []),
|
|
3141
|
+
recommendedCompetitors: parseJsonColumn(s.recommendedCompetitors, []),
|
|
3196
3142
|
location: s.location,
|
|
3197
3143
|
createdAt: s.createdAt
|
|
3198
3144
|
})),
|
|
@@ -3200,8 +3146,7 @@ async function historyRoutes(app) {
|
|
|
3200
3146
|
});
|
|
3201
3147
|
});
|
|
3202
3148
|
app.get("/projects/:name/timeline", async (request, reply) => {
|
|
3203
|
-
const project =
|
|
3204
|
-
if (!project) return;
|
|
3149
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3205
3150
|
const projectKeywords = app.db.select().from(keywords).where(eq9(keywords.projectId, project.id)).all();
|
|
3206
3151
|
const projectRuns = app.db.select().from(runs).where(eq9(runs.projectId, project.id)).orderBy(runs.createdAt).all();
|
|
3207
3152
|
if (projectRuns.length === 0 || projectKeywords.length === 0) {
|
|
@@ -3283,11 +3228,10 @@ async function historyRoutes(app) {
|
|
|
3283
3228
|
return reply.send(timeline);
|
|
3284
3229
|
});
|
|
3285
3230
|
app.get("/projects/:name/snapshots/diff", async (request, reply) => {
|
|
3286
|
-
|
|
3287
|
-
if (!project) return;
|
|
3231
|
+
resolveProject(app.db, request.params.name);
|
|
3288
3232
|
const { run1, run2 } = request.query;
|
|
3289
3233
|
if (!run1 || !run2) {
|
|
3290
|
-
|
|
3234
|
+
throw validationError("Both run1 and run2 query params are required");
|
|
3291
3235
|
}
|
|
3292
3236
|
const snaps1 = app.db.select({
|
|
3293
3237
|
keywordId: querySnapshots.keywordId,
|
|
@@ -3332,36 +3276,16 @@ function formatAuditEntry(row) {
|
|
|
3332
3276
|
action: row.action,
|
|
3333
3277
|
entityType: row.entityType,
|
|
3334
3278
|
entityId: row.entityId,
|
|
3335
|
-
diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(
|
|
3279
|
+
diff: row.diff ? row.entityType === "notification" ? redactNotificationDiff(parseJsonColumn(row.diff, null)) : parseJsonColumn(row.diff, null) : null,
|
|
3336
3280
|
createdAt: row.createdAt
|
|
3337
3281
|
};
|
|
3338
3282
|
}
|
|
3339
|
-
function tryParseJson2(value, fallback) {
|
|
3340
|
-
try {
|
|
3341
|
-
return JSON.parse(value);
|
|
3342
|
-
} catch {
|
|
3343
|
-
return fallback;
|
|
3344
|
-
}
|
|
3345
|
-
}
|
|
3346
|
-
function resolveProjectSafe4(app, name, reply) {
|
|
3347
|
-
try {
|
|
3348
|
-
return resolveProject(app.db, name);
|
|
3349
|
-
} catch (e) {
|
|
3350
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
3351
|
-
const err = e;
|
|
3352
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
3353
|
-
return null;
|
|
3354
|
-
}
|
|
3355
|
-
throw e;
|
|
3356
|
-
}
|
|
3357
|
-
}
|
|
3358
3283
|
|
|
3359
3284
|
// ../api-routes/src/analytics.ts
|
|
3360
3285
|
import { eq as eq10, desc as desc3, inArray as inArray2 } from "drizzle-orm";
|
|
3361
3286
|
async function analyticsRoutes(app) {
|
|
3362
3287
|
app.get("/projects/:name/analytics/metrics", async (request, reply) => {
|
|
3363
|
-
const project =
|
|
3364
|
-
if (!project) return;
|
|
3288
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3365
3289
|
const window = parseWindow(request.query.window);
|
|
3366
3290
|
const cutoff = windowCutoff(window);
|
|
3367
3291
|
const projectRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(runs.createdAt).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
|
|
@@ -3401,8 +3325,7 @@ async function analyticsRoutes(app) {
|
|
|
3401
3325
|
return reply.send({ window, buckets, overall, byProvider, trend, keywordChanges });
|
|
3402
3326
|
});
|
|
3403
3327
|
app.get("/projects/:name/analytics/gaps", async (request, reply) => {
|
|
3404
|
-
const project =
|
|
3405
|
-
if (!project) return;
|
|
3328
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3406
3329
|
const window = parseWindow(request.query.window);
|
|
3407
3330
|
const cutoff = windowCutoff(window);
|
|
3408
3331
|
const latestRun = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().find((r) => r.status === "completed" || r.status === "partial");
|
|
@@ -3450,7 +3373,7 @@ async function analyticsRoutes(app) {
|
|
|
3450
3373
|
const citedProviders = kwSnapshots.filter((s) => s.citationState === "cited").map((s) => s.provider);
|
|
3451
3374
|
const competitorsCiting = /* @__PURE__ */ new Set();
|
|
3452
3375
|
for (const s of kwSnapshots) {
|
|
3453
|
-
const overlap =
|
|
3376
|
+
const overlap = parseJsonColumn(s.competitorOverlap, []);
|
|
3454
3377
|
for (const c of overlap) competitorsCiting.add(c);
|
|
3455
3378
|
}
|
|
3456
3379
|
let category;
|
|
@@ -3483,8 +3406,7 @@ async function analyticsRoutes(app) {
|
|
|
3483
3406
|
return reply.send({ cited, gap, uncited, runId: latestRun.id, window });
|
|
3484
3407
|
});
|
|
3485
3408
|
app.get("/projects/:name/analytics/sources", async (request, reply) => {
|
|
3486
|
-
const project =
|
|
3487
|
-
if (!project) return;
|
|
3409
|
+
const project = resolveProject(app.db, request.params.name);
|
|
3488
3410
|
const window = parseWindow(request.query.window);
|
|
3489
3411
|
const cutoff = windowCutoff(window);
|
|
3490
3412
|
const windowRuns = app.db.select().from(runs).where(eq10(runs.projectId, project.id)).orderBy(desc3(runs.createdAt)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
|
|
@@ -3520,26 +3442,6 @@ async function analyticsRoutes(app) {
|
|
|
3520
3442
|
return reply.send({ overall, byKeyword, runId: latestRunId, window });
|
|
3521
3443
|
});
|
|
3522
3444
|
}
|
|
3523
|
-
function resolveProjectSafe5(app, name, reply) {
|
|
3524
|
-
try {
|
|
3525
|
-
return resolveProject(app.db, name);
|
|
3526
|
-
} catch (e) {
|
|
3527
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
3528
|
-
const err = e;
|
|
3529
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
3530
|
-
return null;
|
|
3531
|
-
}
|
|
3532
|
-
throw e;
|
|
3533
|
-
}
|
|
3534
|
-
}
|
|
3535
|
-
function tryParseJson3(value, fallback) {
|
|
3536
|
-
if (!value) return fallback;
|
|
3537
|
-
try {
|
|
3538
|
-
return JSON.parse(value);
|
|
3539
|
-
} catch {
|
|
3540
|
-
return fallback;
|
|
3541
|
-
}
|
|
3542
|
-
}
|
|
3543
3445
|
var PROVIDER_INFRA_DOMAINS = /* @__PURE__ */ new Set([
|
|
3544
3446
|
"vertexaisearch.cloud.google.com",
|
|
3545
3447
|
"openai.com",
|
|
@@ -3557,7 +3459,7 @@ function isProviderInfraDomain(uri) {
|
|
|
3557
3459
|
return false;
|
|
3558
3460
|
}
|
|
3559
3461
|
function parseGroundingSources(rawResponse) {
|
|
3560
|
-
const parsed =
|
|
3462
|
+
const parsed = parseJsonColumn(rawResponse, {});
|
|
3561
3463
|
const sources = parsed.groundingSources;
|
|
3562
3464
|
if (!Array.isArray(sources)) return [];
|
|
3563
3465
|
return sources.filter(
|
|
@@ -5338,6 +5240,45 @@ var routeCatalog = [
|
|
|
5338
5240
|
404: { description: "Project, connection, or page not found." }
|
|
5339
5241
|
}
|
|
5340
5242
|
},
|
|
5243
|
+
{
|
|
5244
|
+
method: "post",
|
|
5245
|
+
path: "/api/v1/projects/{name}/wordpress/pages/meta/bulk",
|
|
5246
|
+
summary: "Bulk update SEO meta for multiple pages",
|
|
5247
|
+
tags: ["wordpress"],
|
|
5248
|
+
parameters: [nameParameter],
|
|
5249
|
+
requestBody: {
|
|
5250
|
+
required: true,
|
|
5251
|
+
content: {
|
|
5252
|
+
"application/json": {
|
|
5253
|
+
schema: {
|
|
5254
|
+
type: "object",
|
|
5255
|
+
required: ["entries"],
|
|
5256
|
+
properties: {
|
|
5257
|
+
entries: {
|
|
5258
|
+
type: "array",
|
|
5259
|
+
items: {
|
|
5260
|
+
type: "object",
|
|
5261
|
+
required: ["slug"],
|
|
5262
|
+
properties: {
|
|
5263
|
+
slug: stringSchema,
|
|
5264
|
+
title: stringSchema,
|
|
5265
|
+
description: stringSchema,
|
|
5266
|
+
noindex: booleanSchema
|
|
5267
|
+
}
|
|
5268
|
+
}
|
|
5269
|
+
},
|
|
5270
|
+
env: { type: "string", enum: ["live", "staging"] }
|
|
5271
|
+
}
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5275
|
+
},
|
|
5276
|
+
responses: {
|
|
5277
|
+
200: { description: "Bulk SEO meta update results returned." },
|
|
5278
|
+
400: { description: "Invalid entries or environment." },
|
|
5279
|
+
404: { description: "Project or connection not found." }
|
|
5280
|
+
}
|
|
5281
|
+
},
|
|
5341
5282
|
{
|
|
5342
5283
|
method: "get",
|
|
5343
5284
|
path: "/api/v1/projects/{name}/wordpress/schema",
|
|
@@ -5379,6 +5320,48 @@ var routeCatalog = [
|
|
|
5379
5320
|
404: { description: "Project, connection, or page not found." }
|
|
5380
5321
|
}
|
|
5381
5322
|
},
|
|
5323
|
+
{
|
|
5324
|
+
method: "post",
|
|
5325
|
+
path: "/api/v1/projects/{name}/wordpress/schema/deploy",
|
|
5326
|
+
summary: "Deploy JSON-LD schema to WordPress pages",
|
|
5327
|
+
tags: ["wordpress"],
|
|
5328
|
+
parameters: [nameParameter],
|
|
5329
|
+
requestBody: {
|
|
5330
|
+
required: true,
|
|
5331
|
+
content: {
|
|
5332
|
+
"application/json": {
|
|
5333
|
+
schema: {
|
|
5334
|
+
type: "object",
|
|
5335
|
+
required: ["profile"],
|
|
5336
|
+
properties: {
|
|
5337
|
+
profile: {
|
|
5338
|
+
type: "object",
|
|
5339
|
+
description: "Business profile and per-slug schema mapping"
|
|
5340
|
+
},
|
|
5341
|
+
env: { type: "string", enum: ["live", "staging"] }
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
},
|
|
5347
|
+
responses: {
|
|
5348
|
+
200: { description: "Schema deployment results returned." },
|
|
5349
|
+
400: { description: "Invalid profile or environment." },
|
|
5350
|
+
404: { description: "Project or connection not found." }
|
|
5351
|
+
}
|
|
5352
|
+
},
|
|
5353
|
+
{
|
|
5354
|
+
method: "get",
|
|
5355
|
+
path: "/api/v1/projects/{name}/wordpress/schema/status",
|
|
5356
|
+
summary: "Get JSON-LD schema status for all pages",
|
|
5357
|
+
tags: ["wordpress"],
|
|
5358
|
+
parameters: [nameParameter, wordpressEnvQueryParameter],
|
|
5359
|
+
responses: {
|
|
5360
|
+
200: { description: "Schema status per page returned." },
|
|
5361
|
+
400: { description: "Invalid environment." },
|
|
5362
|
+
404: { description: "Project or connection not found." }
|
|
5363
|
+
}
|
|
5364
|
+
},
|
|
5382
5365
|
{
|
|
5383
5366
|
method: "get",
|
|
5384
5367
|
path: "/api/v1/projects/{name}/wordpress/llms-txt",
|
|
@@ -5466,6 +5449,39 @@ var routeCatalog = [
|
|
|
5466
5449
|
404: { description: "Project or connection not found." }
|
|
5467
5450
|
}
|
|
5468
5451
|
},
|
|
5452
|
+
{
|
|
5453
|
+
method: "post",
|
|
5454
|
+
path: "/api/v1/projects/{name}/wordpress/onboard",
|
|
5455
|
+
summary: "Full WordPress onboarding workflow",
|
|
5456
|
+
tags: ["wordpress"],
|
|
5457
|
+
parameters: [nameParameter],
|
|
5458
|
+
requestBody: {
|
|
5459
|
+
required: true,
|
|
5460
|
+
content: {
|
|
5461
|
+
"application/json": {
|
|
5462
|
+
schema: {
|
|
5463
|
+
type: "object",
|
|
5464
|
+
required: ["url", "username", "appPassword"],
|
|
5465
|
+
properties: {
|
|
5466
|
+
url: stringSchema,
|
|
5467
|
+
stagingUrl: stringSchema,
|
|
5468
|
+
username: stringSchema,
|
|
5469
|
+
appPassword: stringSchema,
|
|
5470
|
+
defaultEnv: { type: "string", enum: ["live", "staging"] },
|
|
5471
|
+
profile: objectSchema,
|
|
5472
|
+
skipSchema: booleanSchema,
|
|
5473
|
+
skipSubmit: booleanSchema
|
|
5474
|
+
}
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
},
|
|
5479
|
+
responses: {
|
|
5480
|
+
200: { description: "Onboarding result with step-by-step status." },
|
|
5481
|
+
400: { description: "Invalid onboarding request." },
|
|
5482
|
+
404: { description: "Project not found." }
|
|
5483
|
+
}
|
|
5484
|
+
},
|
|
5469
5485
|
// GA4 routes
|
|
5470
5486
|
{
|
|
5471
5487
|
method: "post",
|
|
@@ -5519,7 +5535,7 @@ var routeCatalog = [
|
|
|
5519
5535
|
{
|
|
5520
5536
|
method: "post",
|
|
5521
5537
|
path: "/api/v1/projects/{name}/ga/sync",
|
|
5522
|
-
summary: "Sync GA4 traffic data",
|
|
5538
|
+
summary: "Sync GA4 traffic and AI referral data",
|
|
5523
5539
|
tags: ["ga4"],
|
|
5524
5540
|
parameters: [nameParameter],
|
|
5525
5541
|
requestBody: {
|
|
@@ -5544,7 +5560,7 @@ var routeCatalog = [
|
|
|
5544
5560
|
{
|
|
5545
5561
|
method: "get",
|
|
5546
5562
|
path: "/api/v1/projects/{name}/ga/traffic",
|
|
5547
|
-
summary: "Get GA4 landing page traffic",
|
|
5563
|
+
summary: "Get GA4 landing page traffic and AI referral sources",
|
|
5548
5564
|
tags: ["ga4"],
|
|
5549
5565
|
parameters: [nameParameter, limitQueryParameter],
|
|
5550
5566
|
responses: {
|
|
@@ -5813,34 +5829,29 @@ import crypto11 from "crypto";
|
|
|
5813
5829
|
import { eq as eq11 } from "drizzle-orm";
|
|
5814
5830
|
async function scheduleRoutes(app, opts) {
|
|
5815
5831
|
app.put("/projects/:name/schedule", async (request, reply) => {
|
|
5816
|
-
const project =
|
|
5817
|
-
if (!project) return;
|
|
5832
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5818
5833
|
const parsedBody = scheduleUpsertRequestSchema.safeParse(request.body);
|
|
5819
5834
|
if (!parsedBody.success) {
|
|
5820
|
-
|
|
5835
|
+
throw validationError("Invalid schedule payload", {
|
|
5821
5836
|
issues: parsedBody.error.issues.map((issue) => ({
|
|
5822
5837
|
path: issue.path.join("."),
|
|
5823
5838
|
message: issue.message
|
|
5824
5839
|
}))
|
|
5825
5840
|
});
|
|
5826
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
5827
5841
|
}
|
|
5828
5842
|
const { preset, cron: cron2, timezone, providers, enabled } = parsedBody.data;
|
|
5829
5843
|
const validNames = opts.validProviderNames ?? [];
|
|
5830
5844
|
if (validNames.length && providers?.length) {
|
|
5831
5845
|
const invalid = providers.filter((p) => !validNames.includes(p));
|
|
5832
5846
|
if (invalid.length) {
|
|
5833
|
-
|
|
5847
|
+
throw validationError(`Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validNames.join(", ")}`, {
|
|
5834
5848
|
invalidProviders: invalid,
|
|
5835
5849
|
validProviders: validNames
|
|
5836
5850
|
});
|
|
5837
|
-
return reply.status(err.statusCode).send(err.toJSON());
|
|
5838
5851
|
}
|
|
5839
5852
|
}
|
|
5840
5853
|
if (!isValidTimezone(timezone)) {
|
|
5841
|
-
|
|
5842
|
-
error: { code: "VALIDATION_ERROR", message: `Invalid timezone: ${timezone}` }
|
|
5843
|
-
});
|
|
5854
|
+
throw validationError(`Invalid timezone: ${timezone}`);
|
|
5844
5855
|
}
|
|
5845
5856
|
let cronExpr;
|
|
5846
5857
|
if (preset) {
|
|
@@ -5848,14 +5859,12 @@ async function scheduleRoutes(app, opts) {
|
|
|
5848
5859
|
cronExpr = resolvePreset(preset);
|
|
5849
5860
|
} catch (err) {
|
|
5850
5861
|
const msg = err instanceof Error ? err.message : String(err);
|
|
5851
|
-
|
|
5862
|
+
throw validationError(msg);
|
|
5852
5863
|
}
|
|
5853
5864
|
} else {
|
|
5854
5865
|
cronExpr = cron2;
|
|
5855
5866
|
if (!validateCron(cronExpr)) {
|
|
5856
|
-
|
|
5857
|
-
error: { code: "VALIDATION_ERROR", message: `Invalid cron expression: ${cronExpr}` }
|
|
5858
|
-
});
|
|
5867
|
+
throw validationError(`Invalid cron expression: ${cronExpr}`);
|
|
5859
5868
|
}
|
|
5860
5869
|
}
|
|
5861
5870
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -5895,20 +5904,18 @@ async function scheduleRoutes(app, opts) {
|
|
|
5895
5904
|
return reply.status(existing ? 200 : 201).send(formatSchedule(schedule));
|
|
5896
5905
|
});
|
|
5897
5906
|
app.get("/projects/:name/schedule", async (request, reply) => {
|
|
5898
|
-
const project =
|
|
5899
|
-
if (!project) return;
|
|
5907
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5900
5908
|
const schedule = app.db.select().from(schedules).where(eq11(schedules.projectId, project.id)).get();
|
|
5901
5909
|
if (!schedule) {
|
|
5902
|
-
|
|
5910
|
+
throw notFound("Schedule", request.params.name);
|
|
5903
5911
|
}
|
|
5904
5912
|
return reply.send(formatSchedule(schedule));
|
|
5905
5913
|
});
|
|
5906
5914
|
app.delete("/projects/:name/schedule", async (request, reply) => {
|
|
5907
|
-
const project =
|
|
5908
|
-
if (!project) return;
|
|
5915
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5909
5916
|
const schedule = app.db.select().from(schedules).where(eq11(schedules.projectId, project.id)).get();
|
|
5910
5917
|
if (!schedule) {
|
|
5911
|
-
|
|
5918
|
+
throw notFound("Schedule", request.params.name);
|
|
5912
5919
|
}
|
|
5913
5920
|
app.db.delete(schedules).where(eq11(schedules.id, schedule.id)).run();
|
|
5914
5921
|
writeAuditLog(app.db, {
|
|
@@ -5930,25 +5937,13 @@ function formatSchedule(row) {
|
|
|
5930
5937
|
preset: row.preset,
|
|
5931
5938
|
timezone: row.timezone,
|
|
5932
5939
|
enabled: row.enabled === 1,
|
|
5933
|
-
providers:
|
|
5940
|
+
providers: parseJsonColumn(row.providers, []),
|
|
5934
5941
|
lastRunAt: row.lastRunAt,
|
|
5935
5942
|
nextRunAt: row.nextRunAt,
|
|
5936
5943
|
createdAt: row.createdAt,
|
|
5937
5944
|
updatedAt: row.updatedAt
|
|
5938
5945
|
};
|
|
5939
5946
|
}
|
|
5940
|
-
function resolveProjectSafe6(app, name, reply) {
|
|
5941
|
-
try {
|
|
5942
|
-
return resolveProject(app.db, name);
|
|
5943
|
-
} catch (e) {
|
|
5944
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
5945
|
-
const err = e;
|
|
5946
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
5947
|
-
return null;
|
|
5948
|
-
}
|
|
5949
|
-
throw e;
|
|
5950
|
-
}
|
|
5951
|
-
}
|
|
5952
5947
|
|
|
5953
5948
|
// ../api-routes/src/notifications.ts
|
|
5954
5949
|
import crypto12 from "crypto";
|
|
@@ -5959,30 +5954,15 @@ async function notificationRoutes(app) {
|
|
|
5959
5954
|
return reply.send(VALID_EVENTS);
|
|
5960
5955
|
});
|
|
5961
5956
|
app.post("/projects/:name/notifications", async (request, reply) => {
|
|
5962
|
-
const project =
|
|
5963
|
-
if (!project) return;
|
|
5957
|
+
const project = resolveProject(app.db, request.params.name);
|
|
5964
5958
|
const { channel, url, events } = request.body ?? {};
|
|
5965
|
-
if (channel !== "webhook")
|
|
5966
|
-
return reply.status(400).send({
|
|
5967
|
-
error: { code: "VALIDATION_ERROR", message: 'Only "webhook" channel is supported' }
|
|
5968
|
-
});
|
|
5969
|
-
}
|
|
5959
|
+
if (channel !== "webhook") throw validationError('Only "webhook" channel is supported');
|
|
5970
5960
|
const urlCheck = await resolveWebhookTarget(url ?? "");
|
|
5971
|
-
if (!urlCheck.ok)
|
|
5972
|
-
|
|
5973
|
-
error: { code: "VALIDATION_ERROR", message: urlCheck.message }
|
|
5974
|
-
});
|
|
5975
|
-
}
|
|
5976
|
-
if (!events?.length) {
|
|
5977
|
-
return reply.status(400).send({
|
|
5978
|
-
error: { code: "VALIDATION_ERROR", message: '"events" must be a non-empty array' }
|
|
5979
|
-
});
|
|
5980
|
-
}
|
|
5961
|
+
if (!urlCheck.ok) throw validationError(urlCheck.message);
|
|
5962
|
+
if (!events?.length) throw validationError('"events" must be a non-empty array');
|
|
5981
5963
|
const invalid = events.filter((e) => !VALID_EVENTS.includes(e));
|
|
5982
5964
|
if (invalid.length) {
|
|
5983
|
-
|
|
5984
|
-
error: { code: "VALIDATION_ERROR", message: `Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}` }
|
|
5985
|
-
});
|
|
5965
|
+
throw validationError(`Invalid event(s): ${invalid.join(", ")}. Must be one of: ${VALID_EVENTS.join(", ")}`);
|
|
5986
5966
|
}
|
|
5987
5967
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
5988
5968
|
const id = crypto12.randomUUID();
|
|
@@ -6011,19 +5991,15 @@ async function notificationRoutes(app) {
|
|
|
6011
5991
|
});
|
|
6012
5992
|
});
|
|
6013
5993
|
app.get("/projects/:name/notifications", async (request, reply) => {
|
|
6014
|
-
const project =
|
|
6015
|
-
if (!project) return;
|
|
5994
|
+
const project = resolveProject(app.db, request.params.name);
|
|
6016
5995
|
const rows = app.db.select().from(notifications).where(eq12(notifications.projectId, project.id)).all();
|
|
6017
5996
|
return reply.send(rows.map(formatNotification));
|
|
6018
5997
|
});
|
|
6019
5998
|
app.delete("/projects/:name/notifications/:id", async (request, reply) => {
|
|
6020
|
-
const project =
|
|
6021
|
-
if (!project) return;
|
|
5999
|
+
const project = resolveProject(app.db, request.params.name);
|
|
6022
6000
|
const notification = app.db.select().from(notifications).where(eq12(notifications.id, request.params.id)).get();
|
|
6023
6001
|
if (!notification || notification.projectId !== project.id) {
|
|
6024
|
-
|
|
6025
|
-
error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
|
|
6026
|
-
});
|
|
6002
|
+
throw notFound("Notification", request.params.id);
|
|
6027
6003
|
}
|
|
6028
6004
|
app.db.delete(notifications).where(eq12(notifications.id, notification.id)).run();
|
|
6029
6005
|
writeAuditLog(app.db, {
|
|
@@ -6036,21 +6012,14 @@ async function notificationRoutes(app) {
|
|
|
6036
6012
|
return reply.status(204).send();
|
|
6037
6013
|
});
|
|
6038
6014
|
app.post("/projects/:name/notifications/:id/test", async (request, reply) => {
|
|
6039
|
-
const project =
|
|
6040
|
-
if (!project) return;
|
|
6015
|
+
const project = resolveProject(app.db, request.params.name);
|
|
6041
6016
|
const notification = app.db.select().from(notifications).where(eq12(notifications.id, request.params.id)).get();
|
|
6042
6017
|
if (!notification || notification.projectId !== project.id) {
|
|
6043
|
-
|
|
6044
|
-
error: { code: "NOT_FOUND", message: `Notification '${request.params.id}' not found` }
|
|
6045
|
-
});
|
|
6018
|
+
throw notFound("Notification", request.params.id);
|
|
6046
6019
|
}
|
|
6047
|
-
const config =
|
|
6020
|
+
const config = parseJsonColumn(notification.config, { url: "", events: [] });
|
|
6048
6021
|
const urlCheck = await resolveWebhookTarget(config.url);
|
|
6049
|
-
if (!urlCheck.ok) {
|
|
6050
|
-
return reply.status(400).send({
|
|
6051
|
-
error: { code: "VALIDATION_ERROR", message: `Stored webhook URL is invalid: ${urlCheck.message}` }
|
|
6052
|
-
});
|
|
6053
|
-
}
|
|
6022
|
+
if (!urlCheck.ok) throw validationError(`Stored webhook URL is invalid: ${urlCheck.message}`);
|
|
6054
6023
|
const payload = {
|
|
6055
6024
|
source: "canonry",
|
|
6056
6025
|
event: "run.completed",
|
|
@@ -6073,14 +6042,12 @@ async function notificationRoutes(app) {
|
|
|
6073
6042
|
entityId: notification.id,
|
|
6074
6043
|
diff: { status, error }
|
|
6075
6044
|
});
|
|
6076
|
-
if (error)
|
|
6077
|
-
return reply.status(502).send({ error: { code: "DELIVERY_FAILED", message: error } });
|
|
6078
|
-
}
|
|
6045
|
+
if (error) throw deliveryFailed(error);
|
|
6079
6046
|
return reply.send({ status, ok: status >= 200 && status < 300 });
|
|
6080
6047
|
});
|
|
6081
6048
|
}
|
|
6082
6049
|
function formatNotification(row) {
|
|
6083
|
-
const config =
|
|
6050
|
+
const config = parseJsonColumn(row.config, { url: "", events: [] });
|
|
6084
6051
|
const redacted = redactNotificationUrl(config.url);
|
|
6085
6052
|
return {
|
|
6086
6053
|
id: row.id,
|
|
@@ -6095,22 +6062,10 @@ function formatNotification(row) {
|
|
|
6095
6062
|
updatedAt: row.updatedAt
|
|
6096
6063
|
};
|
|
6097
6064
|
}
|
|
6098
|
-
function resolveProjectSafe7(app, name, reply) {
|
|
6099
|
-
try {
|
|
6100
|
-
return resolveProject(app.db, name);
|
|
6101
|
-
} catch (e) {
|
|
6102
|
-
if (e && typeof e === "object" && "statusCode" in e && "toJSON" in e) {
|
|
6103
|
-
const err = e;
|
|
6104
|
-
reply.status(err.statusCode).send(err.toJSON());
|
|
6105
|
-
return null;
|
|
6106
|
-
}
|
|
6107
|
-
throw e;
|
|
6108
|
-
}
|
|
6109
|
-
}
|
|
6110
6065
|
|
|
6111
6066
|
// ../api-routes/src/google.ts
|
|
6112
6067
|
import crypto13 from "crypto";
|
|
6113
|
-
import { eq as eq13, and as
|
|
6068
|
+
import { eq as eq13, and as and2, desc as desc4, sql as sql3 } from "drizzle-orm";
|
|
6114
6069
|
|
|
6115
6070
|
// ../integration-google/src/constants.ts
|
|
6116
6071
|
var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
@@ -6557,11 +6512,11 @@ async function googleRoutes(app, opts) {
|
|
|
6557
6512
|
const project = resolveProject(app.db, request.params.name);
|
|
6558
6513
|
const { startDate, endDate, query, page, limit } = request.query;
|
|
6559
6514
|
const conditions = [eq13(gscSearchData.projectId, project.id)];
|
|
6560
|
-
if (startDate) conditions.push(
|
|
6561
|
-
if (endDate) conditions.push(
|
|
6562
|
-
if (query) conditions.push(
|
|
6563
|
-
if (page) conditions.push(
|
|
6564
|
-
const rows = app.db.select().from(gscSearchData).where(
|
|
6515
|
+
if (startDate) conditions.push(sql3`${gscSearchData.date} >= ${startDate}`);
|
|
6516
|
+
if (endDate) conditions.push(sql3`${gscSearchData.date} <= ${endDate}`);
|
|
6517
|
+
if (query) conditions.push(sql3`${gscSearchData.query} LIKE ${"%" + query + "%"}`);
|
|
6518
|
+
if (page) conditions.push(sql3`${gscSearchData.page} LIKE ${"%" + page + "%"}`);
|
|
6519
|
+
const rows = app.db.select().from(gscSearchData).where(and2(...conditions)).orderBy(desc4(gscSearchData.date)).limit(parseInt(limit ?? "500", 10)).all();
|
|
6565
6520
|
return rows.map((r) => ({
|
|
6566
6521
|
date: r.date,
|
|
6567
6522
|
query: r.query,
|
|
@@ -6639,7 +6594,7 @@ async function googleRoutes(app, opts) {
|
|
|
6639
6594
|
const { url, limit } = request.query;
|
|
6640
6595
|
const conditions = [eq13(gscUrlInspections.projectId, project.id)];
|
|
6641
6596
|
if (url) conditions.push(eq13(gscUrlInspections.url, url));
|
|
6642
|
-
const rows = app.db.select().from(gscUrlInspections).where(
|
|
6597
|
+
const rows = app.db.select().from(gscUrlInspections).where(and2(...conditions)).orderBy(desc4(gscUrlInspections.inspectedAt)).limit(parseInt(limit ?? "100", 10)).all();
|
|
6643
6598
|
return rows.map((r) => ({
|
|
6644
6599
|
id: r.id,
|
|
6645
6600
|
url: r.url,
|
|
@@ -7011,7 +6966,7 @@ async function googleRoutes(app, opts) {
|
|
|
7011
6966
|
|
|
7012
6967
|
// ../api-routes/src/bing.ts
|
|
7013
6968
|
import crypto14 from "crypto";
|
|
7014
|
-
import { eq as eq14, and as
|
|
6969
|
+
import { eq as eq14, and as and3, desc as desc5 } from "drizzle-orm";
|
|
7015
6970
|
|
|
7016
6971
|
// ../integration-bing/src/constants.ts
|
|
7017
6972
|
var BING_WMT_API_BASE = "https://ssl.bing.com/webmaster/api.svc/json";
|
|
@@ -7310,7 +7265,7 @@ async function bingRoutes(app, opts) {
|
|
|
7310
7265
|
if (!store) return;
|
|
7311
7266
|
const project = resolveProject(app.db, request.params.name);
|
|
7312
7267
|
const { url, limit } = request.query;
|
|
7313
|
-
const whereClause = url ?
|
|
7268
|
+
const whereClause = url ? and3(eq14(bingUrlInspections.projectId, project.id), eq14(bingUrlInspections.url, url)) : eq14(bingUrlInspections.projectId, project.id);
|
|
7314
7269
|
const filtered = app.db.select().from(bingUrlInspections).where(whereClause).orderBy(desc5(bingUrlInspections.inspectedAt)).limit(Math.max(1, Math.min(parseInt(limit ?? "100", 10) || 100, 1e3))).all();
|
|
7315
7270
|
return filtered.map((r) => ({
|
|
7316
7271
|
id: r.id,
|
|
@@ -7500,7 +7455,7 @@ async function bingRoutes(app, opts) {
|
|
|
7500
7455
|
import fs2 from "fs";
|
|
7501
7456
|
import path2 from "path";
|
|
7502
7457
|
import os2 from "os";
|
|
7503
|
-
import { eq as eq15, and as
|
|
7458
|
+
import { eq as eq15, and as and4 } from "drizzle-orm";
|
|
7504
7459
|
function getScreenshotDir() {
|
|
7505
7460
|
return path2.join(os2.homedir(), ".canonry", "screenshots");
|
|
7506
7461
|
}
|
|
@@ -7573,7 +7528,7 @@ async function cdpRoutes(app, opts) {
|
|
|
7573
7528
|
async (request, reply) => {
|
|
7574
7529
|
const project = resolveProject(app.db, request.params.name);
|
|
7575
7530
|
const { runId } = request.params;
|
|
7576
|
-
const run = app.db.select().from(runs).where(
|
|
7531
|
+
const run = app.db.select().from(runs).where(and4(eq15(runs.id, runId), eq15(runs.projectId, project.id))).get();
|
|
7577
7532
|
if (!run) {
|
|
7578
7533
|
const err = notFound("Run", runId);
|
|
7579
7534
|
return reply.code(err.statusCode).send(err.toJSON());
|
|
@@ -7670,7 +7625,7 @@ async function cdpRoutes(app, opts) {
|
|
|
7670
7625
|
|
|
7671
7626
|
// ../api-routes/src/ga.ts
|
|
7672
7627
|
import crypto16 from "crypto";
|
|
7673
|
-
import { eq as eq16, desc as desc6, and as
|
|
7628
|
+
import { eq as eq16, desc as desc6, and as and5, sql as sql4 } from "drizzle-orm";
|
|
7674
7629
|
|
|
7675
7630
|
// ../integration-google-analytics/src/ga4-client.ts
|
|
7676
7631
|
import crypto15 from "crypto";
|
|
@@ -7809,6 +7764,15 @@ async function batchRunReports(accessToken, propertyId, requests) {
|
|
|
7809
7764
|
function formatDate(d) {
|
|
7810
7765
|
return d.toISOString().split("T")[0];
|
|
7811
7766
|
}
|
|
7767
|
+
var AI_REFERRAL_SOURCE_FILTERS = [
|
|
7768
|
+
{ matchType: "CONTAINS", value: "perplexity" },
|
|
7769
|
+
{ matchType: "CONTAINS", value: "gemini" },
|
|
7770
|
+
{ matchType: "CONTAINS", value: "chatgpt" },
|
|
7771
|
+
{ matchType: "CONTAINS", value: "openai" },
|
|
7772
|
+
{ matchType: "CONTAINS", value: "claude" },
|
|
7773
|
+
{ matchType: "CONTAINS", value: "anthropic" },
|
|
7774
|
+
{ matchType: "CONTAINS", value: "copilot" }
|
|
7775
|
+
];
|
|
7812
7776
|
async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
7813
7777
|
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
7814
7778
|
const endDate = /* @__PURE__ */ new Date();
|
|
@@ -7935,6 +7899,61 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
|
7935
7899
|
ga4Log("info", "fetch-aggregate.done", { propertyId, ...summary });
|
|
7936
7900
|
return summary;
|
|
7937
7901
|
}
|
|
7902
|
+
async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
7903
|
+
const syncDays = Math.min(Math.max(1, days ?? GA4_DEFAULT_SYNC_DAYS), GA4_MAX_SYNC_DAYS);
|
|
7904
|
+
const endDate = /* @__PURE__ */ new Date();
|
|
7905
|
+
const startDate = /* @__PURE__ */ new Date();
|
|
7906
|
+
startDate.setDate(startDate.getDate() - syncDays);
|
|
7907
|
+
ga4Log("info", "fetch-ai-referrals.start", { propertyId, days: syncDays });
|
|
7908
|
+
const PAGE_SIZE = 1e3;
|
|
7909
|
+
const rows = [];
|
|
7910
|
+
let offset = 0;
|
|
7911
|
+
while (true) {
|
|
7912
|
+
const request = {
|
|
7913
|
+
dateRanges: [{ startDate: formatDate(startDate), endDate: formatDate(endDate) }],
|
|
7914
|
+
dimensions: [
|
|
7915
|
+
{ name: "date" },
|
|
7916
|
+
{ name: "sessionSource" },
|
|
7917
|
+
{ name: "sessionMedium" }
|
|
7918
|
+
],
|
|
7919
|
+
metrics: [
|
|
7920
|
+
{ name: "sessions" },
|
|
7921
|
+
{ name: "totalUsers" }
|
|
7922
|
+
],
|
|
7923
|
+
dimensionFilter: {
|
|
7924
|
+
orGroup: {
|
|
7925
|
+
expressions: AI_REFERRAL_SOURCE_FILTERS.map(({ matchType, value }) => ({
|
|
7926
|
+
filter: {
|
|
7927
|
+
fieldName: "sessionSource",
|
|
7928
|
+
stringFilter: { matchType, value }
|
|
7929
|
+
}
|
|
7930
|
+
}))
|
|
7931
|
+
}
|
|
7932
|
+
},
|
|
7933
|
+
limit: PAGE_SIZE,
|
|
7934
|
+
offset
|
|
7935
|
+
};
|
|
7936
|
+
const response = await runReport(accessToken, propertyId, request);
|
|
7937
|
+
const pageRows = (response.rows ?? []).map((row) => ({
|
|
7938
|
+
date: row.dimensionValues[0].value,
|
|
7939
|
+
source: row.dimensionValues[1].value,
|
|
7940
|
+
medium: row.dimensionValues[2].value,
|
|
7941
|
+
sessions: parseInt(row.metricValues[0].value, 10) || 0,
|
|
7942
|
+
users: parseInt(row.metricValues[1].value, 10) || 0
|
|
7943
|
+
}));
|
|
7944
|
+
rows.push(...pageRows);
|
|
7945
|
+
const totalRows = response.rowCount ?? 0;
|
|
7946
|
+
offset += pageRows.length;
|
|
7947
|
+
if (pageRows.length < PAGE_SIZE || offset >= totalRows) break;
|
|
7948
|
+
}
|
|
7949
|
+
for (const row of rows) {
|
|
7950
|
+
if (row.date.length === 8 && !row.date.includes("-")) {
|
|
7951
|
+
row.date = `${row.date.slice(0, 4)}-${row.date.slice(4, 6)}-${row.date.slice(6, 8)}`;
|
|
7952
|
+
}
|
|
7953
|
+
}
|
|
7954
|
+
ga4Log("info", "fetch-ai-referrals.done", { propertyId, rowCount: rows.length });
|
|
7955
|
+
return rows;
|
|
7956
|
+
}
|
|
7938
7957
|
|
|
7939
7958
|
// ../api-routes/src/ga.ts
|
|
7940
7959
|
function gaLog(level, action, ctx) {
|
|
@@ -8020,6 +8039,7 @@ async function ga4Routes(app, opts) {
|
|
|
8020
8039
|
}
|
|
8021
8040
|
app.db.delete(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).run();
|
|
8022
8041
|
app.db.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
|
|
8042
|
+
app.db.delete(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).run();
|
|
8023
8043
|
store.deleteConnection(project.name);
|
|
8024
8044
|
writeAuditLog(app.db, {
|
|
8025
8045
|
projectId: project.id,
|
|
@@ -8038,7 +8058,7 @@ async function ga4Routes(app, opts) {
|
|
|
8038
8058
|
if (!conn) {
|
|
8039
8059
|
return { connected: false, propertyId: null, clientEmail: null, lastSyncedAt: null };
|
|
8040
8060
|
}
|
|
8041
|
-
const latestSync = app.db.select({ syncedAt:
|
|
8061
|
+
const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
8042
8062
|
return {
|
|
8043
8063
|
connected: true,
|
|
8044
8064
|
propertyId: conn.propertyId,
|
|
@@ -8069,11 +8089,13 @@ async function ga4Routes(app, opts) {
|
|
|
8069
8089
|
}
|
|
8070
8090
|
let rows;
|
|
8071
8091
|
let summary;
|
|
8092
|
+
let aiReferrals;
|
|
8072
8093
|
try {
|
|
8073
8094
|
;
|
|
8074
|
-
[rows, summary] = await Promise.all([
|
|
8095
|
+
[rows, summary, aiReferrals] = await Promise.all([
|
|
8075
8096
|
fetchTrafficByLandingPage(accessToken, conn.propertyId, days),
|
|
8076
|
-
fetchAggregateSummary(accessToken, conn.propertyId, days)
|
|
8097
|
+
fetchAggregateSummary(accessToken, conn.propertyId, days),
|
|
8098
|
+
fetchAiReferrals(accessToken, conn.propertyId, days)
|
|
8077
8099
|
]);
|
|
8078
8100
|
} catch (e) {
|
|
8079
8101
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -8082,17 +8104,14 @@ async function ga4Routes(app, opts) {
|
|
|
8082
8104
|
}
|
|
8083
8105
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8084
8106
|
app.db.transaction((tx) => {
|
|
8107
|
+
tx.delete(gaTrafficSnapshots).where(
|
|
8108
|
+
and5(
|
|
8109
|
+
eq16(gaTrafficSnapshots.projectId, project.id),
|
|
8110
|
+
sql4`${gaTrafficSnapshots.date} >= ${summary.periodStart}`,
|
|
8111
|
+
sql4`${gaTrafficSnapshots.date} <= ${summary.periodEnd}`
|
|
8112
|
+
)
|
|
8113
|
+
).run();
|
|
8085
8114
|
if (rows.length > 0) {
|
|
8086
|
-
const dates = rows.map((r) => r.date);
|
|
8087
|
-
const minDate = dates.reduce((a, b) => a < b ? a : b);
|
|
8088
|
-
const maxDate = dates.reduce((a, b) => a > b ? a : b);
|
|
8089
|
-
tx.delete(gaTrafficSnapshots).where(
|
|
8090
|
-
and6(
|
|
8091
|
-
eq16(gaTrafficSnapshots.projectId, project.id),
|
|
8092
|
-
sql3`${gaTrafficSnapshots.date} >= ${minDate}`,
|
|
8093
|
-
sql3`${gaTrafficSnapshots.date} <= ${maxDate}`
|
|
8094
|
-
)
|
|
8095
|
-
).run();
|
|
8096
8115
|
for (const row of rows) {
|
|
8097
8116
|
tx.insert(gaTrafficSnapshots).values({
|
|
8098
8117
|
id: crypto16.randomUUID(),
|
|
@@ -8106,6 +8125,27 @@ async function ga4Routes(app, opts) {
|
|
|
8106
8125
|
}).run();
|
|
8107
8126
|
}
|
|
8108
8127
|
}
|
|
8128
|
+
tx.delete(gaAiReferrals).where(
|
|
8129
|
+
and5(
|
|
8130
|
+
eq16(gaAiReferrals.projectId, project.id),
|
|
8131
|
+
sql4`${gaAiReferrals.date} >= ${summary.periodStart}`,
|
|
8132
|
+
sql4`${gaAiReferrals.date} <= ${summary.periodEnd}`
|
|
8133
|
+
)
|
|
8134
|
+
).run();
|
|
8135
|
+
if (aiReferrals.length > 0) {
|
|
8136
|
+
for (const row of aiReferrals) {
|
|
8137
|
+
tx.insert(gaAiReferrals).values({
|
|
8138
|
+
id: crypto16.randomUUID(),
|
|
8139
|
+
projectId: project.id,
|
|
8140
|
+
date: row.date,
|
|
8141
|
+
source: row.source,
|
|
8142
|
+
medium: row.medium,
|
|
8143
|
+
sessions: row.sessions,
|
|
8144
|
+
users: row.users,
|
|
8145
|
+
syncedAt: now
|
|
8146
|
+
}).run();
|
|
8147
|
+
}
|
|
8148
|
+
}
|
|
8109
8149
|
tx.delete(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).run();
|
|
8110
8150
|
tx.insert(gaTrafficSummaries).values({
|
|
8111
8151
|
id: crypto16.randomUUID(),
|
|
@@ -8118,10 +8158,17 @@ async function ga4Routes(app, opts) {
|
|
|
8118
8158
|
syncedAt: now
|
|
8119
8159
|
}).run();
|
|
8120
8160
|
});
|
|
8121
|
-
gaLog("info", "sync.complete", {
|
|
8161
|
+
gaLog("info", "sync.complete", {
|
|
8162
|
+
projectId: project.id,
|
|
8163
|
+
rowCount: rows.length,
|
|
8164
|
+
aiReferralCount: aiReferrals.length,
|
|
8165
|
+
days,
|
|
8166
|
+
totalUsers: summary.totalUsers
|
|
8167
|
+
});
|
|
8122
8168
|
return {
|
|
8123
8169
|
synced: true,
|
|
8124
8170
|
rowCount: rows.length,
|
|
8171
|
+
aiReferralCount: aiReferrals.length,
|
|
8125
8172
|
days,
|
|
8126
8173
|
syncedAt: now
|
|
8127
8174
|
};
|
|
@@ -8143,11 +8190,17 @@ async function ga4Routes(app, opts) {
|
|
|
8143
8190
|
}).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).get();
|
|
8144
8191
|
const rows = app.db.select({
|
|
8145
8192
|
landingPage: gaTrafficSnapshots.landingPage,
|
|
8146
|
-
sessions:
|
|
8147
|
-
organicSessions:
|
|
8148
|
-
users:
|
|
8149
|
-
}).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(
|
|
8150
|
-
const
|
|
8193
|
+
sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
|
|
8194
|
+
organicSessions: sql4`SUM(${gaTrafficSnapshots.organicSessions})`,
|
|
8195
|
+
users: sql4`SUM(${gaTrafficSnapshots.users})`
|
|
8196
|
+
}).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql4`SUM(${gaTrafficSnapshots.sessions}) DESC`).limit(limit).all();
|
|
8197
|
+
const aiReferrals = app.db.select({
|
|
8198
|
+
source: gaAiReferrals.source,
|
|
8199
|
+
medium: gaAiReferrals.medium,
|
|
8200
|
+
sessions: sql4`SUM(${gaAiReferrals.sessions})`,
|
|
8201
|
+
users: sql4`SUM(${gaAiReferrals.users})`
|
|
8202
|
+
}).from(gaAiReferrals).where(eq16(gaAiReferrals.projectId, project.id)).groupBy(gaAiReferrals.source, gaAiReferrals.medium).orderBy(sql4`SUM(${gaAiReferrals.sessions}) DESC`).all();
|
|
8203
|
+
const latestSync = app.db.select({ syncedAt: gaTrafficSummaries.syncedAt }).from(gaTrafficSummaries).where(eq16(gaTrafficSummaries.projectId, project.id)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
8151
8204
|
return {
|
|
8152
8205
|
totalSessions: summary?.totalSessions ?? 0,
|
|
8153
8206
|
totalOrganicSessions: summary?.totalOrganicSessions ?? 0,
|
|
@@ -8158,6 +8211,12 @@ async function ga4Routes(app, opts) {
|
|
|
8158
8211
|
organicSessions: r.organicSessions ?? 0,
|
|
8159
8212
|
users: r.users ?? 0
|
|
8160
8213
|
})),
|
|
8214
|
+
aiReferrals: aiReferrals.map((r) => ({
|
|
8215
|
+
source: r.source,
|
|
8216
|
+
medium: r.medium,
|
|
8217
|
+
sessions: r.sessions ?? 0,
|
|
8218
|
+
users: r.users ?? 0
|
|
8219
|
+
})),
|
|
8161
8220
|
lastSyncedAt: latestSync?.syncedAt ?? null
|
|
8162
8221
|
};
|
|
8163
8222
|
});
|
|
@@ -8172,10 +8231,10 @@ async function ga4Routes(app, opts) {
|
|
|
8172
8231
|
}
|
|
8173
8232
|
const trafficPages = app.db.select({
|
|
8174
8233
|
landingPage: gaTrafficSnapshots.landingPage,
|
|
8175
|
-
sessions:
|
|
8176
|
-
organicSessions:
|
|
8177
|
-
users:
|
|
8178
|
-
}).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(
|
|
8234
|
+
sessions: sql4`SUM(${gaTrafficSnapshots.sessions})`,
|
|
8235
|
+
organicSessions: sql4`SUM(${gaTrafficSnapshots.organicSessions})`,
|
|
8236
|
+
users: sql4`SUM(${gaTrafficSnapshots.users})`
|
|
8237
|
+
}).from(gaTrafficSnapshots).where(eq16(gaTrafficSnapshots.projectId, project.id)).groupBy(gaTrafficSnapshots.landingPage).orderBy(sql4`SUM(${gaTrafficSnapshots.sessions}) DESC`).all();
|
|
8179
8238
|
return {
|
|
8180
8239
|
pages: trafficPages.map((r) => ({
|
|
8181
8240
|
landingPage: r.landingPage,
|
|
@@ -8199,6 +8258,115 @@ var WordpressApiError = class extends Error {
|
|
|
8199
8258
|
}
|
|
8200
8259
|
};
|
|
8201
8260
|
|
|
8261
|
+
// ../integration-wordpress/src/schema-templates.ts
|
|
8262
|
+
var SUPPORTED_TYPES = /* @__PURE__ */ new Set([
|
|
8263
|
+
"LocalBusiness",
|
|
8264
|
+
"Organization",
|
|
8265
|
+
"FAQPage",
|
|
8266
|
+
"Service",
|
|
8267
|
+
"WebPage"
|
|
8268
|
+
]);
|
|
8269
|
+
function isSupportedSchemaType(type) {
|
|
8270
|
+
return SUPPORTED_TYPES.has(type);
|
|
8271
|
+
}
|
|
8272
|
+
function buildAddress(address) {
|
|
8273
|
+
return {
|
|
8274
|
+
"@type": "PostalAddress",
|
|
8275
|
+
...address.street ? { streetAddress: address.street } : {},
|
|
8276
|
+
...address.city ? { addressLocality: address.city } : {},
|
|
8277
|
+
...address.state ? { addressRegion: address.state } : {},
|
|
8278
|
+
...address.zip ? { postalCode: address.zip } : {},
|
|
8279
|
+
...address.country ? { addressCountry: address.country } : {}
|
|
8280
|
+
};
|
|
8281
|
+
}
|
|
8282
|
+
function generateLocalBusiness(profile) {
|
|
8283
|
+
const schema = {
|
|
8284
|
+
"@context": "https://schema.org",
|
|
8285
|
+
"@type": "LocalBusiness",
|
|
8286
|
+
name: profile.name
|
|
8287
|
+
};
|
|
8288
|
+
if (profile.url) schema.url = profile.url;
|
|
8289
|
+
if (profile.description) schema.description = profile.description;
|
|
8290
|
+
if (profile.phone) schema.telephone = profile.phone;
|
|
8291
|
+
if (profile.email) schema.email = profile.email;
|
|
8292
|
+
if (profile.address) schema.address = buildAddress(profile.address);
|
|
8293
|
+
return schema;
|
|
8294
|
+
}
|
|
8295
|
+
function generateOrganization(profile) {
|
|
8296
|
+
const schema = {
|
|
8297
|
+
"@context": "https://schema.org",
|
|
8298
|
+
"@type": "Organization",
|
|
8299
|
+
name: profile.name
|
|
8300
|
+
};
|
|
8301
|
+
if (profile.url) schema.url = profile.url;
|
|
8302
|
+
if (profile.description) schema.description = profile.description;
|
|
8303
|
+
if (profile.phone) schema.telephone = profile.phone;
|
|
8304
|
+
if (profile.email) schema.email = profile.email;
|
|
8305
|
+
if (profile.address) schema.address = buildAddress(profile.address);
|
|
8306
|
+
return schema;
|
|
8307
|
+
}
|
|
8308
|
+
function generateFAQPage(profile, faqs) {
|
|
8309
|
+
const schema = {
|
|
8310
|
+
"@context": "https://schema.org",
|
|
8311
|
+
"@type": "FAQPage",
|
|
8312
|
+
name: profile.name
|
|
8313
|
+
};
|
|
8314
|
+
if (faqs && faqs.length > 0) {
|
|
8315
|
+
schema.mainEntity = faqs.map((faq) => ({
|
|
8316
|
+
"@type": "Question",
|
|
8317
|
+
name: faq.q,
|
|
8318
|
+
acceptedAnswer: {
|
|
8319
|
+
"@type": "Answer",
|
|
8320
|
+
text: faq.a
|
|
8321
|
+
}
|
|
8322
|
+
}));
|
|
8323
|
+
}
|
|
8324
|
+
return schema;
|
|
8325
|
+
}
|
|
8326
|
+
function generateService(profile) {
|
|
8327
|
+
const schema = {
|
|
8328
|
+
"@context": "https://schema.org",
|
|
8329
|
+
"@type": "Service",
|
|
8330
|
+
name: profile.name
|
|
8331
|
+
};
|
|
8332
|
+
if (profile.url) schema.url = profile.url;
|
|
8333
|
+
if (profile.description) schema.description = profile.description;
|
|
8334
|
+
if (profile.address) schema.areaServed = buildAddress(profile.address);
|
|
8335
|
+
return schema;
|
|
8336
|
+
}
|
|
8337
|
+
function generateWebPage(profile) {
|
|
8338
|
+
const schema = {
|
|
8339
|
+
"@context": "https://schema.org",
|
|
8340
|
+
"@type": "WebPage",
|
|
8341
|
+
name: profile.name
|
|
8342
|
+
};
|
|
8343
|
+
if (profile.url) schema.url = profile.url;
|
|
8344
|
+
if (profile.description) schema.description = profile.description;
|
|
8345
|
+
return schema;
|
|
8346
|
+
}
|
|
8347
|
+
function generateSchema(type, profile, overrides) {
|
|
8348
|
+
switch (type) {
|
|
8349
|
+
case "LocalBusiness":
|
|
8350
|
+
return generateLocalBusiness(profile);
|
|
8351
|
+
case "Organization":
|
|
8352
|
+
return generateOrganization(profile);
|
|
8353
|
+
case "FAQPage":
|
|
8354
|
+
return generateFAQPage(profile, overrides?.faqs);
|
|
8355
|
+
case "Service":
|
|
8356
|
+
return generateService(profile);
|
|
8357
|
+
case "WebPage":
|
|
8358
|
+
return generateWebPage(profile);
|
|
8359
|
+
default:
|
|
8360
|
+
throw new Error(`Unsupported schema type: ${type}`);
|
|
8361
|
+
}
|
|
8362
|
+
}
|
|
8363
|
+
function parseSchemaPageEntry(entry) {
|
|
8364
|
+
if (typeof entry === "string") {
|
|
8365
|
+
return { type: entry };
|
|
8366
|
+
}
|
|
8367
|
+
return { type: entry.type, faqs: entry.faqs };
|
|
8368
|
+
}
|
|
8369
|
+
|
|
8202
8370
|
// ../integration-wordpress/src/wordpress-client.ts
|
|
8203
8371
|
import crypto17 from "crypto";
|
|
8204
8372
|
var PAGE_FIELDS = "id,slug,status,link,modified,modified_gmt,title,content,meta";
|
|
@@ -8635,6 +8803,271 @@ async function setSeoMeta(connection, slug, body, env) {
|
|
|
8635
8803
|
);
|
|
8636
8804
|
return getPageDetail(connection, slug, site.env, plugins);
|
|
8637
8805
|
}
|
|
8806
|
+
async function detectSeoWriteStrategy(connection, env) {
|
|
8807
|
+
const site = resolveEnvironment(connection, env);
|
|
8808
|
+
const plugins = await listActivePlugins(connection, site.env);
|
|
8809
|
+
const pages = await listPages(connection, site.env);
|
|
8810
|
+
if (pages.length === 0) {
|
|
8811
|
+
return { strategy: "manual", plugins };
|
|
8812
|
+
}
|
|
8813
|
+
const samplePage = await getPageBySlug(connection, pages[0].slug, site.env);
|
|
8814
|
+
const writeTargets = resolveSeoWriteTargets(samplePage.meta, plugins);
|
|
8815
|
+
return {
|
|
8816
|
+
strategy: writeTargets.length > 0 ? "plugin" : "manual",
|
|
8817
|
+
plugins
|
|
8818
|
+
};
|
|
8819
|
+
}
|
|
8820
|
+
function buildManualMetaAssist(siteUrl, slug, link, meta) {
|
|
8821
|
+
const fields = [];
|
|
8822
|
+
if (meta.title != null) fields.push(`Title: ${meta.title}`);
|
|
8823
|
+
if (meta.description != null) fields.push(`Description: ${meta.description}`);
|
|
8824
|
+
if (meta.noindex != null) fields.push(`Noindex: ${meta.noindex}`);
|
|
8825
|
+
return {
|
|
8826
|
+
manualRequired: true,
|
|
8827
|
+
targetUrl: link ?? `${siteUrl}/${slug}`,
|
|
8828
|
+
adminUrl: `${siteUrl}/wp-admin/`,
|
|
8829
|
+
content: fields.join("\n"),
|
|
8830
|
+
nextSteps: [
|
|
8831
|
+
`Open the WordPress editor for page "${slug}".`,
|
|
8832
|
+
"Install an SEO plugin (Yoast SEO, Rank Math, or AIOSEO) to manage meta fields via REST, or set the values manually in the page editor.",
|
|
8833
|
+
"Apply the meta values listed above.",
|
|
8834
|
+
"Publish/update the page."
|
|
8835
|
+
]
|
|
8836
|
+
};
|
|
8837
|
+
}
|
|
8838
|
+
async function bulkSetSeoMeta(connection, entries, env) {
|
|
8839
|
+
const site = resolveEnvironment(connection, env);
|
|
8840
|
+
const { strategy, plugins } = await detectSeoWriteStrategy(connection, site.env);
|
|
8841
|
+
const results = await mapWithConcurrency(
|
|
8842
|
+
entries,
|
|
8843
|
+
3,
|
|
8844
|
+
async (entry) => {
|
|
8845
|
+
try {
|
|
8846
|
+
const page = await getPageBySlug(connection, entry.slug, site.env);
|
|
8847
|
+
if (strategy === "manual") {
|
|
8848
|
+
return {
|
|
8849
|
+
slug: entry.slug,
|
|
8850
|
+
status: "manual",
|
|
8851
|
+
manualAssist: buildManualMetaAssist(
|
|
8852
|
+
site.siteUrl,
|
|
8853
|
+
entry.slug,
|
|
8854
|
+
page.link,
|
|
8855
|
+
entry
|
|
8856
|
+
)
|
|
8857
|
+
};
|
|
8858
|
+
}
|
|
8859
|
+
const writeTargets = resolveSeoWriteTargets(page.meta, plugins);
|
|
8860
|
+
if (writeTargets.length === 0 || !page.meta) {
|
|
8861
|
+
return {
|
|
8862
|
+
slug: entry.slug,
|
|
8863
|
+
status: "manual",
|
|
8864
|
+
manualAssist: buildManualMetaAssist(
|
|
8865
|
+
site.siteUrl,
|
|
8866
|
+
entry.slug,
|
|
8867
|
+
page.link,
|
|
8868
|
+
entry
|
|
8869
|
+
)
|
|
8870
|
+
};
|
|
8871
|
+
}
|
|
8872
|
+
const patch = {};
|
|
8873
|
+
for (const target of SEO_TARGETS) {
|
|
8874
|
+
if (entry.title != null && writeTargets.includes(target.titleKey)) patch[target.titleKey] = entry.title;
|
|
8875
|
+
if (entry.description != null && writeTargets.includes(target.descriptionKey)) patch[target.descriptionKey] = entry.description;
|
|
8876
|
+
if (entry.noindex != null && writeTargets.includes(target.noindexKey)) {
|
|
8877
|
+
patch[target.noindexKey] = encodeNoindexValue(target.noindexKey, entry.noindex);
|
|
8878
|
+
}
|
|
8879
|
+
}
|
|
8880
|
+
if (Object.keys(patch).length === 0) {
|
|
8881
|
+
return {
|
|
8882
|
+
slug: entry.slug,
|
|
8883
|
+
status: "manual",
|
|
8884
|
+
manualAssist: buildManualMetaAssist(
|
|
8885
|
+
site.siteUrl,
|
|
8886
|
+
entry.slug,
|
|
8887
|
+
page.link,
|
|
8888
|
+
entry
|
|
8889
|
+
)
|
|
8890
|
+
};
|
|
8891
|
+
}
|
|
8892
|
+
await fetchJson(
|
|
8893
|
+
connection,
|
|
8894
|
+
site.siteUrl,
|
|
8895
|
+
`/wp-json/wp/v2/pages/${page.id}`,
|
|
8896
|
+
{
|
|
8897
|
+
method: "POST",
|
|
8898
|
+
body: JSON.stringify({
|
|
8899
|
+
meta: { ...page.meta ?? {}, ...patch }
|
|
8900
|
+
})
|
|
8901
|
+
}
|
|
8902
|
+
);
|
|
8903
|
+
return { slug: entry.slug, status: "applied" };
|
|
8904
|
+
} catch (error) {
|
|
8905
|
+
if (error instanceof WordpressApiError && error.code === "NOT_FOUND") {
|
|
8906
|
+
return { slug: entry.slug, status: "skipped", error: `Page "${entry.slug}" not found` };
|
|
8907
|
+
}
|
|
8908
|
+
return {
|
|
8909
|
+
slug: entry.slug,
|
|
8910
|
+
status: "skipped",
|
|
8911
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8912
|
+
};
|
|
8913
|
+
}
|
|
8914
|
+
}
|
|
8915
|
+
);
|
|
8916
|
+
return { env: site.env, strategy, results };
|
|
8917
|
+
}
|
|
8918
|
+
var CANONRY_SCHEMA_START = "<!-- canonry:schema:start -->";
|
|
8919
|
+
var CANONRY_SCHEMA_END = "<!-- canonry:schema:end -->";
|
|
8920
|
+
function stripCanonrySchema(content) {
|
|
8921
|
+
const regex = new RegExp(
|
|
8922
|
+
`${escapeRegExp(CANONRY_SCHEMA_START)}[\\s\\S]*?${escapeRegExp(CANONRY_SCHEMA_END)}`,
|
|
8923
|
+
"g"
|
|
8924
|
+
);
|
|
8925
|
+
return content.replace(regex, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
8926
|
+
}
|
|
8927
|
+
function escapeRegExp(str) {
|
|
8928
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8929
|
+
}
|
|
8930
|
+
function injectCanonrySchema(content, schemas) {
|
|
8931
|
+
if (schemas.length === 0) return content;
|
|
8932
|
+
const blocks = schemas.map((schema) => `<script type="application/ld+json">${JSON.stringify(schema).replace(/<\//g, "<\\/")}</script>`).join("\n");
|
|
8933
|
+
const injection = `
|
|
8934
|
+
|
|
8935
|
+
${CANONRY_SCHEMA_START}
|
|
8936
|
+
${blocks}
|
|
8937
|
+
${CANONRY_SCHEMA_END}`;
|
|
8938
|
+
const stripped = stripCanonrySchema(content);
|
|
8939
|
+
return stripped + injection;
|
|
8940
|
+
}
|
|
8941
|
+
async function verifySchemaInjection(connection, slug, env) {
|
|
8942
|
+
const page = await getPageBySlug(connection, slug, env);
|
|
8943
|
+
const raw = page.content?.raw ?? page.content?.rendered ?? "";
|
|
8944
|
+
return raw.includes(CANONRY_SCHEMA_START);
|
|
8945
|
+
}
|
|
8946
|
+
async function deploySchema(connection, slug, schemas, env) {
|
|
8947
|
+
const site = resolveEnvironment(connection, env);
|
|
8948
|
+
try {
|
|
8949
|
+
const page = await getPageBySlug(connection, slug, site.env);
|
|
8950
|
+
const currentContent = page.content?.raw ?? page.content?.rendered ?? "";
|
|
8951
|
+
const updatedContent = injectCanonrySchema(currentContent, schemas);
|
|
8952
|
+
await fetchJson(
|
|
8953
|
+
connection,
|
|
8954
|
+
site.siteUrl,
|
|
8955
|
+
`/wp-json/wp/v2/pages/${page.id}`,
|
|
8956
|
+
{
|
|
8957
|
+
method: "POST",
|
|
8958
|
+
body: JSON.stringify({ content: updatedContent })
|
|
8959
|
+
}
|
|
8960
|
+
);
|
|
8961
|
+
const persisted = await verifySchemaInjection(connection, slug, site.env);
|
|
8962
|
+
if (!persisted) {
|
|
8963
|
+
return {
|
|
8964
|
+
slug,
|
|
8965
|
+
status: "stripped",
|
|
8966
|
+
schemasInjected: schemas.map((s) => String(s["@type"] ?? "Unknown")),
|
|
8967
|
+
manualAssist: {
|
|
8968
|
+
manualRequired: true,
|
|
8969
|
+
targetUrl: page.link ?? `${site.siteUrl}/${slug}`,
|
|
8970
|
+
adminUrl: `${site.siteUrl}/wp-admin/`,
|
|
8971
|
+
content: schemas.map((s) => JSON.stringify(s, null, 2)).join("\n\n"),
|
|
8972
|
+
nextSteps: [
|
|
8973
|
+
`WordPress stripped the schema <script> tags for page "${slug}". The connected user likely lacks the unfiltered_html capability.`,
|
|
8974
|
+
"Grant the user Administrator or Super Admin role, or add the schema manually in the page editor or via a schema plugin.",
|
|
8975
|
+
"Paste the JSON-LD blocks provided above."
|
|
8976
|
+
]
|
|
8977
|
+
}
|
|
8978
|
+
};
|
|
8979
|
+
}
|
|
8980
|
+
return {
|
|
8981
|
+
slug,
|
|
8982
|
+
status: "deployed",
|
|
8983
|
+
schemasInjected: schemas.map((s) => String(s["@type"] ?? "Unknown"))
|
|
8984
|
+
};
|
|
8985
|
+
} catch (error) {
|
|
8986
|
+
if (error instanceof WordpressApiError && error.code === "NOT_FOUND") {
|
|
8987
|
+
return { slug, status: "skipped", error: `Page "${slug}" not found` };
|
|
8988
|
+
}
|
|
8989
|
+
return {
|
|
8990
|
+
slug,
|
|
8991
|
+
status: "failed",
|
|
8992
|
+
error: error instanceof Error ? error.message : String(error)
|
|
8993
|
+
};
|
|
8994
|
+
}
|
|
8995
|
+
}
|
|
8996
|
+
async function deploySchemaFromProfile(connection, profile, env) {
|
|
8997
|
+
const site = resolveEnvironment(connection, env);
|
|
8998
|
+
const slugEntries = Object.entries(profile.pages);
|
|
8999
|
+
const results = await mapWithConcurrency(
|
|
9000
|
+
slugEntries,
|
|
9001
|
+
3,
|
|
9002
|
+
async ([slug, entries]) => {
|
|
9003
|
+
const parsed = entries.map(parseSchemaPageEntry);
|
|
9004
|
+
const unsupported = parsed.filter((p) => !isSupportedSchemaType(p.type));
|
|
9005
|
+
if (unsupported.length > 0) {
|
|
9006
|
+
return {
|
|
9007
|
+
slug,
|
|
9008
|
+
status: "failed",
|
|
9009
|
+
error: `Unsupported schema type(s): ${unsupported.map((u) => u.type).join(", ")}`
|
|
9010
|
+
};
|
|
9011
|
+
}
|
|
9012
|
+
const schemas = parsed.map((p) => generateSchema(p.type, profile.business, { faqs: p.faqs }));
|
|
9013
|
+
return deploySchema(connection, slug, schemas, site.env);
|
|
9014
|
+
}
|
|
9015
|
+
);
|
|
9016
|
+
return { env: site.env, results };
|
|
9017
|
+
}
|
|
9018
|
+
async function getSchemaStatus(connection, env) {
|
|
9019
|
+
const site = resolveEnvironment(connection, env);
|
|
9020
|
+
const pages = await listPages(connection, site.env);
|
|
9021
|
+
const details = await mapWithConcurrency(
|
|
9022
|
+
pages,
|
|
9023
|
+
5,
|
|
9024
|
+
async (page) => getPageDetail(connection, page.slug, site.env)
|
|
9025
|
+
);
|
|
9026
|
+
const statusPages = details.map((page) => {
|
|
9027
|
+
const rawContent = page.content;
|
|
9028
|
+
const hasCanonryMarker = rawContent.includes(CANONRY_SCHEMA_START);
|
|
9029
|
+
const allSchemaTypes = page.schemaBlocks.map((b) => b.type);
|
|
9030
|
+
const canonrySchemas = [];
|
|
9031
|
+
const thirdPartySchemas = [];
|
|
9032
|
+
if (hasCanonryMarker) {
|
|
9033
|
+
const markerRegex = new RegExp(
|
|
9034
|
+
`${escapeRegExp(CANONRY_SCHEMA_START)}([\\s\\S]*?)${escapeRegExp(CANONRY_SCHEMA_END)}`
|
|
9035
|
+
);
|
|
9036
|
+
const match = markerRegex.exec(rawContent);
|
|
9037
|
+
if (match?.[1]) {
|
|
9038
|
+
const jsonLdRegex = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
9039
|
+
let jsonMatch;
|
|
9040
|
+
while ((jsonMatch = jsonLdRegex.exec(match[1])) !== null) {
|
|
9041
|
+
try {
|
|
9042
|
+
const parsed = JSON.parse(jsonMatch[1].trim());
|
|
9043
|
+
canonrySchemas.push(String(parsed["@type"] ?? "Unknown"));
|
|
9044
|
+
} catch {
|
|
9045
|
+
}
|
|
9046
|
+
}
|
|
9047
|
+
}
|
|
9048
|
+
}
|
|
9049
|
+
const canonryCounts = /* @__PURE__ */ new Map();
|
|
9050
|
+
for (const t of canonrySchemas) {
|
|
9051
|
+
canonryCounts.set(t, (canonryCounts.get(t) ?? 0) + 1);
|
|
9052
|
+
}
|
|
9053
|
+
for (const schemaType of allSchemaTypes) {
|
|
9054
|
+
const remaining = canonryCounts.get(schemaType) ?? 0;
|
|
9055
|
+
if (remaining > 0) {
|
|
9056
|
+
canonryCounts.set(schemaType, remaining - 1);
|
|
9057
|
+
} else {
|
|
9058
|
+
thirdPartySchemas.push(schemaType);
|
|
9059
|
+
}
|
|
9060
|
+
}
|
|
9061
|
+
return {
|
|
9062
|
+
slug: page.slug,
|
|
9063
|
+
title: page.title,
|
|
9064
|
+
canonrySchemas,
|
|
9065
|
+
thirdPartySchemas,
|
|
9066
|
+
hasCanonrySchema: hasCanonryMarker
|
|
9067
|
+
};
|
|
9068
|
+
});
|
|
9069
|
+
return { env: site.env, pages: statusPages };
|
|
9070
|
+
}
|
|
8638
9071
|
async function getLlmsTxt(connection, env) {
|
|
8639
9072
|
const site = resolveEnvironment(connection, env);
|
|
8640
9073
|
const url = `${site.siteUrl}/llms.txt`;
|
|
@@ -9080,6 +9513,39 @@ async function wordpressRoutes(app, opts) {
|
|
|
9080
9513
|
return updated;
|
|
9081
9514
|
});
|
|
9082
9515
|
});
|
|
9516
|
+
app.post("/projects/:name/wordpress/pages/meta/bulk", async (request, reply) => {
|
|
9517
|
+
return withWordpressErrorHandling(reply, async () => {
|
|
9518
|
+
const store = requireStore(reply);
|
|
9519
|
+
if (!store) return;
|
|
9520
|
+
const project = resolveProject(app.db, request.params.name);
|
|
9521
|
+
const connection = requireConnection(store, project.name, reply);
|
|
9522
|
+
if (!connection) return;
|
|
9523
|
+
const entries = request.body?.entries;
|
|
9524
|
+
if (!Array.isArray(entries) || entries.length === 0) {
|
|
9525
|
+
const err = validationError("entries array is required and must not be empty");
|
|
9526
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
9527
|
+
}
|
|
9528
|
+
for (const entry of entries) {
|
|
9529
|
+
if (!entry.slug?.trim()) {
|
|
9530
|
+
const err = validationError("each entry must have a slug");
|
|
9531
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
9532
|
+
}
|
|
9533
|
+
}
|
|
9534
|
+
const env = parseEnvInput(request.body?.env);
|
|
9535
|
+
const result = await bulkSetSeoMeta(connection, entries, env);
|
|
9536
|
+
const applied = result.results.filter((r) => r.status === "applied");
|
|
9537
|
+
if (applied.length > 0) {
|
|
9538
|
+
writeAuditLog(app.db, {
|
|
9539
|
+
projectId: project.id,
|
|
9540
|
+
actor: "api",
|
|
9541
|
+
action: "wordpress.page-meta-updated",
|
|
9542
|
+
entityType: "wordpress_page",
|
|
9543
|
+
entityId: `bulk(${applied.map((r) => r.slug).join(",")})`
|
|
9544
|
+
});
|
|
9545
|
+
}
|
|
9546
|
+
return result;
|
|
9547
|
+
});
|
|
9548
|
+
});
|
|
9083
9549
|
app.get("/projects/:name/wordpress/schema", async (request, reply) => {
|
|
9084
9550
|
return withWordpressErrorHandling(reply, async () => {
|
|
9085
9551
|
const store = requireStore(reply);
|
|
@@ -9113,6 +9579,33 @@ async function wordpressRoutes(app, opts) {
|
|
|
9113
9579
|
return buildManualSchemaUpdate(connection, slug, { type: request.body?.type, json }, env);
|
|
9114
9580
|
});
|
|
9115
9581
|
});
|
|
9582
|
+
app.post("/projects/:name/wordpress/schema/deploy", async (request, reply) => {
|
|
9583
|
+
return withWordpressErrorHandling(reply, async () => {
|
|
9584
|
+
const store = requireStore(reply);
|
|
9585
|
+
if (!store) return;
|
|
9586
|
+
const project = resolveProject(app.db, request.params.name);
|
|
9587
|
+
const connection = requireConnection(store, project.name, reply);
|
|
9588
|
+
if (!connection) return;
|
|
9589
|
+
const profile = request.body?.profile;
|
|
9590
|
+
if (!profile?.business?.name || !profile?.pages || Object.keys(profile.pages).length === 0) {
|
|
9591
|
+
const err = validationError("profile with business.name and non-empty pages is required");
|
|
9592
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
9593
|
+
}
|
|
9594
|
+
const env = parseEnvInput(request.body?.env);
|
|
9595
|
+
return deploySchemaFromProfile(connection, profile, env);
|
|
9596
|
+
});
|
|
9597
|
+
});
|
|
9598
|
+
app.get("/projects/:name/wordpress/schema/status", async (request, reply) => {
|
|
9599
|
+
return withWordpressErrorHandling(reply, async () => {
|
|
9600
|
+
const store = requireStore(reply);
|
|
9601
|
+
if (!store) return;
|
|
9602
|
+
const project = resolveProject(app.db, request.params.name);
|
|
9603
|
+
const connection = requireConnection(store, project.name, reply);
|
|
9604
|
+
if (!connection) return;
|
|
9605
|
+
const env = parseEnvInput(request.query?.env);
|
|
9606
|
+
return getSchemaStatus(connection, env);
|
|
9607
|
+
});
|
|
9608
|
+
});
|
|
9116
9609
|
app.get("/projects/:name/wordpress/llms-txt", async (request, reply) => {
|
|
9117
9610
|
return withWordpressErrorHandling(reply, async () => {
|
|
9118
9611
|
const store = requireStore(reply);
|
|
@@ -9196,6 +9689,201 @@ async function wordpressRoutes(app, opts) {
|
|
|
9196
9689
|
return buildManualStagingPush(connection);
|
|
9197
9690
|
});
|
|
9198
9691
|
});
|
|
9692
|
+
app.post("/projects/:name/wordpress/onboard", async (request, reply) => {
|
|
9693
|
+
return withWordpressErrorHandling(reply, async () => {
|
|
9694
|
+
const store = requireStore(reply);
|
|
9695
|
+
if (!store) return;
|
|
9696
|
+
const project = resolveProject(app.db, request.params.name);
|
|
9697
|
+
const { url, username, appPassword, stagingUrl, profile, skipSchema, skipSubmit } = request.body ?? {};
|
|
9698
|
+
if (!url || !username || !appPassword) {
|
|
9699
|
+
const err = validationError("url, username, and appPassword are required");
|
|
9700
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
9701
|
+
}
|
|
9702
|
+
const defaultEnv = parseEnvInput(request.body?.defaultEnv, "defaultEnv") ?? (stagingUrl ? "staging" : "live");
|
|
9703
|
+
if (defaultEnv === "staging" && !stagingUrl) {
|
|
9704
|
+
const err = validationError('defaultEnv "staging" requires stagingUrl');
|
|
9705
|
+
return reply.status(err.statusCode).send(err.toJSON());
|
|
9706
|
+
}
|
|
9707
|
+
const steps = [];
|
|
9708
|
+
let connection = null;
|
|
9709
|
+
let pageUrls = [];
|
|
9710
|
+
try {
|
|
9711
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
9712
|
+
const existing = store.getConnection(project.name);
|
|
9713
|
+
const nextConnection = {
|
|
9714
|
+
projectName: project.name,
|
|
9715
|
+
url,
|
|
9716
|
+
stagingUrl,
|
|
9717
|
+
username,
|
|
9718
|
+
appPassword,
|
|
9719
|
+
defaultEnv,
|
|
9720
|
+
createdAt: existing?.createdAt ?? now,
|
|
9721
|
+
updatedAt: now
|
|
9722
|
+
};
|
|
9723
|
+
await verifyWordpressConnection(nextConnection);
|
|
9724
|
+
connection = store.upsertConnection(nextConnection);
|
|
9725
|
+
writeAuditLog(app.db, {
|
|
9726
|
+
projectId: project.id,
|
|
9727
|
+
actor: "api",
|
|
9728
|
+
action: "wordpress.connected",
|
|
9729
|
+
entityType: "wordpress_connection",
|
|
9730
|
+
entityId: project.name
|
|
9731
|
+
});
|
|
9732
|
+
steps.push({ name: "connect", status: "completed", summary: `Connected to ${url}` });
|
|
9733
|
+
} catch (err) {
|
|
9734
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9735
|
+
steps.push({ name: "connect", status: "failed", error: msg });
|
|
9736
|
+
return { projectName: project.name, steps };
|
|
9737
|
+
}
|
|
9738
|
+
let auditIssues = [];
|
|
9739
|
+
let auditPages = [];
|
|
9740
|
+
try {
|
|
9741
|
+
const audit = await runAudit(connection);
|
|
9742
|
+
const issueCount = audit.issues?.length ?? 0;
|
|
9743
|
+
const pageCount = audit.pages?.length ?? 0;
|
|
9744
|
+
auditIssues = audit.issues;
|
|
9745
|
+
auditPages = audit.pages;
|
|
9746
|
+
const pageSummaries = await listPages(connection);
|
|
9747
|
+
pageUrls = pageSummaries.map((p) => p.link).filter((link) => typeof link === "string" && link.length > 0);
|
|
9748
|
+
steps.push({ name: "audit", status: "completed", summary: `${pageCount} pages audited, ${issueCount} issues` });
|
|
9749
|
+
} catch (err) {
|
|
9750
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9751
|
+
steps.push({ name: "audit", status: "failed", error: msg });
|
|
9752
|
+
return { projectName: project.name, steps };
|
|
9753
|
+
}
|
|
9754
|
+
try {
|
|
9755
|
+
const metaEntries = [];
|
|
9756
|
+
for (const issue of auditIssues) {
|
|
9757
|
+
if (issue.code === "missing-meta-description" || issue.code === "missing-seo-title") {
|
|
9758
|
+
const existing = metaEntries.find((e) => e.slug === issue.slug);
|
|
9759
|
+
const page = auditPages.find((p) => p.slug === issue.slug);
|
|
9760
|
+
if (!existing) {
|
|
9761
|
+
metaEntries.push({
|
|
9762
|
+
slug: issue.slug,
|
|
9763
|
+
title: issue.code === "missing-seo-title" ? page?.title ?? issue.slug : void 0,
|
|
9764
|
+
description: issue.code === "missing-meta-description" ? page?.title ?? issue.slug : void 0
|
|
9765
|
+
});
|
|
9766
|
+
} else {
|
|
9767
|
+
if (issue.code === "missing-seo-title" && !existing.title) {
|
|
9768
|
+
existing.title = page?.title ?? issue.slug;
|
|
9769
|
+
}
|
|
9770
|
+
if (issue.code === "missing-meta-description" && !existing.description) {
|
|
9771
|
+
existing.description = page?.title ?? issue.slug;
|
|
9772
|
+
}
|
|
9773
|
+
}
|
|
9774
|
+
}
|
|
9775
|
+
}
|
|
9776
|
+
if (metaEntries.length === 0) {
|
|
9777
|
+
steps.push({ name: "set-meta", status: "skipped", summary: "No pages with missing meta found" });
|
|
9778
|
+
} else {
|
|
9779
|
+
const result = await bulkSetSeoMeta(connection, metaEntries);
|
|
9780
|
+
const applied = result.results.filter((r) => r.status === "applied").length;
|
|
9781
|
+
const manual = result.results.filter((r) => r.status === "manual").length;
|
|
9782
|
+
const skipped = result.results.filter((r) => r.status === "skipped").length;
|
|
9783
|
+
steps.push({
|
|
9784
|
+
name: "set-meta",
|
|
9785
|
+
status: "completed",
|
|
9786
|
+
summary: `${applied} applied, ${manual} manual-assist, ${skipped} skipped`
|
|
9787
|
+
});
|
|
9788
|
+
}
|
|
9789
|
+
} catch (err) {
|
|
9790
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9791
|
+
steps.push({ name: "set-meta", status: "failed", error: msg });
|
|
9792
|
+
return { projectName: project.name, steps };
|
|
9793
|
+
}
|
|
9794
|
+
if (skipSchema || !profile) {
|
|
9795
|
+
steps.push({
|
|
9796
|
+
name: "schema-deploy",
|
|
9797
|
+
status: "skipped",
|
|
9798
|
+
summary: skipSchema ? "Skipped via --skip-schema" : "No --profile provided"
|
|
9799
|
+
});
|
|
9800
|
+
} else {
|
|
9801
|
+
try {
|
|
9802
|
+
if (!profile.business?.name || !profile.pages || Object.keys(profile.pages).length === 0) {
|
|
9803
|
+
steps.push({ name: "schema-deploy", status: "skipped", summary: "Profile missing business.name or pages" });
|
|
9804
|
+
} else {
|
|
9805
|
+
const result = await deploySchemaFromProfile(connection, profile);
|
|
9806
|
+
const deployed = result.results.filter((r) => r.status === "deployed").length;
|
|
9807
|
+
const stripped = result.results.filter((r) => r.status === "stripped").length;
|
|
9808
|
+
const skipped = result.results.filter((r) => r.status === "skipped").length;
|
|
9809
|
+
steps.push({
|
|
9810
|
+
name: "schema-deploy",
|
|
9811
|
+
status: "completed",
|
|
9812
|
+
summary: `${deployed} deployed, ${stripped} stripped (manual-assist), ${skipped} skipped`
|
|
9813
|
+
});
|
|
9814
|
+
}
|
|
9815
|
+
} catch (err) {
|
|
9816
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9817
|
+
steps.push({ name: "schema-deploy", status: "failed", error: msg });
|
|
9818
|
+
return { projectName: project.name, steps };
|
|
9819
|
+
}
|
|
9820
|
+
}
|
|
9821
|
+
if (skipSubmit || pageUrls.length === 0) {
|
|
9822
|
+
const reason = skipSubmit ? "Skipped via --skip-submit" : "No page URLs to submit";
|
|
9823
|
+
steps.push({ name: "google-submit", status: "skipped", summary: reason });
|
|
9824
|
+
steps.push({ name: "bing-submit", status: "skipped", summary: reason });
|
|
9825
|
+
} else {
|
|
9826
|
+
try {
|
|
9827
|
+
const authHeader = request.headers.authorization;
|
|
9828
|
+
const googleRes = await app.inject({
|
|
9829
|
+
method: "POST",
|
|
9830
|
+
url: `${opts.routePrefix ?? "/api/v1"}/projects/${encodeURIComponent(project.name)}/google/indexing/request`,
|
|
9831
|
+
payload: { urls: pageUrls },
|
|
9832
|
+
headers: authHeader ? { authorization: authHeader } : {}
|
|
9833
|
+
});
|
|
9834
|
+
if (googleRes.statusCode === 200) {
|
|
9835
|
+
const body = JSON.parse(googleRes.body);
|
|
9836
|
+
const succeeded = body.results?.filter((r) => r.status === "success").length ?? 0;
|
|
9837
|
+
steps.push({ name: "google-submit", status: "completed", summary: `${succeeded}/${pageUrls.length} URLs submitted` });
|
|
9838
|
+
} else {
|
|
9839
|
+
const body = JSON.parse(googleRes.body);
|
|
9840
|
+
const msg = body.message || body.error || `HTTP ${googleRes.statusCode}`;
|
|
9841
|
+
if (googleRes.statusCode === 400 || googleRes.statusCode === 404) {
|
|
9842
|
+
steps.push({ name: "google-submit", status: "skipped", summary: msg });
|
|
9843
|
+
} else {
|
|
9844
|
+
steps.push({ name: "google-submit", status: "failed", error: msg });
|
|
9845
|
+
}
|
|
9846
|
+
}
|
|
9847
|
+
} catch (err) {
|
|
9848
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9849
|
+
steps.push({ name: "google-submit", status: "skipped", summary: `Google not available: ${msg}` });
|
|
9850
|
+
}
|
|
9851
|
+
try {
|
|
9852
|
+
const authHeader = request.headers.authorization;
|
|
9853
|
+
const bingRes = await app.inject({
|
|
9854
|
+
method: "POST",
|
|
9855
|
+
url: `${opts.routePrefix ?? "/api/v1"}/projects/${encodeURIComponent(project.name)}/bing/request-indexing`,
|
|
9856
|
+
payload: { urls: pageUrls },
|
|
9857
|
+
headers: authHeader ? { authorization: authHeader } : {}
|
|
9858
|
+
});
|
|
9859
|
+
if (bingRes.statusCode === 200) {
|
|
9860
|
+
const body = JSON.parse(bingRes.body);
|
|
9861
|
+
const succeeded = body.results?.filter((r) => r.status === "success").length ?? 0;
|
|
9862
|
+
steps.push({ name: "bing-submit", status: "completed", summary: `${succeeded}/${pageUrls.length} URLs submitted` });
|
|
9863
|
+
} else {
|
|
9864
|
+
const body = JSON.parse(bingRes.body);
|
|
9865
|
+
const msg = body.message || body.error || `HTTP ${bingRes.statusCode}`;
|
|
9866
|
+
if (bingRes.statusCode === 400 || bingRes.statusCode === 404) {
|
|
9867
|
+
steps.push({ name: "bing-submit", status: "skipped", summary: msg });
|
|
9868
|
+
} else {
|
|
9869
|
+
steps.push({ name: "bing-submit", status: "failed", error: msg });
|
|
9870
|
+
}
|
|
9871
|
+
}
|
|
9872
|
+
} catch (err) {
|
|
9873
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
9874
|
+
steps.push({ name: "bing-submit", status: "skipped", summary: `Bing not available: ${msg}` });
|
|
9875
|
+
}
|
|
9876
|
+
}
|
|
9877
|
+
writeAuditLog(app.db, {
|
|
9878
|
+
projectId: project.id,
|
|
9879
|
+
actor: "api",
|
|
9880
|
+
action: "wordpress.onboarded",
|
|
9881
|
+
entityType: "wordpress_connection",
|
|
9882
|
+
entityId: project.name
|
|
9883
|
+
});
|
|
9884
|
+
return { projectName: project.name, steps };
|
|
9885
|
+
});
|
|
9886
|
+
});
|
|
9199
9887
|
}
|
|
9200
9888
|
|
|
9201
9889
|
// ../api-routes/src/index.ts
|
|
@@ -9288,7 +9976,8 @@ async function apiRoutes(app, opts) {
|
|
|
9288
9976
|
onInspectSitemapRequested: opts.onInspectSitemapRequested
|
|
9289
9977
|
});
|
|
9290
9978
|
await api.register(wordpressRoutes, {
|
|
9291
|
-
wordpressConnectionStore: opts.wordpressConnectionStore
|
|
9979
|
+
wordpressConnectionStore: opts.wordpressConnectionStore,
|
|
9980
|
+
routePrefix: opts.routePrefix ?? "/api/v1"
|
|
9292
9981
|
});
|
|
9293
9982
|
await api.register(cdpRoutes, {
|
|
9294
9983
|
getCdpStatus: opts.getCdpStatus,
|
|
@@ -11251,7 +11940,7 @@ import crypto18 from "crypto";
|
|
|
11251
11940
|
import fs4 from "fs";
|
|
11252
11941
|
import path5 from "path";
|
|
11253
11942
|
import os4 from "os";
|
|
11254
|
-
import { and as
|
|
11943
|
+
import { and as and6, eq as eq17, inArray as inArray3 } from "drizzle-orm";
|
|
11255
11944
|
|
|
11256
11945
|
// src/logger.ts
|
|
11257
11946
|
var IS_TTY = process.stdout.isTTY === true;
|
|
@@ -11401,7 +12090,7 @@ var JobRunner = class {
|
|
|
11401
12090
|
throw new Error(`Run ${runId} is not executable from status '${existingRun.status}'`);
|
|
11402
12091
|
}
|
|
11403
12092
|
if (existingRun.status === "queued") {
|
|
11404
|
-
this.db.update(runs).set({ status: "running", startedAt: now }).where(
|
|
12093
|
+
this.db.update(runs).set({ status: "running", startedAt: now }).where(and6(eq17(runs.id, runId), eq17(runs.status, "queued"))).run();
|
|
11405
12094
|
}
|
|
11406
12095
|
this.throwIfRunCancelled(runId);
|
|
11407
12096
|
const project = this.db.select().from(projects).where(eq17(projects.id, projectId)).get();
|
|
@@ -11488,6 +12177,12 @@ var JobRunner = class {
|
|
|
11488
12177
|
log.info("query.result", { runId, provider: providerName, keyword: kw.keyword, citedDomains: normalized.citedDomains, groundingSources: normalized.groundingSources.map((s) => s.uri), matchDomains: allDomains });
|
|
11489
12178
|
const citationState = determineCitationState(normalized, allDomains);
|
|
11490
12179
|
const overlap = computeCompetitorOverlap(normalized, competitorDomains);
|
|
12180
|
+
const extractedCompetitors = extractRecommendedCompetitors(
|
|
12181
|
+
normalized.answerText,
|
|
12182
|
+
allDomains,
|
|
12183
|
+
normalized.citedDomains,
|
|
12184
|
+
overlap
|
|
12185
|
+
);
|
|
11491
12186
|
let screenshotRelPath = null;
|
|
11492
12187
|
if (raw.screenshotPath && fs4.existsSync(raw.screenshotPath)) {
|
|
11493
12188
|
const snapshotId = crypto18.randomUUID();
|
|
@@ -11506,6 +12201,7 @@ var JobRunner = class {
|
|
|
11506
12201
|
answerText: normalized.answerText,
|
|
11507
12202
|
citedDomains: JSON.stringify(normalized.citedDomains),
|
|
11508
12203
|
competitorOverlap: JSON.stringify(overlap),
|
|
12204
|
+
recommendedCompetitors: JSON.stringify(extractedCompetitors),
|
|
11509
12205
|
location: runLocation?.label ?? null,
|
|
11510
12206
|
screenshotPath: screenshotRelPath,
|
|
11511
12207
|
rawResponse: JSON.stringify({
|
|
@@ -11527,6 +12223,7 @@ var JobRunner = class {
|
|
|
11527
12223
|
answerText: normalized.answerText,
|
|
11528
12224
|
citedDomains: JSON.stringify(normalized.citedDomains),
|
|
11529
12225
|
competitorOverlap: JSON.stringify(overlap),
|
|
12226
|
+
recommendedCompetitors: JSON.stringify(extractedCompetitors),
|
|
11530
12227
|
location: runLocation?.label ?? null,
|
|
11531
12228
|
rawResponse: JSON.stringify({
|
|
11532
12229
|
model: raw.model,
|
|
@@ -11750,10 +12447,101 @@ function computeCompetitorOverlap(normalized, competitorDomains) {
|
|
|
11750
12447
|
}
|
|
11751
12448
|
return [...overlapSet];
|
|
11752
12449
|
}
|
|
12450
|
+
function extractRecommendedCompetitors(answerText, ownDomains, citedDomains, competitorDomains) {
|
|
12451
|
+
if (!answerText || answerText.length < 20) return [];
|
|
12452
|
+
const ownBrandKeys = new Set(
|
|
12453
|
+
ownDomains.flatMap((domain) => collectBrandKeysFromDomain(domain))
|
|
12454
|
+
);
|
|
12455
|
+
const knownCompetitorKeys = new Set(
|
|
12456
|
+
[...citedDomains, ...competitorDomains].flatMap((domain) => collectBrandKeysFromDomain(domain)).filter((key) => !ownBrandKeys.has(key))
|
|
12457
|
+
);
|
|
12458
|
+
if (knownCompetitorKeys.size === 0) return [];
|
|
12459
|
+
const candidatePatterns = [
|
|
12460
|
+
/^\s*(?:[-*]|\d+\.)\s+(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?\s*[:\u2014\u2013–-]/gm,
|
|
12461
|
+
/\*\*([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\*\*/g,
|
|
12462
|
+
/^#{1,4}\s+(?:\d+\.\s+)?(?:\*\*)?([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)(?:\*\*)?$/gm,
|
|
12463
|
+
/\[([A-Z0-9][A-Za-z0-9][\w\s.&',/()-]{1,50}?)\]\(https?:\/\/[^\s)]+\)/g
|
|
12464
|
+
];
|
|
12465
|
+
const genericKeys = /* @__PURE__ */ new Set([
|
|
12466
|
+
"additional",
|
|
12467
|
+
"best",
|
|
12468
|
+
"benefits",
|
|
12469
|
+
"bottomline",
|
|
12470
|
+
"comparison",
|
|
12471
|
+
"conclusion",
|
|
12472
|
+
"directorylisting",
|
|
12473
|
+
"example",
|
|
12474
|
+
"expertise",
|
|
12475
|
+
"features",
|
|
12476
|
+
"finalthoughts",
|
|
12477
|
+
"howitworks",
|
|
12478
|
+
"important",
|
|
12479
|
+
"keybenefits",
|
|
12480
|
+
"keyfeatures",
|
|
12481
|
+
"major",
|
|
12482
|
+
"note",
|
|
12483
|
+
"notable",
|
|
12484
|
+
"option",
|
|
12485
|
+
"other",
|
|
12486
|
+
"overview",
|
|
12487
|
+
"pricing",
|
|
12488
|
+
"pros",
|
|
12489
|
+
"reviews",
|
|
12490
|
+
"step",
|
|
12491
|
+
"summary",
|
|
12492
|
+
"top",
|
|
12493
|
+
"verdict",
|
|
12494
|
+
"whattolookfor",
|
|
12495
|
+
"whyitmatters",
|
|
12496
|
+
"whyitstandsout",
|
|
12497
|
+
"whywechoseit"
|
|
12498
|
+
]);
|
|
12499
|
+
const seen = /* @__PURE__ */ new Map();
|
|
12500
|
+
for (const pattern of candidatePatterns) {
|
|
12501
|
+
let match;
|
|
12502
|
+
while ((match = pattern.exec(answerText)) !== null) {
|
|
12503
|
+
const candidate = cleanCandidateName(match[1] ?? "");
|
|
12504
|
+
const candidateKey = brandKeyFromText(candidate);
|
|
12505
|
+
if (!candidateKey) continue;
|
|
12506
|
+
if (genericKeys.has(candidateKey)) continue;
|
|
12507
|
+
if (candidate.split(/\s+/).length > 6) continue;
|
|
12508
|
+
if (matchesBrandKey(candidateKey, ownBrandKeys)) continue;
|
|
12509
|
+
if (!matchesBrandKey(candidateKey, knownCompetitorKeys)) continue;
|
|
12510
|
+
if (!seen.has(candidateKey)) seen.set(candidateKey, candidate);
|
|
12511
|
+
}
|
|
12512
|
+
}
|
|
12513
|
+
return [...seen.values()].slice(0, 10);
|
|
12514
|
+
}
|
|
12515
|
+
function cleanCandidateName(candidate) {
|
|
12516
|
+
return candidate.replace(/^[\s"'`]+|[\s"'`.,:;!?]+$/g, "").replace(/\s+/g, " ").trim();
|
|
12517
|
+
}
|
|
12518
|
+
function brandKeyFromText(value) {
|
|
12519
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
12520
|
+
}
|
|
12521
|
+
function collectBrandKeysFromDomain(domain) {
|
|
12522
|
+
const hostname = normalizeProjectDomain(domain).split("/")[0] ?? "";
|
|
12523
|
+
const labels = hostname.split(".").filter(Boolean);
|
|
12524
|
+
const keys = /* @__PURE__ */ new Set();
|
|
12525
|
+
const hostnameKey = hostname.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
12526
|
+
if (hostnameKey.length >= 4) keys.add(hostnameKey);
|
|
12527
|
+
for (const label of labels) {
|
|
12528
|
+
const key = label.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
12529
|
+
if (key.length >= 4) keys.add(key);
|
|
12530
|
+
}
|
|
12531
|
+
return [...keys];
|
|
12532
|
+
}
|
|
12533
|
+
function matchesBrandKey(candidateKey, brandKeys) {
|
|
12534
|
+
for (const brandKey of brandKeys) {
|
|
12535
|
+
if (candidateKey === brandKey) return true;
|
|
12536
|
+
if (candidateKey.startsWith(brandKey) || candidateKey.endsWith(brandKey)) return true;
|
|
12537
|
+
if (brandKey.startsWith(candidateKey) || brandKey.endsWith(candidateKey)) return true;
|
|
12538
|
+
}
|
|
12539
|
+
return false;
|
|
12540
|
+
}
|
|
11753
12541
|
|
|
11754
12542
|
// src/gsc-sync.ts
|
|
11755
12543
|
import crypto19 from "crypto";
|
|
11756
|
-
import { eq as eq18, and as
|
|
12544
|
+
import { eq as eq18, and as and7, sql as sql5 } from "drizzle-orm";
|
|
11757
12545
|
var log2 = createLogger("GscSync");
|
|
11758
12546
|
function formatDate2(d) {
|
|
11759
12547
|
return d.toISOString().split("T")[0];
|
|
@@ -11805,10 +12593,10 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
11805
12593
|
});
|
|
11806
12594
|
log2.info("fetch.complete", { runId, projectId, rowCount: rows.length });
|
|
11807
12595
|
db.delete(gscSearchData).where(
|
|
11808
|
-
|
|
12596
|
+
and7(
|
|
11809
12597
|
eq18(gscSearchData.projectId, projectId),
|
|
11810
|
-
|
|
11811
|
-
|
|
12598
|
+
sql5`${gscSearchData.date} >= ${startDate}`,
|
|
12599
|
+
sql5`${gscSearchData.date} <= ${endDate}`
|
|
11812
12600
|
)
|
|
11813
12601
|
).run();
|
|
11814
12602
|
const batchSize = 500;
|
|
@@ -11894,7 +12682,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
11894
12682
|
}
|
|
11895
12683
|
}
|
|
11896
12684
|
const snapshotDate = formatDate2(/* @__PURE__ */ new Date());
|
|
11897
|
-
db.delete(gscCoverageSnapshots).where(
|
|
12685
|
+
db.delete(gscCoverageSnapshots).where(and7(eq18(gscCoverageSnapshots.projectId, projectId), eq18(gscCoverageSnapshots.date, snapshotDate))).run();
|
|
11898
12686
|
db.insert(gscCoverageSnapshots).values({
|
|
11899
12687
|
id: crypto19.randomUUID(),
|
|
11900
12688
|
projectId,
|
|
@@ -11917,7 +12705,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
11917
12705
|
|
|
11918
12706
|
// src/gsc-inspect-sitemap.ts
|
|
11919
12707
|
import crypto20 from "crypto";
|
|
11920
|
-
import { eq as eq19, and as
|
|
12708
|
+
import { eq as eq19, and as and8 } from "drizzle-orm";
|
|
11921
12709
|
|
|
11922
12710
|
// src/sitemap-parser.ts
|
|
11923
12711
|
var LOC_REGEX = /<loc>\s*([^<]+?)\s*<\/loc>/gi;
|
|
@@ -12081,7 +12869,7 @@ async function executeInspectSitemap(db, runId, projectId, opts) {
|
|
|
12081
12869
|
}
|
|
12082
12870
|
}
|
|
12083
12871
|
const snapshotDate = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
12084
|
-
db.delete(gscCoverageSnapshots).where(
|
|
12872
|
+
db.delete(gscCoverageSnapshots).where(and8(eq19(gscCoverageSnapshots.projectId, projectId), eq19(gscCoverageSnapshots.date, snapshotDate))).run();
|
|
12085
12873
|
db.insert(gscCoverageSnapshots).values({
|
|
12086
12874
|
id: crypto20.randomUUID(),
|
|
12087
12875
|
projectId,
|
|
@@ -12274,7 +13062,7 @@ var Scheduler = class {
|
|
|
12274
13062
|
};
|
|
12275
13063
|
|
|
12276
13064
|
// src/notifier.ts
|
|
12277
|
-
import { eq as eq21, desc as desc7, and as
|
|
13065
|
+
import { eq as eq21, desc as desc7, and as and9, or as or2 } from "drizzle-orm";
|
|
12278
13066
|
import crypto21 from "crypto";
|
|
12279
13067
|
var log5 = createLogger("Notifier");
|
|
12280
13068
|
var Notifier = class {
|
|
@@ -12338,7 +13126,7 @@ var Notifier = class {
|
|
|
12338
13126
|
}
|
|
12339
13127
|
computeTransitions(runId, projectId) {
|
|
12340
13128
|
const recentRuns = this.db.select().from(runs).where(
|
|
12341
|
-
|
|
13129
|
+
and9(
|
|
12342
13130
|
eq21(runs.projectId, projectId),
|
|
12343
13131
|
or2(eq21(runs.status, "completed"), eq21(runs.status, "partial"))
|
|
12344
13132
|
)
|
|
@@ -12825,7 +13613,7 @@ var SnapshotService = class {
|
|
|
12825
13613
|
heuristicCompetitors: result.recommendedCompetitors,
|
|
12826
13614
|
citedDomains: result.citedDomains,
|
|
12827
13615
|
groundingSources: result.groundingSources.map((source) => source.uri),
|
|
12828
|
-
answerText: clipText(result.answerText,
|
|
13616
|
+
answerText: clipText(result.answerText, 2e3)
|
|
12829
13617
|
}))
|
|
12830
13618
|
);
|
|
12831
13619
|
if (responses.length === 0) {
|
|
@@ -12853,7 +13641,7 @@ var SnapshotService = class {
|
|
|
12853
13641
|
accuracyNotes: assessment.accuracyNotes ?? null,
|
|
12854
13642
|
incorrectClaims: uniqueStrings(assessment.incorrectClaims ?? []).slice(0, 5),
|
|
12855
13643
|
...hasReviewedCompetitors ? {
|
|
12856
|
-
recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0,
|
|
13644
|
+
recommendedCompetitors: uniqueStrings(assessment.recommendedCompetitors ?? []).slice(0, 10)
|
|
12857
13645
|
} : {}
|
|
12858
13646
|
};
|
|
12859
13647
|
}),
|
|
@@ -12906,7 +13694,13 @@ function buildBatchAnalysisPrompt(ctx) {
|
|
|
12906
13694
|
"Return strict JSON with keys: assessments, whatThisMeans, recommendedActions.",
|
|
12907
13695
|
"Each assessment must include: phrase, provider, mentioned, describedAccurately, accuracyNotes, incorrectClaims, recommendedCompetitors.",
|
|
12908
13696
|
"describedAccurately must be one of: yes, no, unknown, not-mentioned.",
|
|
12909
|
-
"
|
|
13697
|
+
"",
|
|
13698
|
+
"CRITICAL \u2014 recommendedCompetitors extraction:",
|
|
13699
|
+
"For each response, extract EVERY specific company/brand/product name that the AI recommended or listed as an alternative.",
|
|
13700
|
+
'Include the company name exactly as it appears in the response (e.g. "Accenture", "Deloitte", "C3.ai").',
|
|
13701
|
+
'Do NOT include generic terms like "consulting firms" or directories like "G2" or "Clutch".',
|
|
13702
|
+
"Do NOT include the target company itself.",
|
|
13703
|
+
"This is the most important field \u2014 it shows the prospect who AI recommends INSTEAD of them.",
|
|
12910
13704
|
"",
|
|
12911
13705
|
`Target company: ${ctx.companyName}`,
|
|
12912
13706
|
`Target domain: ${ctx.domain}`,
|
|
@@ -13042,7 +13836,7 @@ function mentionsTargetCompany(answerText, companyName, domain) {
|
|
|
13042
13836
|
if (targetTokens.length === 0) return false;
|
|
13043
13837
|
let matches = 0;
|
|
13044
13838
|
for (const token of targetTokens) {
|
|
13045
|
-
if (new RegExp(`\\b${
|
|
13839
|
+
if (new RegExp(`\\b${escapeRegExp2(token)}\\b`, "i").test(answerText)) {
|
|
13046
13840
|
matches++;
|
|
13047
13841
|
}
|
|
13048
13842
|
}
|
|
@@ -13128,7 +13922,10 @@ function normalizeStringList(values) {
|
|
|
13128
13922
|
);
|
|
13129
13923
|
}
|
|
13130
13924
|
function uniqueStrings(values) {
|
|
13131
|
-
|
|
13925
|
+
if (!Array.isArray(values)) return [];
|
|
13926
|
+
return [...new Set(
|
|
13927
|
+
values.filter((value) => typeof value === "string").map((value) => value.trim()).filter(Boolean)
|
|
13928
|
+
)];
|
|
13132
13929
|
}
|
|
13133
13930
|
function normalizeDomain(value) {
|
|
13134
13931
|
const trimmed = value.trim();
|
|
@@ -13159,7 +13956,7 @@ function clipText(value, length) {
|
|
|
13159
13956
|
if (value.length <= length) return value;
|
|
13160
13957
|
return `${value.slice(0, length - 3)}...`;
|
|
13161
13958
|
}
|
|
13162
|
-
function
|
|
13959
|
+
function escapeRegExp2(value) {
|
|
13163
13960
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13164
13961
|
}
|
|
13165
13962
|
|