@ainyc/canonry 4.71.0 → 4.72.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/agent-workspace/skills/aero/references/regression-playbook.md +1 -1
- package/assets/agent-workspace/skills/canonry/references/aeo-analysis.md +7 -0
- package/assets/agent-workspace/skills/canonry/references/canonry-cli.md +20 -2
- package/assets/assets/{BacklinksPage-CQNPYiDA.js → BacklinksPage-CjfpwZEH.js} +1 -1
- package/assets/assets/{ChartPrimitives-BShpLrpS.js → ChartPrimitives-Ckf2FrUy.js} +1 -1
- package/assets/assets/{ProjectPage-CJLw1m4O.js → ProjectPage-DZeplYeC.js} +6 -6
- package/assets/assets/{RunRow-Dq1vs1hA.js → RunRow-BuFyG0V_.js} +1 -1
- package/assets/assets/{RunsPage-CBMa2xWh.js → RunsPage-D-pr000K.js} +1 -1
- package/assets/assets/{SettingsPage-B_XeJDdg.js → SettingsPage-CiaapCYn.js} +1 -1
- package/assets/assets/{TrafficPage-vJv_Mf6f.js → TrafficPage-B40xytJD.js} +1 -1
- package/assets/assets/{TrafficSourceDetailPage-C3yFwVmQ.js → TrafficSourceDetailPage-7hHem-gM.js} +1 -1
- package/assets/assets/{extract-error-message-CIpeBFLl.js → extract-error-message-3GkDsu1h.js} +1 -1
- package/assets/assets/{index-BXLM3-cs.js → index-BVdH2O9w.js} +77 -77
- package/assets/assets/{server-traffic-Yt3jIi3g.js → server-traffic-CsgPsudZ.js} +1 -1
- package/assets/assets/{trash-2-xGvNHhEj.js → trash-2-B8Ipf9rI.js} +1 -1
- package/assets/index.html +1 -1
- package/dist/{chunk-ZNWMVYYU.js → chunk-NYZSY5QJ.js} +126 -7
- package/dist/{chunk-5FM7QRYD.js → chunk-SJI6JGPN.js} +1249 -1005
- package/dist/{chunk-XYBBC5CH.js → chunk-XYX447L2.js} +670 -99
- package/dist/{chunk-B32J3DSZ.js → chunk-ZISXWFQA.js} +92 -4
- package/dist/cli.js +306 -84
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-GV57RTPO.js → intelligence-service-YOZOOYUI.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +9 -9
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
TrafficSourceStatuses,
|
|
29
29
|
TrafficSourceTypes,
|
|
30
30
|
VerificationStatuses,
|
|
31
|
+
WinnabilityClasses,
|
|
31
32
|
__export,
|
|
32
33
|
absolutizeProjectUrl,
|
|
33
34
|
actionConfidenceLabel,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
citationStateToCited,
|
|
66
67
|
citationVisibilityResponseSchema,
|
|
67
68
|
classifySkillFile,
|
|
69
|
+
classifySurfaceFromCategory,
|
|
68
70
|
clusterByCosine,
|
|
69
71
|
coerceSkillManifest,
|
|
70
72
|
competitorBatchRequestSchema,
|
|
@@ -83,6 +85,7 @@ import {
|
|
|
83
85
|
deliveryFailed,
|
|
84
86
|
deltaPercent,
|
|
85
87
|
deltaTone,
|
|
88
|
+
deriveWinnabilityClass,
|
|
86
89
|
determineAnswerMentioned,
|
|
87
90
|
discoveryBucketSchema,
|
|
88
91
|
discoveryPromotePreviewSchema,
|
|
@@ -92,6 +95,7 @@ import {
|
|
|
92
95
|
discoverySessionDetailDtoSchema,
|
|
93
96
|
discoverySessionDtoSchema,
|
|
94
97
|
doctorReportSchema,
|
|
98
|
+
domainClassificationsResponseDtoSchema,
|
|
95
99
|
effectiveBrandNames,
|
|
96
100
|
effectiveDomains,
|
|
97
101
|
emptyCitationVisibility,
|
|
@@ -158,6 +162,7 @@ import {
|
|
|
158
162
|
queryDtoSchema,
|
|
159
163
|
queryGenerateRequestSchema,
|
|
160
164
|
quotaExceeded,
|
|
165
|
+
recommendationBriefDtoSchema,
|
|
161
166
|
recommendationExplainRequestSchema,
|
|
162
167
|
recommendationExplanationDtoSchema,
|
|
163
168
|
registrableDomain,
|
|
@@ -184,7 +189,10 @@ import {
|
|
|
184
189
|
snapshotListResponseSchema,
|
|
185
190
|
snapshotReportSchema,
|
|
186
191
|
snapshotRequestSchema,
|
|
192
|
+
sourceBreakdownDtoSchema,
|
|
187
193
|
summarizeCheckResults,
|
|
194
|
+
surfaceClassFromCompetitorType,
|
|
195
|
+
surfaceClassLabel,
|
|
188
196
|
trafficBackfillResponseSchema,
|
|
189
197
|
trafficConnectVercelRequestSchema,
|
|
190
198
|
trafficConnectWordpressRequestSchema,
|
|
@@ -199,6 +207,8 @@ import {
|
|
|
199
207
|
validationError,
|
|
200
208
|
visibilityStateFromAnswerMentioned,
|
|
201
209
|
windowCutoff,
|
|
210
|
+
winnabilityClassLabel,
|
|
211
|
+
winnabilityClassSchema,
|
|
202
212
|
withRetry,
|
|
203
213
|
wordpressAuditPageDtoSchema,
|
|
204
214
|
wordpressBulkMetaResultDtoSchema,
|
|
@@ -212,10 +222,10 @@ import {
|
|
|
212
222
|
wordpressSchemaDeployResultDtoSchema,
|
|
213
223
|
wordpressSchemaStatusResultDtoSchema,
|
|
214
224
|
wordpressStatusDtoSchema
|
|
215
|
-
} from "./chunk-
|
|
225
|
+
} from "./chunk-SJI6JGPN.js";
|
|
216
226
|
|
|
217
227
|
// src/intelligence-service.ts
|
|
218
|
-
import { eq as
|
|
228
|
+
import { eq as eq32, desc as desc16, asc as asc3, and as and23, ne as ne5, or as or5, inArray as inArray11, gte as gte6, lte as lte3 } from "drizzle-orm";
|
|
219
229
|
|
|
220
230
|
// ../db/src/client.ts
|
|
221
231
|
import { mkdirSync } from "fs";
|
|
@@ -244,6 +254,7 @@ __export(schema_exports, {
|
|
|
244
254
|
crawlerEventsHourly: () => crawlerEventsHourly,
|
|
245
255
|
discoveryProbes: () => discoveryProbes,
|
|
246
256
|
discoverySessions: () => discoverySessions,
|
|
257
|
+
domainClassifications: () => domainClassifications,
|
|
247
258
|
gaAiReferrals: () => gaAiReferrals,
|
|
248
259
|
gaConnections: () => gaConnections,
|
|
249
260
|
gaSocialReferrals: () => gaSocialReferrals,
|
|
@@ -269,6 +280,7 @@ __export(schema_exports, {
|
|
|
269
280
|
queries: () => queries,
|
|
270
281
|
querySnapshots: () => querySnapshots,
|
|
271
282
|
rawEventSamples: () => rawEventSamples,
|
|
283
|
+
recommendationBriefs: () => recommendationBriefs,
|
|
272
284
|
recommendationExplanations: () => recommendationExplanations,
|
|
273
285
|
runs: () => runs,
|
|
274
286
|
schedules: () => schedules,
|
|
@@ -981,6 +993,20 @@ var discoveryProbes = sqliteTable("discovery_probes", {
|
|
|
981
993
|
index("idx_discovery_probes_session").on(table.sessionId),
|
|
982
994
|
index("idx_discovery_probes_project").on(table.projectId)
|
|
983
995
|
]);
|
|
996
|
+
var domainClassifications = sqliteTable("domain_classifications", {
|
|
997
|
+
id: text("id").primaryKey(),
|
|
998
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
999
|
+
domain: text("domain").notNull(),
|
|
1000
|
+
competitorType: text("competitor_type").$type().notNull(),
|
|
1001
|
+
/** Recurrence count from the latest classifying session; informational. */
|
|
1002
|
+
hits: integer("hits").notNull().default(0),
|
|
1003
|
+
/** Discovery session that produced the latest classification. */
|
|
1004
|
+
sessionId: text("session_id"),
|
|
1005
|
+
updatedAt: text("updated_at").notNull()
|
|
1006
|
+
}, (table) => [
|
|
1007
|
+
uniqueIndex("idx_domain_classifications_project_domain").on(table.projectId, table.domain),
|
|
1008
|
+
index("idx_domain_classifications_project").on(table.projectId)
|
|
1009
|
+
]);
|
|
984
1010
|
var contentTargetDismissals = sqliteTable("content_target_dismissals", {
|
|
985
1011
|
id: text("id").primaryKey(),
|
|
986
1012
|
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
@@ -1011,6 +1037,26 @@ var recommendationExplanations = sqliteTable("recommendation_explanations", {
|
|
|
1011
1037
|
),
|
|
1012
1038
|
index("idx_recommendation_explanations_project").on(table.projectId)
|
|
1013
1039
|
]);
|
|
1040
|
+
var recommendationBriefs = sqliteTable("recommendation_briefs", {
|
|
1041
|
+
id: text("id").primaryKey(),
|
|
1042
|
+
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
|
1043
|
+
targetRef: text("target_ref").notNull(),
|
|
1044
|
+
promptVersion: text("prompt_version").notNull(),
|
|
1045
|
+
provider: text("provider").notNull(),
|
|
1046
|
+
model: text("model").notNull(),
|
|
1047
|
+
/** The structured brief payload (angle, why-winnable, schema hookup, etc.). */
|
|
1048
|
+
brief: text("brief", { mode: "json" }).$type().notNull(),
|
|
1049
|
+
/** Estimated cost in millicents (1/100 of a cent) for audit; 0 if unknown. */
|
|
1050
|
+
costMillicents: integer("cost_millicents").notNull().default(0),
|
|
1051
|
+
generatedAt: text("generated_at").notNull()
|
|
1052
|
+
}, (table) => [
|
|
1053
|
+
uniqueIndex("idx_recommendation_briefs_unique").on(
|
|
1054
|
+
table.projectId,
|
|
1055
|
+
table.targetRef,
|
|
1056
|
+
table.promptVersion
|
|
1057
|
+
),
|
|
1058
|
+
index("idx_recommendation_briefs_project").on(table.projectId)
|
|
1059
|
+
]);
|
|
1014
1060
|
var migrationsTable = sqliteTable("_migrations", {
|
|
1015
1061
|
version: integer("version").primaryKey(),
|
|
1016
1062
|
name: text("name").notNull(),
|
|
@@ -2690,6 +2736,49 @@ var MIGRATION_VERSIONS = [
|
|
|
2690
2736
|
)`,
|
|
2691
2737
|
`CREATE INDEX IF NOT EXISTS idx_gbp_place_details_loc ON gbp_place_details(project_id, location_name, synced_at)`
|
|
2692
2738
|
]
|
|
2739
|
+
},
|
|
2740
|
+
{
|
|
2741
|
+
// Durable per-domain classification of cited surfaces, upserted on each
|
|
2742
|
+
// discovery completion. Powers the content-targets winnabilityClass winnability
|
|
2743
|
+
// gate without re-running a discovery probe. Keyed (project_id, domain).
|
|
2744
|
+
version: 73,
|
|
2745
|
+
name: "domain-classifications",
|
|
2746
|
+
statements: [
|
|
2747
|
+
`CREATE TABLE IF NOT EXISTS domain_classifications (
|
|
2748
|
+
id TEXT PRIMARY KEY,
|
|
2749
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
2750
|
+
domain TEXT NOT NULL,
|
|
2751
|
+
competitor_type TEXT NOT NULL,
|
|
2752
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
2753
|
+
session_id TEXT,
|
|
2754
|
+
updated_at TEXT NOT NULL
|
|
2755
|
+
)`,
|
|
2756
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_classifications_project_domain ON domain_classifications(project_id, domain)`,
|
|
2757
|
+
`CREATE INDEX IF NOT EXISTS idx_domain_classifications_project ON domain_classifications(project_id)`
|
|
2758
|
+
]
|
|
2759
|
+
},
|
|
2760
|
+
{
|
|
2761
|
+
// Structured LLM content briefs, cached per (project, target_ref,
|
|
2762
|
+
// prompt_version). Separate from recommendation_explanations so the
|
|
2763
|
+
// structured brief payload and its version-keyed cache never collide with
|
|
2764
|
+
// the prompt-version-blind explanation lookup.
|
|
2765
|
+
version: 74,
|
|
2766
|
+
name: "recommendation-briefs",
|
|
2767
|
+
statements: [
|
|
2768
|
+
`CREATE TABLE IF NOT EXISTS recommendation_briefs (
|
|
2769
|
+
id TEXT PRIMARY KEY,
|
|
2770
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
2771
|
+
target_ref TEXT NOT NULL,
|
|
2772
|
+
prompt_version TEXT NOT NULL,
|
|
2773
|
+
provider TEXT NOT NULL,
|
|
2774
|
+
model TEXT NOT NULL,
|
|
2775
|
+
brief TEXT NOT NULL,
|
|
2776
|
+
cost_millicents INTEGER NOT NULL DEFAULT 0,
|
|
2777
|
+
generated_at TEXT NOT NULL
|
|
2778
|
+
)`,
|
|
2779
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_recommendation_briefs_unique ON recommendation_briefs(project_id, target_ref, prompt_version)`,
|
|
2780
|
+
`CREATE INDEX IF NOT EXISTS idx_recommendation_briefs_project ON recommendation_briefs(project_id)`
|
|
2781
|
+
]
|
|
2693
2782
|
}
|
|
2694
2783
|
];
|
|
2695
2784
|
function isDuplicateColumnError(err) {
|
|
@@ -3547,6 +3636,7 @@ function buildContentTargetRows(input) {
|
|
|
3547
3636
|
gscAvgPosition: cq.gscPosition,
|
|
3548
3637
|
organicSessions: input.gaTrafficByPage.get(ourPage.url) ?? 0
|
|
3549
3638
|
} : null;
|
|
3639
|
+
const { winnabilityClass, winnability } = deriveWinnabilityClass(cq.citedSurfaceDomains, input.domainClasses);
|
|
3550
3640
|
rows.push({
|
|
3551
3641
|
targetRef,
|
|
3552
3642
|
query: cq.query,
|
|
@@ -3558,7 +3648,9 @@ function buildContentTargetRows(input) {
|
|
|
3558
3648
|
drivers: scoring.drivers,
|
|
3559
3649
|
demandSource: scoring.demandSource,
|
|
3560
3650
|
actionConfidence,
|
|
3561
|
-
existingAction: input.inProgressActions.get(targetRef) ?? null
|
|
3651
|
+
existingAction: input.inProgressActions.get(targetRef) ?? null,
|
|
3652
|
+
winnabilityClass,
|
|
3653
|
+
winnability
|
|
3562
3654
|
});
|
|
3563
3655
|
}
|
|
3564
3656
|
return dedupeByIntent(
|
|
@@ -7173,9 +7265,32 @@ async function analyticsRoutes(app) {
|
|
|
7173
7265
|
const project = resolveProject(app.db, request.params.name);
|
|
7174
7266
|
const window = parseWindow(request.query.window);
|
|
7175
7267
|
const cutoff = windowCutoff(window);
|
|
7268
|
+
let limit = null;
|
|
7269
|
+
if (request.query.limit !== void 0) {
|
|
7270
|
+
const n = Number(request.query.limit);
|
|
7271
|
+
if (!Number.isInteger(n) || n <= 0) throw validationError('"limit" must be a positive integer');
|
|
7272
|
+
limit = n;
|
|
7273
|
+
}
|
|
7274
|
+
const classifyCtx = {
|
|
7275
|
+
projectDomains: effectiveDomains(project),
|
|
7276
|
+
competitorDomains: app.db.select({ domain: competitors.domain }).from(competitors).where(eq10(competitors.projectId, project.id)).all().map((r) => r.domain)
|
|
7277
|
+
};
|
|
7278
|
+
const storedSurfaceClasses = /* @__PURE__ */ new Map();
|
|
7279
|
+
for (const row of app.db.select({ domain: domainClassifications.domain, competitorType: domainClassifications.competitorType }).from(domainClassifications).where(eq10(domainClassifications.projectId, project.id)).all()) {
|
|
7280
|
+
const mapped = surfaceClassFromCompetitorType(row.competitorType);
|
|
7281
|
+
if (mapped) storedSurfaceClasses.set(normalizeProjectDomain(row.domain), mapped);
|
|
7282
|
+
}
|
|
7176
7283
|
const windowRuns = app.db.select().from(runs).where(and5(eq10(runs.projectId, project.id), notProbeRun())).orderBy(desc3(runs.createdAt), desc3(runs.id)).all().filter((r) => r.status === "completed" || r.status === "partial").filter((r) => !cutoff || r.createdAt >= cutoff);
|
|
7177
7284
|
if (windowRuns.length === 0) {
|
|
7178
|
-
return reply.send({
|
|
7285
|
+
return reply.send({
|
|
7286
|
+
overall: [],
|
|
7287
|
+
byQuery: {},
|
|
7288
|
+
ranked: buildRankedList(/* @__PURE__ */ new Map(), limit),
|
|
7289
|
+
byProvider: {},
|
|
7290
|
+
runId: "",
|
|
7291
|
+
window,
|
|
7292
|
+
limit
|
|
7293
|
+
});
|
|
7179
7294
|
}
|
|
7180
7295
|
const latestGroup = groupRunsByCreatedAt(windowRuns)[0] ?? [];
|
|
7181
7296
|
const latestRunId = pickGroupRepresentative(latestGroup)?.id ?? windowRuns[0].id;
|
|
@@ -7183,28 +7298,49 @@ async function analyticsRoutes(app) {
|
|
|
7183
7298
|
const snapshots = app.db.select({
|
|
7184
7299
|
queryId: querySnapshots.queryId,
|
|
7185
7300
|
query: queries.query,
|
|
7301
|
+
provider: querySnapshots.provider,
|
|
7186
7302
|
rawResponse: querySnapshots.rawResponse
|
|
7187
7303
|
}).from(querySnapshots).leftJoin(queries, eq10(querySnapshots.queryId, queries.id)).where(inArray3(querySnapshots.runId, windowRunIds)).all();
|
|
7188
7304
|
const overallCounts = /* @__PURE__ */ new Map();
|
|
7189
7305
|
const byQuery = {};
|
|
7306
|
+
const overallDomains = /* @__PURE__ */ new Map();
|
|
7307
|
+
const providerDomains = /* @__PURE__ */ new Map();
|
|
7190
7308
|
for (const snap of snapshots) {
|
|
7191
7309
|
const sources = parseGroundingSources(snap.rawResponse);
|
|
7192
7310
|
const qCounts = /* @__PURE__ */ new Map();
|
|
7193
7311
|
for (const source of sources) {
|
|
7194
|
-
const { category, domain } = categorizeSource(source.uri);
|
|
7312
|
+
const { category, label, domain } = categorizeSource(source.uri);
|
|
7313
|
+
const surfaceClass = classifySurfaceFromCategory(
|
|
7314
|
+
domain,
|
|
7315
|
+
category,
|
|
7316
|
+
classifyCtx,
|
|
7317
|
+
storedSurfaceClasses.get(normalizeProjectDomain(domain))
|
|
7318
|
+
);
|
|
7195
7319
|
if (!overallCounts.has(category)) overallCounts.set(category, /* @__PURE__ */ new Map());
|
|
7196
7320
|
const oDomains = overallCounts.get(category);
|
|
7197
7321
|
oDomains.set(domain, (oDomains.get(domain) ?? 0) + 1);
|
|
7198
7322
|
if (!qCounts.has(category)) qCounts.set(category, /* @__PURE__ */ new Map());
|
|
7199
7323
|
const qDomains = qCounts.get(category);
|
|
7200
7324
|
qDomains.set(domain, (qDomains.get(domain) ?? 0) + 1);
|
|
7325
|
+
bumpDomain(overallDomains, domain, category, label, surfaceClass);
|
|
7326
|
+
let pm = providerDomains.get(snap.provider);
|
|
7327
|
+
if (!pm) {
|
|
7328
|
+
pm = /* @__PURE__ */ new Map();
|
|
7329
|
+
providerDomains.set(snap.provider, pm);
|
|
7330
|
+
}
|
|
7331
|
+
bumpDomain(pm, domain, category, label, surfaceClass);
|
|
7201
7332
|
}
|
|
7202
7333
|
if (sources.length > 0 && snap.query) {
|
|
7203
7334
|
byQuery[snap.query] = buildCategoryCounts(qCounts);
|
|
7204
7335
|
}
|
|
7205
7336
|
}
|
|
7206
7337
|
const overall = buildCategoryCounts(overallCounts);
|
|
7207
|
-
|
|
7338
|
+
const ranked = buildRankedList(overallDomains, limit);
|
|
7339
|
+
const byProvider = {};
|
|
7340
|
+
for (const [provider, domains] of providerDomains) {
|
|
7341
|
+
byProvider[provider] = buildRankedList(domains, limit);
|
|
7342
|
+
}
|
|
7343
|
+
return reply.send({ overall, byQuery, ranked, byProvider, runId: latestRunId, window, limit });
|
|
7208
7344
|
});
|
|
7209
7345
|
}
|
|
7210
7346
|
function isProviderInfraDomain(uri) {
|
|
@@ -7315,6 +7451,52 @@ function computeTrend(buckets, rateKey) {
|
|
|
7315
7451
|
if (diff < -0.05) return "declining";
|
|
7316
7452
|
return "stable";
|
|
7317
7453
|
}
|
|
7454
|
+
function round4(ratio) {
|
|
7455
|
+
return Math.round(ratio * 1e4) / 1e4;
|
|
7456
|
+
}
|
|
7457
|
+
function bumpDomain(map, domain, category, label, surfaceClass) {
|
|
7458
|
+
const existing = map.get(domain);
|
|
7459
|
+
if (existing) existing.count += 1;
|
|
7460
|
+
else map.set(domain, { domain, count: 1, category, label, surfaceClass });
|
|
7461
|
+
}
|
|
7462
|
+
function buildRankedList(domains, limit) {
|
|
7463
|
+
const all = [...domains.values()];
|
|
7464
|
+
const totalCitedSlots = all.reduce((sum, d) => sum + d.count, 0);
|
|
7465
|
+
const domainTotal = all.length;
|
|
7466
|
+
all.sort((a, b) => b.count - a.count || a.domain.localeCompare(b.domain));
|
|
7467
|
+
const shownEntries = limit != null && limit < all.length ? all.slice(0, limit) : all;
|
|
7468
|
+
const entries = shownEntries.map((d) => ({
|
|
7469
|
+
domain: d.domain,
|
|
7470
|
+
count: d.count,
|
|
7471
|
+
percentage: totalCitedSlots > 0 ? round4(d.count / totalCitedSlots) : 0,
|
|
7472
|
+
category: d.category,
|
|
7473
|
+
label: d.label,
|
|
7474
|
+
surfaceClass: d.surfaceClass
|
|
7475
|
+
}));
|
|
7476
|
+
const shownSlots = shownEntries.reduce((sum, d) => sum + d.count, 0);
|
|
7477
|
+
const classAgg = /* @__PURE__ */ new Map();
|
|
7478
|
+
for (const d of all) {
|
|
7479
|
+
const entry = classAgg.get(d.surfaceClass) ?? { count: 0, domainCount: 0 };
|
|
7480
|
+
entry.count += d.count;
|
|
7481
|
+
entry.domainCount += 1;
|
|
7482
|
+
classAgg.set(d.surfaceClass, entry);
|
|
7483
|
+
}
|
|
7484
|
+
const bySurfaceClass = [...classAgg.entries()].map(([surfaceClass, v]) => ({
|
|
7485
|
+
surfaceClass,
|
|
7486
|
+
label: surfaceClassLabel(surfaceClass),
|
|
7487
|
+
count: v.count,
|
|
7488
|
+
percentage: totalCitedSlots > 0 ? round4(v.count / totalCitedSlots) : 0,
|
|
7489
|
+
domainCount: v.domainCount
|
|
7490
|
+
})).sort((a, b) => b.count - a.count || a.surfaceClass.localeCompare(b.surfaceClass));
|
|
7491
|
+
return {
|
|
7492
|
+
totalCitedSlots,
|
|
7493
|
+
domainTotal,
|
|
7494
|
+
entries,
|
|
7495
|
+
truncatedDomainCount: domainTotal - shownEntries.length,
|
|
7496
|
+
truncatedCitedSlots: totalCitedSlots - shownSlots,
|
|
7497
|
+
bySurfaceClass
|
|
7498
|
+
};
|
|
7499
|
+
}
|
|
7318
7500
|
function buildCategoryCounts(counts) {
|
|
7319
7501
|
let grandTotal = 0;
|
|
7320
7502
|
for (const domains of counts.values()) {
|
|
@@ -7528,6 +7710,7 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
|
7528
7710
|
});
|
|
7529
7711
|
const gaTrafficByPage = buildGaTrafficByPage(db, projectId);
|
|
7530
7712
|
const totalAiReferralSessions = sumAiReferralSessions(db, projectId);
|
|
7713
|
+
const domainClasses = loadDomainClasses(db, projectId);
|
|
7531
7714
|
return {
|
|
7532
7715
|
projectId,
|
|
7533
7716
|
ownDomain,
|
|
@@ -7540,9 +7723,14 @@ function loadOrchestratorInput(db, project, locationFilter = void 0) {
|
|
|
7540
7723
|
totalAiReferralSessions,
|
|
7541
7724
|
latestRunId,
|
|
7542
7725
|
latestRunTimestamp,
|
|
7543
|
-
inProgressActions: /* @__PURE__ */ new Map()
|
|
7726
|
+
inProgressActions: /* @__PURE__ */ new Map(),
|
|
7727
|
+
domainClasses
|
|
7544
7728
|
};
|
|
7545
7729
|
}
|
|
7730
|
+
function loadDomainClasses(db, projectId) {
|
|
7731
|
+
const rows = db.select({ domain: domainClassifications.domain, competitorType: domainClassifications.competitorType }).from(domainClassifications).where(eq12(domainClassifications.projectId, projectId)).all();
|
|
7732
|
+
return new Map(rows.map((r) => [normalizeDomain(r.domain), r.competitorType]));
|
|
7733
|
+
}
|
|
7546
7734
|
function buildQueryIntentModifiers(project, locationFilter) {
|
|
7547
7735
|
if (locationFilter === void 0 || locationFilter === null) return [];
|
|
7548
7736
|
const locations = project.locations ?? [];
|
|
@@ -7749,6 +7937,7 @@ function aggregateCandidate(opts) {
|
|
|
7749
7937
|
const competitorTally = /* @__PURE__ */ new Map();
|
|
7750
7938
|
const competitorGroundingTally = /* @__PURE__ */ new Map();
|
|
7751
7939
|
const ourGroundingTally = /* @__PURE__ */ new Map();
|
|
7940
|
+
const citedSurfaceTally = /* @__PURE__ */ new Map();
|
|
7752
7941
|
let ourCitedInLatestRun = false;
|
|
7753
7942
|
for (const snap of opts.snapshots) {
|
|
7754
7943
|
const isLatestRun = snap.runId === opts.latestRunId;
|
|
@@ -7767,6 +7956,7 @@ function aggregateCandidate(opts) {
|
|
|
7767
7956
|
recordGroundingHit(ourGroundingTally, g, domain, snap.provider);
|
|
7768
7957
|
continue;
|
|
7769
7958
|
}
|
|
7959
|
+
citedSurfaceTally.set(domain, (citedSurfaceTally.get(domain) ?? 0) + 1);
|
|
7770
7960
|
if (!opts.competitorSet.has(domain)) continue;
|
|
7771
7961
|
recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
|
|
7772
7962
|
}
|
|
@@ -7785,6 +7975,7 @@ function aggregateCandidate(opts) {
|
|
|
7785
7975
|
recentMissRate,
|
|
7786
7976
|
ourGroundingUrls: Array.from(ourGroundingTally.values()),
|
|
7787
7977
|
competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
|
|
7978
|
+
citedSurfaceDomains: Array.from(citedSurfaceTally.entries()).map(([domain, citationCount]) => ({ domain, citationCount })),
|
|
7788
7979
|
runsOfHistory: new Set(opts.snapshots.map((s) => s.runId)).size
|
|
7789
7980
|
};
|
|
7790
7981
|
}
|
|
@@ -7820,6 +8011,7 @@ function emptyCandidate(query) {
|
|
|
7820
8011
|
recentMissRate: 0,
|
|
7821
8012
|
ourGroundingUrls: [],
|
|
7822
8013
|
competitorGroundingUrls: [],
|
|
8014
|
+
citedSurfaceDomains: [],
|
|
7823
8015
|
runsOfHistory: 0
|
|
7824
8016
|
};
|
|
7825
8017
|
}
|
|
@@ -7886,6 +8078,17 @@ function formatExplanationRow(row) {
|
|
|
7886
8078
|
generatedAt: row.generatedAt
|
|
7887
8079
|
};
|
|
7888
8080
|
}
|
|
8081
|
+
function formatBriefRow(row) {
|
|
8082
|
+
return {
|
|
8083
|
+
targetRef: row.targetRef,
|
|
8084
|
+
promptVersion: row.promptVersion,
|
|
8085
|
+
provider: row.provider,
|
|
8086
|
+
model: row.model,
|
|
8087
|
+
brief: row.brief,
|
|
8088
|
+
costMillicents: row.costMillicents,
|
|
8089
|
+
generatedAt: row.generatedAt
|
|
8090
|
+
};
|
|
8091
|
+
}
|
|
7889
8092
|
function findRecommendationByRef(db, project, targetRef) {
|
|
7890
8093
|
const input = loadOrchestratorInput(db, project);
|
|
7891
8094
|
const rows = buildContentTargetRows(input);
|
|
@@ -7896,6 +8099,10 @@ async function contentRoutes(app, opts = {}) {
|
|
|
7896
8099
|
const project = resolveProject(app.db, request.params.name);
|
|
7897
8100
|
const includeInProgress = request.query["include-in-progress"] === "true";
|
|
7898
8101
|
const limit = parseLimitParam(request.query.limit);
|
|
8102
|
+
if (request.query["surface-class"] !== void 0) {
|
|
8103
|
+
throw validationError('"surface-class" was renamed to "winnability-class"');
|
|
8104
|
+
}
|
|
8105
|
+
const winnabilityClassFilter = parseWinnabilityClassFilter(request.query["winnability-class"], request.query.ownable);
|
|
7899
8106
|
const input = loadOrchestratorInput(app.db, project);
|
|
7900
8107
|
let rows = buildContentTargetRows(input);
|
|
7901
8108
|
if (!includeInProgress) {
|
|
@@ -7905,6 +8112,10 @@ async function contentRoutes(app, opts = {}) {
|
|
|
7905
8112
|
if (dismissed.size > 0) {
|
|
7906
8113
|
rows = rows.filter((r) => !dismissed.has(r.targetRef));
|
|
7907
8114
|
}
|
|
8115
|
+
if (winnabilityClassFilter) {
|
|
8116
|
+
rows = rows.filter((r) => r.winnabilityClass === winnabilityClassFilter);
|
|
8117
|
+
}
|
|
8118
|
+
rows = [...rows].sort((a, b) => winnabilityClassRank(a.winnabilityClass) - winnabilityClassRank(b.winnabilityClass));
|
|
7908
8119
|
if (limit !== void 0) {
|
|
7909
8120
|
rows = rows.slice(0, limit);
|
|
7910
8121
|
}
|
|
@@ -8065,6 +8276,113 @@ async function contentRoutes(app, opts = {}) {
|
|
|
8065
8276
|
if (!row) throw notFound("recommendationExplanation", targetRef);
|
|
8066
8277
|
return reply.send(formatExplanationRow(row));
|
|
8067
8278
|
});
|
|
8279
|
+
app.get("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
|
|
8280
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8281
|
+
const { targetRef } = request.params;
|
|
8282
|
+
const recommendation = findRecommendationByRef(app.db, project, targetRef);
|
|
8283
|
+
if (!recommendation) {
|
|
8284
|
+
throw notFound("contentRecommendation", targetRef);
|
|
8285
|
+
}
|
|
8286
|
+
if (recommendation.winnabilityClass === WinnabilityClasses.ceded) {
|
|
8287
|
+
throw validationError(
|
|
8288
|
+
`Cannot return a brief for "${recommendation.query}": its cited surface is now ceded (dominated by aggregators/editorial).`
|
|
8289
|
+
);
|
|
8290
|
+
}
|
|
8291
|
+
const row = lookupCachedBrief(app.db, project.id, targetRef, opts.briefPromptVersion);
|
|
8292
|
+
if (!row) throw notFound("recommendationBrief", request.params.targetRef);
|
|
8293
|
+
return reply.send(formatBriefRow(row));
|
|
8294
|
+
});
|
|
8295
|
+
app.post("/projects/:name/content/recommendations/:targetRef/brief", async (request, reply) => {
|
|
8296
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8297
|
+
const synthesizer = opts.briefContentRecommendation;
|
|
8298
|
+
if (!synthesizer) {
|
|
8299
|
+
throw providerError(
|
|
8300
|
+
"No AI provider configured for content briefs. Configure a provider via `canonry settings` or set an API key env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, ZAI_API_KEY)."
|
|
8301
|
+
);
|
|
8302
|
+
}
|
|
8303
|
+
const parsed = recommendationExplainRequestSchema.safeParse(request.body ?? {});
|
|
8304
|
+
if (!parsed.success) {
|
|
8305
|
+
throw validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
|
|
8306
|
+
}
|
|
8307
|
+
const body = parsed.data;
|
|
8308
|
+
const { targetRef } = request.params;
|
|
8309
|
+
const recommendation = findRecommendationByRef(app.db, project, targetRef);
|
|
8310
|
+
if (!recommendation) {
|
|
8311
|
+
throw notFound("contentRecommendation", targetRef);
|
|
8312
|
+
}
|
|
8313
|
+
if (recommendation.winnabilityClass === WinnabilityClasses.ceded) {
|
|
8314
|
+
throw validationError(
|
|
8315
|
+
`Cannot synthesize a brief for "${recommendation.query}": its cited surface is ceded (dominated by aggregators/editorial). This is not a query first-party content can realistically win.`
|
|
8316
|
+
);
|
|
8317
|
+
}
|
|
8318
|
+
if (!body.forceRefresh) {
|
|
8319
|
+
const cached = lookupCachedBrief(app.db, project.id, targetRef, opts.briefPromptVersion);
|
|
8320
|
+
if (cached) return reply.send(formatBriefRow(cached));
|
|
8321
|
+
}
|
|
8322
|
+
const result = await synthesizer({
|
|
8323
|
+
projectId: project.id,
|
|
8324
|
+
projectName: project.name,
|
|
8325
|
+
canonicalDomain: project.canonicalDomain,
|
|
8326
|
+
recommendation,
|
|
8327
|
+
providerOverride: body.provider,
|
|
8328
|
+
modelOverride: body.model
|
|
8329
|
+
});
|
|
8330
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
8331
|
+
app.db.insert(recommendationBriefs).values({
|
|
8332
|
+
id: crypto11.randomUUID(),
|
|
8333
|
+
projectId: project.id,
|
|
8334
|
+
targetRef,
|
|
8335
|
+
promptVersion: result.promptVersion,
|
|
8336
|
+
provider: result.provider,
|
|
8337
|
+
model: result.model,
|
|
8338
|
+
brief: result.brief,
|
|
8339
|
+
costMillicents: result.costMillicents,
|
|
8340
|
+
generatedAt: now
|
|
8341
|
+
}).onConflictDoUpdate({
|
|
8342
|
+
target: [
|
|
8343
|
+
recommendationBriefs.projectId,
|
|
8344
|
+
recommendationBriefs.targetRef,
|
|
8345
|
+
recommendationBriefs.promptVersion
|
|
8346
|
+
],
|
|
8347
|
+
set: {
|
|
8348
|
+
provider: result.provider,
|
|
8349
|
+
model: result.model,
|
|
8350
|
+
brief: result.brief,
|
|
8351
|
+
costMillicents: result.costMillicents,
|
|
8352
|
+
generatedAt: now
|
|
8353
|
+
}
|
|
8354
|
+
}).run();
|
|
8355
|
+
const row = app.db.select().from(recommendationBriefs).where(and8(
|
|
8356
|
+
eq13(recommendationBriefs.projectId, project.id),
|
|
8357
|
+
eq13(recommendationBriefs.targetRef, targetRef),
|
|
8358
|
+
eq13(recommendationBriefs.promptVersion, result.promptVersion)
|
|
8359
|
+
)).get();
|
|
8360
|
+
if (!row) throw notFound("recommendationBrief", targetRef);
|
|
8361
|
+
return reply.send(formatBriefRow(row));
|
|
8362
|
+
});
|
|
8363
|
+
app.get("/projects/:name/content/domain-classifications", async (request) => {
|
|
8364
|
+
const project = resolveProject(app.db, request.params.name);
|
|
8365
|
+
const rows = app.db.select().from(domainClassifications).where(eq13(domainClassifications.projectId, project.id)).orderBy(desc6(domainClassifications.hits)).all();
|
|
8366
|
+
const response = {
|
|
8367
|
+
classifications: rows.map((r) => ({
|
|
8368
|
+
domain: r.domain,
|
|
8369
|
+
competitorType: r.competitorType,
|
|
8370
|
+
hits: r.hits,
|
|
8371
|
+
updatedAt: r.updatedAt
|
|
8372
|
+
}))
|
|
8373
|
+
};
|
|
8374
|
+
return response;
|
|
8375
|
+
});
|
|
8376
|
+
}
|
|
8377
|
+
function lookupCachedBrief(db, projectId, targetRef, promptVersion) {
|
|
8378
|
+
const conditions = [
|
|
8379
|
+
eq13(recommendationBriefs.projectId, projectId),
|
|
8380
|
+
eq13(recommendationBriefs.targetRef, targetRef)
|
|
8381
|
+
];
|
|
8382
|
+
if (promptVersion !== void 0) {
|
|
8383
|
+
conditions.push(eq13(recommendationBriefs.promptVersion, promptVersion));
|
|
8384
|
+
}
|
|
8385
|
+
return db.select().from(recommendationBriefs).where(and8(...conditions)).orderBy(desc6(recommendationBriefs.generatedAt)).limit(1).get();
|
|
8068
8386
|
}
|
|
8069
8387
|
function parseLimitParam(raw) {
|
|
8070
8388
|
if (raw === void 0) return void 0;
|
|
@@ -8074,6 +8392,20 @@ function parseLimitParam(raw) {
|
|
|
8074
8392
|
}
|
|
8075
8393
|
return parsed;
|
|
8076
8394
|
}
|
|
8395
|
+
function parseWinnabilityClassFilter(raw, ownable) {
|
|
8396
|
+
if (raw !== void 0) {
|
|
8397
|
+
const parsed = winnabilityClassSchema.safeParse(raw);
|
|
8398
|
+
if (!parsed.success) {
|
|
8399
|
+
throw validationError('"winnability-class" must be "ownable" or "ceded"');
|
|
8400
|
+
}
|
|
8401
|
+
return parsed.data;
|
|
8402
|
+
}
|
|
8403
|
+
if (ownable === "true") return WinnabilityClasses.ownable;
|
|
8404
|
+
return void 0;
|
|
8405
|
+
}
|
|
8406
|
+
function winnabilityClassRank(winnabilityClass) {
|
|
8407
|
+
return winnabilityClass === WinnabilityClasses.ownable ? 0 : 1;
|
|
8408
|
+
}
|
|
8077
8409
|
|
|
8078
8410
|
// ../api-routes/src/report-renderer.ts
|
|
8079
8411
|
var COLORS = {
|
|
@@ -9995,9 +10327,11 @@ function renderOpportunities(report) {
|
|
|
9995
10327
|
const ourPage = o.ourBestPage ? `<a href="${safeHref(absolutizeProjectUrl(o.ourBestPage.url, canonical))}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">No page yet</span>';
|
|
9996
10328
|
const winning = o.winningCompetitor ? `<a href="${safeHref(o.winningCompetitor.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
9997
10329
|
const drivers = o.drivers.length > 0 ? `<ul class="driver-list">${o.drivers.map((d) => `<li>${escapeHtml(d)}</li>`).join("")}</ul>` : '<span class="cell-not-cited">No driver signal yet</span>';
|
|
10330
|
+
const surfaceTone = o.winnabilityClass === "ceded" ? "tone-caution" : "tone-neutral";
|
|
9998
10331
|
return `<tr>
|
|
9999
10332
|
<td>${escapeHtml(o.query)}</td>
|
|
10000
10333
|
<td><span class="badge tone-neutral">${escapeHtml(contentActionLabel(o.action))}</span></td>
|
|
10334
|
+
<td><span class="badge ${surfaceTone}">${escapeHtml(winnabilityClassLabel(o.winnabilityClass))}</span></td>
|
|
10001
10335
|
<td class="numeric" title="Opportunity score (0\u2013100)">${Math.round(o.score)}</td>
|
|
10002
10336
|
<td>${drivers}</td>
|
|
10003
10337
|
<td>${ourPage}</td>
|
|
@@ -10010,10 +10344,10 @@ function renderOpportunities(report) {
|
|
|
10010
10344
|
id: "content-opportunities",
|
|
10011
10345
|
eyebrow: "Section 14",
|
|
10012
10346
|
title: "Content Opportunities",
|
|
10013
|
-
intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger."
|
|
10347
|
+
intro: "Queries where content work has the clearest path to more AI citations. Opportunity score is 0\u2013100, higher = stronger. Winnability flags whether the cited surface is ownable or ceded to aggregators/editorial."
|
|
10014
10348
|
},
|
|
10015
10349
|
`${highlights}<table class="report-table">
|
|
10016
|
-
<thead><tr><th>Query</th><th>Action</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
|
|
10350
|
+
<thead><tr><th>Query</th><th>Action</th><th>Winnability</th><th class="numeric" title="Opportunity score (0\u2013100)">Score</th><th>Why</th><th>Our page</th><th>Winning competitor</th><th>Confidence</th></tr></thead>
|
|
10017
10351
|
<tbody>${rows}</tbody>
|
|
10018
10352
|
</table>`
|
|
10019
10353
|
);
|
|
@@ -10241,7 +10575,7 @@ function renderClientEvidenceSummary(report) {
|
|
|
10241
10575
|
<ul class="client-opportunity-list">
|
|
10242
10576
|
${opportunities.map((o) => `<li>
|
|
10243
10577
|
<div class="op-query">${escapeHtml(o.query)}</div>
|
|
10244
|
-
<div class="op-action">${escapeHtml(contentActionLabel(o.action))}</div>
|
|
10578
|
+
<div class="op-action">${escapeHtml(contentActionLabel(o.action))}${o.winnabilityClass === "ceded" ? ' <span class="badge tone-caution">Ceded surface</span>' : ""}</div>
|
|
10245
10579
|
</li>`).join("")}
|
|
10246
10580
|
</ul>
|
|
10247
10581
|
</div>`);
|
|
@@ -12618,6 +12952,8 @@ var SCHEMA_TABLE = {
|
|
|
12618
12952
|
ContentTargetsResponseDto: contentTargetsResponseDtoSchema,
|
|
12619
12953
|
CreateApiKeyRequest: createApiKeyRequestSchema,
|
|
12620
12954
|
CreatedApiKeyDto: createdApiKeyDtoSchema,
|
|
12955
|
+
DomainClassificationsResponseDto: domainClassificationsResponseDtoSchema,
|
|
12956
|
+
RecommendationBriefDto: recommendationBriefDtoSchema,
|
|
12621
12957
|
RecommendationExplanationDto: recommendationExplanationDtoSchema,
|
|
12622
12958
|
DiscoveryPromotePreview: discoveryPromotePreviewSchema,
|
|
12623
12959
|
DiscoveryPromoteResult: discoveryPromoteResultSchema,
|
|
@@ -12664,6 +13000,7 @@ var SCHEMA_TABLE = {
|
|
|
12664
13000
|
SnapshotDiffResponse: snapshotDiffResponseSchema,
|
|
12665
13001
|
SnapshotListResponse: snapshotListResponseSchema,
|
|
12666
13002
|
SnapshotReportDto: snapshotReportSchema,
|
|
13003
|
+
SourceBreakdownDto: sourceBreakdownDtoSchema,
|
|
12667
13004
|
TrafficBackfillResponse: trafficBackfillResponseSchema,
|
|
12668
13005
|
TrafficEventsResponse: trafficEventsResponseSchema,
|
|
12669
13006
|
TrafficSourceDetailDto: trafficSourceDetailDtoSchema,
|
|
@@ -13616,10 +13953,9 @@ var routeCatalog = [
|
|
|
13616
13953
|
path: "/api/v1/projects/{name}/analytics/sources",
|
|
13617
13954
|
summary: "Get source origin analytics",
|
|
13618
13955
|
tags: ["analytics"],
|
|
13619
|
-
parameters: [nameParameter, analyticsWindowParameter],
|
|
13956
|
+
parameters: [nameParameter, analyticsWindowParameter, limitQueryParameter],
|
|
13620
13957
|
responses: {
|
|
13621
|
-
|
|
13622
|
-
200: rawJsonResponse("Source breakdown returned.", looseObjectSchema),
|
|
13958
|
+
200: jsonResponse("Source breakdown returned.", "SourceBreakdownDto"),
|
|
13623
13959
|
404: errorResponse("Project not found.")
|
|
13624
13960
|
}
|
|
13625
13961
|
},
|
|
@@ -15529,16 +15865,18 @@ var routeCatalog = [
|
|
|
15529
15865
|
method: "get",
|
|
15530
15866
|
path: "/api/v1/projects/{name}/content/targets",
|
|
15531
15867
|
summary: "Ranked, action-typed content opportunities",
|
|
15532
|
-
description: "Returns the canonical opportunity list. Each row is `{query, action, ourBestPage?, winningCompetitor?, score, scoreBreakdown, drivers[], demandSource, actionConfidence, existingAction?}`. Hides rows with in-progress actions by default; pass `?include-in-progress=true` to include them annotated.",
|
|
15868
|
+
description: "Returns the canonical opportunity list. Each row is `{query, action, ourBestPage?, winningCompetitor?, score, scoreBreakdown, drivers[], demandSource, actionConfidence, existingAction?, winnabilityClass, winnability?}`. `winnabilityClass` is the deterministic winnability gate (`ownable` worth a brief, `ceded` an aggregator/editorial head term to skip). Ownable rows sort first. Hides rows with in-progress actions by default; pass `?include-in-progress=true` to include them annotated.",
|
|
15533
15869
|
tags: ["content"],
|
|
15534
15870
|
parameters: [
|
|
15535
15871
|
nameParameter,
|
|
15536
15872
|
{ name: "limit", in: "query", description: "Max rows returned.", schema: stringSchema },
|
|
15537
|
-
{ name: "include-in-progress", in: "query", description: "Include rows with in-flight tracked actions.", schema: stringSchema }
|
|
15873
|
+
{ name: "include-in-progress", in: "query", description: "Include rows with in-flight tracked actions.", schema: stringSchema },
|
|
15874
|
+
{ name: "winnability-class", in: "query", description: 'Filter by winnability: "ownable" or "ceded".', schema: stringSchema },
|
|
15875
|
+
{ name: "ownable", in: "query", description: 'Convenience alias for winnability-class=ownable when "true".', schema: stringSchema }
|
|
15538
15876
|
],
|
|
15539
15877
|
responses: {
|
|
15540
15878
|
200: jsonResponse("Targets returned.", "ContentTargetsResponseDto"),
|
|
15541
|
-
400: errorResponse("Invalid limit."),
|
|
15879
|
+
400: errorResponse("Invalid limit or winnability-class."),
|
|
15542
15880
|
404: errorResponse("Project not found.")
|
|
15543
15881
|
}
|
|
15544
15882
|
},
|
|
@@ -15645,6 +15983,65 @@ var routeCatalog = [
|
|
|
15645
15983
|
503: errorResponse("No AI provider configured for this project.")
|
|
15646
15984
|
}
|
|
15647
15985
|
},
|
|
15986
|
+
{
|
|
15987
|
+
method: "get",
|
|
15988
|
+
path: "/api/v1/projects/{name}/content/recommendations/{targetRef}/brief",
|
|
15989
|
+
summary: "Get cached structured content brief for a recommendation",
|
|
15990
|
+
description: "Returns the cached structured brief (`{targetQuery, winnabilityClass, angle, whyWinnable, schemaHookup, controllableSurfaceRationale}`) for one content recommendation at the current prompt version, or 404 if none exists. Cache-only read from the dedicated recommendation_briefs table \u2014 never collides with the prose explanation. Use `POST /brief` to synthesize one.",
|
|
15991
|
+
tags: ["content"],
|
|
15992
|
+
parameters: [
|
|
15993
|
+
nameParameter,
|
|
15994
|
+
{ name: "targetRef", in: "path", required: true, description: "Stable hash from ContentTargetRowDto.targetRef.", schema: stringSchema }
|
|
15995
|
+
],
|
|
15996
|
+
responses: {
|
|
15997
|
+
200: jsonResponse("Cached brief.", "RecommendationBriefDto"),
|
|
15998
|
+
404: errorResponse("No cached brief for this targetRef yet.")
|
|
15999
|
+
}
|
|
16000
|
+
},
|
|
16001
|
+
{
|
|
16002
|
+
method: "post",
|
|
16003
|
+
path: "/api/v1/projects/{name}/content/recommendations/{targetRef}/brief",
|
|
16004
|
+
summary: "Synthesize (or fetch cached) a structured content brief",
|
|
16005
|
+
description: "Synthesizes a STRUCTURED content brief for one recommendation, reusing the `analyze` capability tier. GATED to `ownable` targets \u2014 a `ceded` head term (cited surface dominated by aggregators/editorial) is rejected with 400 before any LLM call. Cached per (project, targetRef, promptVersion) in a dedicated table; repeat calls without `forceRefresh` return the cached row free. Pass `provider`/`model` to override.",
|
|
16006
|
+
tags: ["content"],
|
|
16007
|
+
parameters: [
|
|
16008
|
+
nameParameter,
|
|
16009
|
+
{ name: "targetRef", in: "path", required: true, description: "Stable hash from ContentTargetRowDto.targetRef.", schema: stringSchema }
|
|
16010
|
+
],
|
|
16011
|
+
requestBody: {
|
|
16012
|
+
required: false,
|
|
16013
|
+
content: {
|
|
16014
|
+
"application/json": {
|
|
16015
|
+
schema: {
|
|
16016
|
+
type: "object",
|
|
16017
|
+
properties: {
|
|
16018
|
+
provider: stringSchema,
|
|
16019
|
+
model: stringSchema,
|
|
16020
|
+
forceRefresh: { type: "boolean" }
|
|
16021
|
+
}
|
|
16022
|
+
}
|
|
16023
|
+
}
|
|
16024
|
+
}
|
|
16025
|
+
},
|
|
16026
|
+
responses: {
|
|
16027
|
+
200: jsonResponse("Brief synthesized or returned from cache.", "RecommendationBriefDto"),
|
|
16028
|
+
400: errorResponse("Invalid request body, unknown provider, or target is ceded (not winnable)."),
|
|
16029
|
+
404: errorResponse("Project not found or targetRef does not match any current recommendation."),
|
|
16030
|
+
503: errorResponse("No AI provider configured for this project.")
|
|
16031
|
+
}
|
|
16032
|
+
},
|
|
16033
|
+
{
|
|
16034
|
+
method: "get",
|
|
16035
|
+
path: "/api/v1/projects/{name}/content/domain-classifications",
|
|
16036
|
+
summary: "List per-domain cited-surface classifications",
|
|
16037
|
+
description: "Returns every cited-surface domain classification discovery has produced for the project (`{domain, competitorType, hits, updatedAt}`), ranked by recurrence. This is the read surface behind the winnabilityClass winnability gate; running discovery improves coverage.",
|
|
16038
|
+
tags: ["content"],
|
|
16039
|
+
parameters: [nameParameter],
|
|
16040
|
+
responses: {
|
|
16041
|
+
200: jsonResponse("Classifications returned.", "DomainClassificationsResponseDto"),
|
|
16042
|
+
404: errorResponse("Project not found.")
|
|
16043
|
+
}
|
|
16044
|
+
},
|
|
15648
16045
|
{
|
|
15649
16046
|
method: "get",
|
|
15650
16047
|
path: "/api/v1/projects/{name}/content/sources",
|
|
@@ -27326,10 +27723,11 @@ async function resolveRetainedStart(options, unservableStartMs, endMs) {
|
|
|
27326
27723
|
async function drainVercelTrafficEvents(options) {
|
|
27327
27724
|
const startMs = toMs(options.startDate);
|
|
27328
27725
|
const endMs = toMs(options.endDate);
|
|
27726
|
+
const now = options.now ?? (() => Date.now());
|
|
27329
27727
|
const events = [];
|
|
27330
27728
|
const seenEventIds = /* @__PURE__ */ new Set();
|
|
27331
27729
|
if (endMs <= startMs) {
|
|
27332
|
-
return { events, subWindowCount: 0, effectiveStartMs: startMs, retentionClamped: false, truncatedSliceCount: 0, truncatedSliceStartsMs: [] };
|
|
27730
|
+
return { events, subWindowCount: 0, effectiveStartMs: startMs, retentionClamped: false, truncatedSliceCount: 0, truncatedSliceStartsMs: [], drainedThroughMs: startMs, deadlineReached: false };
|
|
27333
27731
|
}
|
|
27334
27732
|
let cursorMs = startMs;
|
|
27335
27733
|
let spanMs = endMs - startMs;
|
|
@@ -27340,8 +27738,13 @@ async function drainVercelTrafficEvents(options) {
|
|
|
27340
27738
|
let floorSpanProbeCountdown = 0;
|
|
27341
27739
|
let floorPageBudgetCountdown = 0;
|
|
27342
27740
|
let truncatedSliceCount = 0;
|
|
27741
|
+
let deadlineReached = false;
|
|
27343
27742
|
const truncatedSliceStartsMs = [];
|
|
27344
27743
|
while (cursorMs < endMs) {
|
|
27744
|
+
if (options.deadlineMs !== void 0 && now() >= options.deadlineMs) {
|
|
27745
|
+
deadlineReached = true;
|
|
27746
|
+
break;
|
|
27747
|
+
}
|
|
27345
27748
|
if (subWindowCount >= options.maxSubWindows) {
|
|
27346
27749
|
throw new Error(
|
|
27347
27750
|
`Vercel window not drained within ${options.maxSubWindows} sub-windows \u2014 narrow the time range`
|
|
@@ -27428,7 +27831,7 @@ async function drainVercelTrafficEvents(options) {
|
|
|
27428
27831
|
}
|
|
27429
27832
|
}
|
|
27430
27833
|
}
|
|
27431
|
-
return { events, subWindowCount, effectiveStartMs, retentionClamped, truncatedSliceCount, truncatedSliceStartsMs };
|
|
27834
|
+
return { events, subWindowCount, effectiveStartMs, retentionClamped, truncatedSliceCount, truncatedSliceStartsMs, drainedThroughMs: cursorMs, deadlineReached };
|
|
27432
27835
|
}
|
|
27433
27836
|
|
|
27434
27837
|
// ../api-routes/src/traffic.ts
|
|
@@ -27440,6 +27843,8 @@ var DEFAULT_WP_PAGE_SIZE = 500;
|
|
|
27440
27843
|
var DEFAULT_WP_MAX_PAGES = 20;
|
|
27441
27844
|
var DEFAULT_VERCEL_MAX_PAGES = 50;
|
|
27442
27845
|
var VERCEL_MAX_SUB_WINDOWS = 5e3;
|
|
27846
|
+
var VERCEL_MAX_SYNC_WINDOW_MS = 24 * 60 * 6e4;
|
|
27847
|
+
var DEFAULT_VERCEL_SYNC_DEADLINE_MS = 4 * 6e4;
|
|
27443
27848
|
var VERCEL_BACKFILL_CHUNK_MS = 60 * 6e4;
|
|
27444
27849
|
var MAX_TRACKED_EVENT_IDS = 1e3;
|
|
27445
27850
|
var DEFAULT_BACKFILL_DAYS = 30;
|
|
@@ -27687,6 +28092,7 @@ async function trafficRoutes(app, opts) {
|
|
|
27687
28092
|
}
|
|
27688
28093
|
}
|
|
27689
28094
|
const vercelMaxPages = opts.defaultVercelMaxPages ?? DEFAULT_VERCEL_MAX_PAGES;
|
|
28095
|
+
const vercelSyncDeadlineMs = opts.vercelSyncDeadlineMs ?? DEFAULT_VERCEL_SYNC_DEADLINE_MS;
|
|
27690
28096
|
const syncWindowMinutes = opts.defaultSyncWindowMinutes ?? DEFAULT_SYNC_WINDOW_MINUTES;
|
|
27691
28097
|
const pageSize = opts.defaultPageSize ?? DEFAULT_PAGE_SIZE3;
|
|
27692
28098
|
const maxPages = opts.defaultMaxPages ?? DEFAULT_MAX_PAGES4;
|
|
@@ -28038,6 +28444,7 @@ async function trafficRoutes(app, opts) {
|
|
|
28038
28444
|
let allEvents;
|
|
28039
28445
|
let nextCursor;
|
|
28040
28446
|
let auditAction;
|
|
28447
|
+
let effectiveWindowEnd = windowEnd;
|
|
28041
28448
|
if (sourceRow.sourceType === TrafficSourceTypes["cloud-run"]) {
|
|
28042
28449
|
auditAction = "traffic.cloud-run.synced";
|
|
28043
28450
|
const credentialStore = opts.cloudRunCredentialStore;
|
|
@@ -28163,9 +28570,19 @@ async function trafficRoutes(app, opts) {
|
|
|
28163
28570
|
const windowMinutes = Number.isFinite(requestedMinutes) && requestedMinutes && requestedMinutes > 0 ? Math.floor(requestedMinutes) : syncWindowMinutes;
|
|
28164
28571
|
const requestedStartMs = windowEnd.getTime() - windowMinutes * 6e4;
|
|
28165
28572
|
const lastSyncedMs = sourceRow.lastSyncedAt ? new Date(sourceRow.lastSyncedAt).getTime() : Number.NEGATIVE_INFINITY;
|
|
28166
|
-
|
|
28167
|
-
|
|
28168
|
-
)
|
|
28573
|
+
const clampedStartMs = Math.min(windowEnd.getTime(), Math.max(requestedStartMs, lastSyncedMs));
|
|
28574
|
+
const cappedStartMs = Math.max(clampedStartMs, windowEnd.getTime() - VERCEL_MAX_SYNC_WINDOW_MS);
|
|
28575
|
+
if (cappedStartMs > clampedStartMs) {
|
|
28576
|
+
request.log.warn(
|
|
28577
|
+
{
|
|
28578
|
+
sourceId: sourceRow.id,
|
|
28579
|
+
requestedStart: new Date(clampedStartMs).toISOString(),
|
|
28580
|
+
cappedStart: new Date(cappedStartMs).toISOString()
|
|
28581
|
+
},
|
|
28582
|
+
"Vercel sync window exceeded the max single-sync span; clamped the start forward (older traffic skipped \u2014 run a backfill to recover it)"
|
|
28583
|
+
);
|
|
28584
|
+
}
|
|
28585
|
+
windowStart = new Date(cappedStartMs);
|
|
28169
28586
|
try {
|
|
28170
28587
|
const drained = await drainVercelTrafficEvents({
|
|
28171
28588
|
pull: pullVercelEvents,
|
|
@@ -28176,11 +28593,31 @@ async function trafficRoutes(app, opts) {
|
|
|
28176
28593
|
startDate: windowStart.getTime(),
|
|
28177
28594
|
endDate: windowEnd.getTime(),
|
|
28178
28595
|
pagesPerSubWindow: vercelMaxPages,
|
|
28179
|
-
maxSubWindows: VERCEL_MAX_SUB_WINDOWS
|
|
28596
|
+
maxSubWindows: VERCEL_MAX_SUB_WINDOWS,
|
|
28597
|
+
// Bound the drain's wall-clock so a dense/slow window can't run for
|
|
28598
|
+
// many minutes. On hit the drain stops and reports how far it got.
|
|
28599
|
+
deadlineMs: syncStartedAtMs + vercelSyncDeadlineMs
|
|
28180
28600
|
});
|
|
28181
28601
|
if (drained.retentionClamped) {
|
|
28182
28602
|
throw vercelRetentionClampError(windowStart.getTime(), drained.effectiveStartMs);
|
|
28183
28603
|
}
|
|
28604
|
+
if (drained.deadlineReached) {
|
|
28605
|
+
if (drained.drainedThroughMs <= windowStart.getTime()) {
|
|
28606
|
+
throw new Error(
|
|
28607
|
+
`sync exceeded its ${vercelSyncDeadlineMs}ms drain budget without completing any sub-window (request-logs slow or unavailable)`
|
|
28608
|
+
);
|
|
28609
|
+
}
|
|
28610
|
+
effectiveWindowEnd = new Date(drained.drainedThroughMs);
|
|
28611
|
+
request.log.warn(
|
|
28612
|
+
{
|
|
28613
|
+
sourceId: sourceRow.id,
|
|
28614
|
+
drainedThrough: effectiveWindowEnd.toISOString(),
|
|
28615
|
+
requestedEnd: windowEnd.toISOString(),
|
|
28616
|
+
subWindows: drained.subWindowCount
|
|
28617
|
+
},
|
|
28618
|
+
"Vercel drain hit its time budget; committing the partial window and advancing to it \u2014 next sync resumes from here"
|
|
28619
|
+
);
|
|
28620
|
+
}
|
|
28184
28621
|
if (drained.truncatedSliceCount > 0) {
|
|
28185
28622
|
request.log.warn(
|
|
28186
28623
|
{
|
|
@@ -28364,11 +28801,13 @@ async function trafficRoutes(app, opts) {
|
|
|
28364
28801
|
}
|
|
28365
28802
|
const sourceUpdate = {
|
|
28366
28803
|
status: TrafficSourceStatuses.connected,
|
|
28367
|
-
// Advance to
|
|
28368
|
-
// source between
|
|
28369
|
-
// range. If we stored finishedAt, the next sync's clamp would skip
|
|
28370
|
-
//
|
|
28371
|
-
|
|
28804
|
+
// Advance to effectiveWindowEnd, not finishedAt — events arriving at the
|
|
28805
|
+
// source between the window end and finishedAt aren't in this pull's
|
|
28806
|
+
// range. If we stored finishedAt, the next sync's clamp would skip past
|
|
28807
|
+
// them and they'd be lost. effectiveWindowEnd equals windowEnd on a full
|
|
28808
|
+
// sync; for a Vercel drain that stopped at its deadline it is the partial
|
|
28809
|
+
// boundary, so the next sync resumes exactly where this one left off.
|
|
28810
|
+
lastSyncedAt: effectiveWindowEnd.toISOString(),
|
|
28372
28811
|
lastError: null,
|
|
28373
28812
|
lastEventIds: nextEventIds,
|
|
28374
28813
|
updatedAt: finishedAt
|
|
@@ -28421,7 +28860,9 @@ async function trafficRoutes(app, opts) {
|
|
|
28421
28860
|
aiReferralBucketRows,
|
|
28422
28861
|
sampleRows,
|
|
28423
28862
|
windowStart: windowStart.toISOString(),
|
|
28424
|
-
|
|
28863
|
+
// The window actually synced: equals windowEnd on a full sync, or the
|
|
28864
|
+
// partial boundary when a Vercel drain stopped at its deadline.
|
|
28865
|
+
windowEnd: effectiveWindowEnd.toISOString()
|
|
28425
28866
|
};
|
|
28426
28867
|
return response;
|
|
28427
28868
|
});
|
|
@@ -29170,6 +29611,104 @@ var BING_AUTH_CHECKS = [
|
|
|
29170
29611
|
}
|
|
29171
29612
|
];
|
|
29172
29613
|
|
|
29614
|
+
// ../api-routes/src/doctor/checks/content.ts
|
|
29615
|
+
import { eq as eq26 } from "drizzle-orm";
|
|
29616
|
+
var WINNABILITY_COVERAGE_WARN_THRESHOLD = 0.8;
|
|
29617
|
+
var UNCLASSIFIED_DOMAIN_SAMPLE_LIMIT = 10;
|
|
29618
|
+
function skippedNoProject() {
|
|
29619
|
+
return {
|
|
29620
|
+
status: CheckStatuses.skipped,
|
|
29621
|
+
code: "content.winnability.no-project",
|
|
29622
|
+
summary: "Project context required for content winnability checks.",
|
|
29623
|
+
remediation: "Run `canonry doctor --project <name>` to scope this check to a project."
|
|
29624
|
+
};
|
|
29625
|
+
}
|
|
29626
|
+
function loadProject(ctx) {
|
|
29627
|
+
if (!ctx.project) return null;
|
|
29628
|
+
return ctx.db.select().from(projects).where(eq26(projects.id, ctx.project.id)).get() ?? null;
|
|
29629
|
+
}
|
|
29630
|
+
function percent(value) {
|
|
29631
|
+
return Math.round(value * 100);
|
|
29632
|
+
}
|
|
29633
|
+
var winnabilityCoverageCheck = {
|
|
29634
|
+
id: "content.winnability.coverage",
|
|
29635
|
+
category: CheckCategories.integrations,
|
|
29636
|
+
scope: CheckScopes.project,
|
|
29637
|
+
title: "Content winnability classification coverage",
|
|
29638
|
+
run: (ctx) => {
|
|
29639
|
+
if (!ctx.project) return skippedNoProject();
|
|
29640
|
+
const project = loadProject(ctx);
|
|
29641
|
+
if (!project) {
|
|
29642
|
+
return {
|
|
29643
|
+
status: CheckStatuses.fail,
|
|
29644
|
+
code: "content.winnability.project-missing",
|
|
29645
|
+
summary: "Project row disappeared before the content winnability check could run.",
|
|
29646
|
+
remediation: "Re-run `canonry doctor --project <name>`; if this persists, inspect the local database."
|
|
29647
|
+
};
|
|
29648
|
+
}
|
|
29649
|
+
const input = loadOrchestratorInput(ctx.db, project);
|
|
29650
|
+
const citationCounts = /* @__PURE__ */ new Map();
|
|
29651
|
+
for (const candidate of input.candidateQueries) {
|
|
29652
|
+
for (const cited of candidate.citedSurfaceDomains) {
|
|
29653
|
+
citationCounts.set(cited.domain, (citationCounts.get(cited.domain) ?? 0) + cited.citationCount);
|
|
29654
|
+
}
|
|
29655
|
+
}
|
|
29656
|
+
const citedDomains = [...citationCounts.keys()].sort();
|
|
29657
|
+
if (citedDomains.length === 0) {
|
|
29658
|
+
return {
|
|
29659
|
+
status: CheckStatuses.skipped,
|
|
29660
|
+
code: "content.winnability.no-cited-surface",
|
|
29661
|
+
summary: "No non-owned cited-surface domains in recent content evidence, so the winnability gate has nothing to classify yet.",
|
|
29662
|
+
remediation: `Run \`canonry run ${ctx.project.name}\` to capture fresh answer-engine citations before checking discovery coverage.`,
|
|
29663
|
+
details: {
|
|
29664
|
+
citedSurfaceDomainCount: 0,
|
|
29665
|
+
classifiedDomainCount: input.domainClasses.size
|
|
29666
|
+
}
|
|
29667
|
+
};
|
|
29668
|
+
}
|
|
29669
|
+
const coveredDomains = citedDomains.filter((domain) => input.domainClasses.has(domain));
|
|
29670
|
+
const unclassifiedDomains = citedDomains.filter((domain) => !input.domainClasses.has(domain));
|
|
29671
|
+
const coverage = coveredDomains.length / citedDomains.length;
|
|
29672
|
+
const details = {
|
|
29673
|
+
citedSurfaceDomainCount: citedDomains.length,
|
|
29674
|
+
classifiedDomainCount: input.domainClasses.size,
|
|
29675
|
+
coveredDomainCount: coveredDomains.length,
|
|
29676
|
+
coverage,
|
|
29677
|
+
threshold: WINNABILITY_COVERAGE_WARN_THRESHOLD,
|
|
29678
|
+
unclassifiedDomains: unclassifiedDomains.slice(0, UNCLASSIFIED_DOMAIN_SAMPLE_LIMIT)
|
|
29679
|
+
};
|
|
29680
|
+
if (coveredDomains.length === 0) {
|
|
29681
|
+
return {
|
|
29682
|
+
status: CheckStatuses.warn,
|
|
29683
|
+
code: "content.winnability.no-classifications",
|
|
29684
|
+
summary: `0 of ${citedDomains.length} cited-surface domain(s) have discovery classifications; the winnability gate is failing open.`,
|
|
29685
|
+
remediation: `Run \`canonry discover run ${ctx.project.name} --wait\` to classify cited domains before trusting ownable/ceded content targets.`,
|
|
29686
|
+
details
|
|
29687
|
+
};
|
|
29688
|
+
}
|
|
29689
|
+
if (coverage < WINNABILITY_COVERAGE_WARN_THRESHOLD) {
|
|
29690
|
+
return {
|
|
29691
|
+
status: CheckStatuses.warn,
|
|
29692
|
+
code: "content.winnability.low-coverage",
|
|
29693
|
+
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) classified (${percent(coverage)}%); the winnability gate may miss ceded surfaces.`,
|
|
29694
|
+
remediation: `Run \`canonry discover run ${ctx.project.name} --wait\` to raise classification coverage before relying on ownable/ceded content targets.`,
|
|
29695
|
+
details
|
|
29696
|
+
};
|
|
29697
|
+
}
|
|
29698
|
+
return {
|
|
29699
|
+
status: CheckStatuses.ok,
|
|
29700
|
+
code: "content.winnability.covered",
|
|
29701
|
+
summary: `${coveredDomains.length} of ${citedDomains.length} cited-surface domain(s) classified (${percent(coverage)}%); the winnability gate is active.`,
|
|
29702
|
+
remediation: null,
|
|
29703
|
+
details
|
|
29704
|
+
};
|
|
29705
|
+
}
|
|
29706
|
+
};
|
|
29707
|
+
var CONTENT_CHECKS = [winnabilityCoverageCheck];
|
|
29708
|
+
var CONTENT_CHECK_BY_ID = Object.fromEntries(
|
|
29709
|
+
CONTENT_CHECKS.map((check) => [check.id, check])
|
|
29710
|
+
);
|
|
29711
|
+
|
|
29173
29712
|
// ../api-routes/src/doctor/checks/ga-auth.ts
|
|
29174
29713
|
async function checkServiceAccount(conn) {
|
|
29175
29714
|
if (!conn.propertyId) {
|
|
@@ -29312,10 +29851,10 @@ var ga4ConnectionCheck = {
|
|
|
29312
29851
|
var GA_AUTH_CHECKS = [ga4ConnectionCheck];
|
|
29313
29852
|
|
|
29314
29853
|
// ../api-routes/src/doctor/checks/gbp-auth.ts
|
|
29315
|
-
import { and as and20, eq as
|
|
29854
|
+
import { and as and20, eq as eq27 } from "drizzle-orm";
|
|
29316
29855
|
var RECENT_SYNC_WARN_DAYS = 7;
|
|
29317
29856
|
var RECENT_SYNC_FAIL_DAYS = 30;
|
|
29318
|
-
function
|
|
29857
|
+
function skippedNoProject2() {
|
|
29319
29858
|
return {
|
|
29320
29859
|
status: CheckStatuses.skipped,
|
|
29321
29860
|
code: "gbp.auth.no-project",
|
|
@@ -29332,7 +29871,7 @@ function storeUnavailable() {
|
|
|
29332
29871
|
};
|
|
29333
29872
|
}
|
|
29334
29873
|
async function resolveGbpToken(ctx) {
|
|
29335
|
-
if (!ctx.project) return { ok: false, output:
|
|
29874
|
+
if (!ctx.project) return { ok: false, output: skippedNoProject2() };
|
|
29336
29875
|
const store = ctx.googleConnectionStore;
|
|
29337
29876
|
if (!store) return { ok: false, output: storeUnavailable() };
|
|
29338
29877
|
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
@@ -29410,7 +29949,7 @@ var scopesCheck = {
|
|
|
29410
29949
|
scope: CheckScopes.project,
|
|
29411
29950
|
title: "GBP granted scopes",
|
|
29412
29951
|
run: async (ctx) => {
|
|
29413
|
-
if (!ctx.project) return
|
|
29952
|
+
if (!ctx.project) return skippedNoProject2();
|
|
29414
29953
|
const store = ctx.googleConnectionStore;
|
|
29415
29954
|
if (!store) return storeUnavailable();
|
|
29416
29955
|
const conn = store.getConnection(ctx.project.canonicalDomain, "gbp");
|
|
@@ -29447,7 +29986,7 @@ var accountAccessCheck = {
|
|
|
29447
29986
|
scope: CheckScopes.project,
|
|
29448
29987
|
title: "GBP account access",
|
|
29449
29988
|
run: async (ctx) => {
|
|
29450
|
-
if (!ctx.project) return
|
|
29989
|
+
if (!ctx.project) return skippedNoProject2();
|
|
29451
29990
|
const store = ctx.googleConnectionStore;
|
|
29452
29991
|
if (!store) return storeUnavailable();
|
|
29453
29992
|
const conn = store.getConnection(ctx.project.canonicalDomain, "gbp");
|
|
@@ -29544,8 +30083,8 @@ var recentSyncCheck = {
|
|
|
29544
30083
|
scope: CheckScopes.project,
|
|
29545
30084
|
title: "GBP recent sync",
|
|
29546
30085
|
run: (ctx) => {
|
|
29547
|
-
if (!ctx.project) return
|
|
29548
|
-
const selected = ctx.db.select({ locationName: gbpLocations.locationName, syncedAt: gbpLocations.syncedAt }).from(gbpLocations).where(and20(
|
|
30086
|
+
if (!ctx.project) return skippedNoProject2();
|
|
30087
|
+
const selected = ctx.db.select({ locationName: gbpLocations.locationName, syncedAt: gbpLocations.syncedAt }).from(gbpLocations).where(and20(eq27(gbpLocations.projectId, ctx.project.id), eq27(gbpLocations.selected, true))).all();
|
|
29549
30088
|
if (selected.length === 0) {
|
|
29550
30089
|
return {
|
|
29551
30090
|
status: CheckStatuses.skipped,
|
|
@@ -29605,7 +30144,7 @@ var GBP_AUTH_CHECK_BY_ID = Object.fromEntries(
|
|
|
29605
30144
|
);
|
|
29606
30145
|
|
|
29607
30146
|
// ../api-routes/src/doctor/checks/places.ts
|
|
29608
|
-
import { eq as
|
|
30147
|
+
import { eq as eq28 } from "drizzle-orm";
|
|
29609
30148
|
var apiKeyCheck = {
|
|
29610
30149
|
id: "gbp.places.api-key",
|
|
29611
30150
|
category: CheckCategories.auth,
|
|
@@ -29650,7 +30189,7 @@ var apiKeyCheck = {
|
|
|
29650
30189
|
details: { tier: cfg.tier }
|
|
29651
30190
|
};
|
|
29652
30191
|
}
|
|
29653
|
-
const rows = ctx.db.select({ placeId: gbpLocations.placeId, selected: gbpLocations.selected }).from(gbpLocations).where(
|
|
30192
|
+
const rows = ctx.db.select({ placeId: gbpLocations.placeId, selected: gbpLocations.selected }).from(gbpLocations).where(eq28(gbpLocations.projectId, ctx.project.id)).all();
|
|
29654
30193
|
const selected = rows.filter((r) => r.selected);
|
|
29655
30194
|
const locationsWithPlaceId = selected.filter((r) => Boolean(r.placeId)).length;
|
|
29656
30195
|
const details = {
|
|
@@ -29686,7 +30225,7 @@ var PLACES_CHECK_BY_ID = Object.fromEntries(
|
|
|
29686
30225
|
var REQUIRED_GSC_SCOPES = [GSC_SCOPE, INDEXING_SCOPE];
|
|
29687
30226
|
async function resolveAccessToken(ctx) {
|
|
29688
30227
|
if (!ctx.project) {
|
|
29689
|
-
return { ok: false, output:
|
|
30228
|
+
return { ok: false, output: skippedNoProject3() };
|
|
29690
30229
|
}
|
|
29691
30230
|
const store = ctx.googleConnectionStore;
|
|
29692
30231
|
if (!store) {
|
|
@@ -29753,7 +30292,7 @@ async function resolveAccessToken(ctx) {
|
|
|
29753
30292
|
};
|
|
29754
30293
|
}
|
|
29755
30294
|
}
|
|
29756
|
-
function
|
|
30295
|
+
function skippedNoProject3() {
|
|
29757
30296
|
return {
|
|
29758
30297
|
status: CheckStatuses.skipped,
|
|
29759
30298
|
code: "google.auth.no-project",
|
|
@@ -29783,7 +30322,7 @@ var propertyAccessCheck = {
|
|
|
29783
30322
|
scope: CheckScopes.project,
|
|
29784
30323
|
title: "GSC property access",
|
|
29785
30324
|
run: async (ctx) => {
|
|
29786
|
-
if (!ctx.project) return
|
|
30325
|
+
if (!ctx.project) return skippedNoProject3();
|
|
29787
30326
|
const store = ctx.googleConnectionStore;
|
|
29788
30327
|
if (!store) {
|
|
29789
30328
|
return {
|
|
@@ -29884,7 +30423,7 @@ var redirectUriCheck = {
|
|
|
29884
30423
|
scope: CheckScopes.project,
|
|
29885
30424
|
title: "OAuth redirect URI",
|
|
29886
30425
|
run: async (ctx) => {
|
|
29887
|
-
if (!ctx.project) return
|
|
30426
|
+
if (!ctx.project) return skippedNoProject3();
|
|
29888
30427
|
const auth = ctx.getGoogleAuthConfig?.() ?? {};
|
|
29889
30428
|
if (!auth.clientId || !auth.clientSecret) {
|
|
29890
30429
|
return {
|
|
@@ -29938,7 +30477,7 @@ var scopesCheck2 = {
|
|
|
29938
30477
|
scope: CheckScopes.project,
|
|
29939
30478
|
title: "GSC granted scopes",
|
|
29940
30479
|
run: async (ctx) => {
|
|
29941
|
-
if (!ctx.project) return
|
|
30480
|
+
if (!ctx.project) return skippedNoProject3();
|
|
29942
30481
|
const store = ctx.googleConnectionStore;
|
|
29943
30482
|
if (!store) {
|
|
29944
30483
|
return {
|
|
@@ -30101,10 +30640,10 @@ var RUNTIME_STATE_CHECKS = [
|
|
|
30101
30640
|
];
|
|
30102
30641
|
|
|
30103
30642
|
// ../api-routes/src/doctor/checks/traffic-source.ts
|
|
30104
|
-
import { and as and21, eq as
|
|
30643
|
+
import { and as and21, eq as eq29, gte as gte4, ne as ne4, sql as sql12 } from "drizzle-orm";
|
|
30105
30644
|
var RECENT_DATA_WARN_DAYS = 7;
|
|
30106
30645
|
var RECENT_DATA_FAIL_DAYS = 30;
|
|
30107
|
-
function
|
|
30646
|
+
function skippedNoProject4() {
|
|
30108
30647
|
return {
|
|
30109
30648
|
status: CheckStatuses.skipped,
|
|
30110
30649
|
code: "traffic.no-project",
|
|
@@ -30116,7 +30655,7 @@ function loadProbes(ctx) {
|
|
|
30116
30655
|
if (!ctx.project) return [];
|
|
30117
30656
|
const rows = ctx.db.select().from(trafficSources).where(
|
|
30118
30657
|
and21(
|
|
30119
|
-
|
|
30658
|
+
eq29(trafficSources.projectId, ctx.project.id),
|
|
30120
30659
|
ne4(trafficSources.status, TrafficSourceStatuses.archived)
|
|
30121
30660
|
)
|
|
30122
30661
|
).all();
|
|
@@ -30138,7 +30677,7 @@ var sourceConnectedCheck = {
|
|
|
30138
30677
|
scope: CheckScopes.project,
|
|
30139
30678
|
title: "Traffic source connected",
|
|
30140
30679
|
run: (ctx) => {
|
|
30141
|
-
if (!ctx.project) return
|
|
30680
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30142
30681
|
const sources = loadProbes(ctx);
|
|
30143
30682
|
if (sources.length === 0) {
|
|
30144
30683
|
return {
|
|
@@ -30182,7 +30721,7 @@ var recentDataCheck = {
|
|
|
30182
30721
|
scope: CheckScopes.project,
|
|
30183
30722
|
title: "Traffic source recent data",
|
|
30184
30723
|
run: (ctx) => {
|
|
30185
|
-
if (!ctx.project) return
|
|
30724
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30186
30725
|
const sources = loadProbes(ctx);
|
|
30187
30726
|
if (sources.length === 0) {
|
|
30188
30727
|
return {
|
|
@@ -30197,7 +30736,7 @@ var recentDataCheck = {
|
|
|
30197
30736
|
const recentCrawlers = Number(
|
|
30198
30737
|
ctx.db.select({ total: sql12`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
|
|
30199
30738
|
and21(
|
|
30200
|
-
|
|
30739
|
+
eq29(crawlerEventsHourly.projectId, ctx.project.id),
|
|
30201
30740
|
gte4(crawlerEventsHourly.tsHour, warnCutoff)
|
|
30202
30741
|
)
|
|
30203
30742
|
).get()?.total ?? 0
|
|
@@ -30205,7 +30744,7 @@ var recentDataCheck = {
|
|
|
30205
30744
|
const recentReferrals = Number(
|
|
30206
30745
|
ctx.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
|
|
30207
30746
|
and21(
|
|
30208
|
-
|
|
30747
|
+
eq29(aiReferralEventsHourly.projectId, ctx.project.id),
|
|
30209
30748
|
gte4(aiReferralEventsHourly.tsHour, warnCutoff)
|
|
30210
30749
|
)
|
|
30211
30750
|
).get()?.total ?? 0
|
|
@@ -30221,7 +30760,7 @@ var recentDataCheck = {
|
|
|
30221
30760
|
const olderCrawlers = Number(
|
|
30222
30761
|
ctx.db.select({ total: sql12`COALESCE(SUM(${crawlerEventsHourly.hits}), 0)` }).from(crawlerEventsHourly).where(
|
|
30223
30762
|
and21(
|
|
30224
|
-
|
|
30763
|
+
eq29(crawlerEventsHourly.projectId, ctx.project.id),
|
|
30225
30764
|
gte4(crawlerEventsHourly.tsHour, failCutoff)
|
|
30226
30765
|
)
|
|
30227
30766
|
).get()?.total ?? 0
|
|
@@ -30229,7 +30768,7 @@ var recentDataCheck = {
|
|
|
30229
30768
|
const olderReferrals = Number(
|
|
30230
30769
|
ctx.db.select({ total: sql12`COALESCE(SUM(${aiReferralEventsHourly.sessionsOrHits}), 0)` }).from(aiReferralEventsHourly).where(
|
|
30231
30770
|
and21(
|
|
30232
|
-
|
|
30771
|
+
eq29(aiReferralEventsHourly.projectId, ctx.project.id),
|
|
30233
30772
|
gte4(aiReferralEventsHourly.tsHour, failCutoff)
|
|
30234
30773
|
)
|
|
30235
30774
|
).get()?.total ?? 0
|
|
@@ -30344,7 +30883,7 @@ var credentialsCheck = {
|
|
|
30344
30883
|
scope: CheckScopes.project,
|
|
30345
30884
|
title: "Traffic source credentials",
|
|
30346
30885
|
run: async (ctx) => {
|
|
30347
|
-
if (!ctx.project) return
|
|
30886
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30348
30887
|
const sources = loadProbes(ctx);
|
|
30349
30888
|
if (sources.length === 0) {
|
|
30350
30889
|
return {
|
|
@@ -30373,7 +30912,7 @@ var scopesCheck3 = {
|
|
|
30373
30912
|
scope: CheckScopes.project,
|
|
30374
30913
|
title: "Traffic source scopes",
|
|
30375
30914
|
run: async (ctx) => {
|
|
30376
|
-
if (!ctx.project) return
|
|
30915
|
+
if (!ctx.project) return skippedNoProject4();
|
|
30377
30916
|
const sources = loadProbes(ctx);
|
|
30378
30917
|
if (sources.length === 0) {
|
|
30379
30918
|
return {
|
|
@@ -30486,6 +31025,7 @@ var ALL_CHECKS = [
|
|
|
30486
31025
|
...GA_AUTH_CHECKS,
|
|
30487
31026
|
...PROVIDERS_CHECKS,
|
|
30488
31027
|
...TRAFFIC_SOURCE_CHECKS,
|
|
31028
|
+
...CONTENT_CHECKS,
|
|
30489
31029
|
...AGENT_CHECKS
|
|
30490
31030
|
];
|
|
30491
31031
|
|
|
@@ -30608,7 +31148,7 @@ async function doctorRoutes(app, opts) {
|
|
|
30608
31148
|
|
|
30609
31149
|
// ../api-routes/src/discovery/routes.ts
|
|
30610
31150
|
import crypto25 from "crypto";
|
|
30611
|
-
import { and as and22, desc as desc15, eq as
|
|
31151
|
+
import { and as and22, desc as desc15, eq as eq30, gte as gte5, inArray as inArray10 } from "drizzle-orm";
|
|
30612
31152
|
var MAX_INFLIGHT_DISCOVERY_AGE_MS = 2 * 60 * 60 * 1e3;
|
|
30613
31153
|
async function discoveryRoutes(app, opts) {
|
|
30614
31154
|
app.post("/projects/:name/discover/run", async (request, reply) => {
|
|
@@ -30641,8 +31181,8 @@ async function discoveryRoutes(app, opts) {
|
|
|
30641
31181
|
const ageFloorIso = new Date(Date.now() - MAX_INFLIGHT_DISCOVERY_AGE_MS).toISOString();
|
|
30642
31182
|
const decision = app.db.transaction((tx) => {
|
|
30643
31183
|
const existing = tx.select({ id: discoverySessions.id, runId: discoverySessions.runId }).from(discoverySessions).where(and22(
|
|
30644
|
-
|
|
30645
|
-
|
|
31184
|
+
eq30(discoverySessions.projectId, project.id),
|
|
31185
|
+
eq30(discoverySessions.icpDescription, icpDescription),
|
|
30646
31186
|
inArray10(discoverySessions.status, [
|
|
30647
31187
|
DiscoverySessionStatuses.queued,
|
|
30648
31188
|
DiscoverySessionStatuses.seeding,
|
|
@@ -30712,7 +31252,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30712
31252
|
const project = resolveProject(app.db, request.params.name);
|
|
30713
31253
|
const parsedLimit = parseInt(request.query.limit ?? "", 10);
|
|
30714
31254
|
const limit = Number.isNaN(parsedLimit) || parsedLimit <= 0 ? 50 : parsedLimit;
|
|
30715
|
-
const rows = app.db.select().from(discoverySessions).where(
|
|
31255
|
+
const rows = app.db.select().from(discoverySessions).where(eq30(discoverySessions.projectId, project.id)).orderBy(desc15(discoverySessions.createdAt)).limit(limit).all();
|
|
30716
31256
|
return reply.send(rows.map(serializeSession));
|
|
30717
31257
|
}
|
|
30718
31258
|
);
|
|
@@ -30720,11 +31260,11 @@ async function discoveryRoutes(app, opts) {
|
|
|
30720
31260
|
"/projects/:name/discover/sessions/:id",
|
|
30721
31261
|
async (request, reply) => {
|
|
30722
31262
|
const project = resolveProject(app.db, request.params.name);
|
|
30723
|
-
const session = app.db.select().from(discoverySessions).where(
|
|
31263
|
+
const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
|
|
30724
31264
|
if (!session || session.projectId !== project.id) {
|
|
30725
31265
|
throw notFound("Discovery session", request.params.id);
|
|
30726
31266
|
}
|
|
30727
|
-
const probeRows = app.db.select().from(discoveryProbes).where(
|
|
31267
|
+
const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
|
|
30728
31268
|
const detail = {
|
|
30729
31269
|
...serializeSession(session),
|
|
30730
31270
|
probes: probeRows.map(serializeProbe)
|
|
@@ -30736,12 +31276,12 @@ async function discoveryRoutes(app, opts) {
|
|
|
30736
31276
|
"/projects/:name/discover/sessions/:id/promote",
|
|
30737
31277
|
async (request, reply) => {
|
|
30738
31278
|
const project = resolveProject(app.db, request.params.name);
|
|
30739
|
-
const session = app.db.select().from(discoverySessions).where(
|
|
31279
|
+
const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
|
|
30740
31280
|
if (!session || session.projectId !== project.id) {
|
|
30741
31281
|
throw notFound("Discovery session", request.params.id);
|
|
30742
31282
|
}
|
|
30743
|
-
const probeRows = app.db.select().from(discoveryProbes).where(
|
|
30744
|
-
const existingCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
31283
|
+
const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
|
|
31284
|
+
const existingCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase());
|
|
30745
31285
|
const seenCompetitors = new Set(existingCompetitors);
|
|
30746
31286
|
const cited = /* @__PURE__ */ new Set();
|
|
30747
31287
|
const aspirational = /* @__PURE__ */ new Set();
|
|
@@ -30770,7 +31310,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30770
31310
|
);
|
|
30771
31311
|
app.post("/projects/:name/discover/sessions/:id/promote", async (request, reply) => {
|
|
30772
31312
|
const project = resolveProject(app.db, request.params.name);
|
|
30773
|
-
const session = app.db.select().from(discoverySessions).where(
|
|
31313
|
+
const session = app.db.select().from(discoverySessions).where(eq30(discoverySessions.id, request.params.id)).get();
|
|
30774
31314
|
if (!session || session.projectId !== project.id) {
|
|
30775
31315
|
throw notFound("Discovery session", request.params.id);
|
|
30776
31316
|
}
|
|
@@ -30793,7 +31333,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30793
31333
|
const bucketSet = new Set(buckets);
|
|
30794
31334
|
const includeCompetitors = parsed.data.includeCompetitors ?? true;
|
|
30795
31335
|
const competitorTypes = parsed.data.competitorTypes ?? DEFAULT_DISCOVERY_PROMOTE_COMPETITOR_TYPES;
|
|
30796
|
-
const probeRows = app.db.select().from(discoveryProbes).where(
|
|
31336
|
+
const probeRows = app.db.select().from(discoveryProbes).where(eq30(discoveryProbes.sessionId, session.id)).all();
|
|
30797
31337
|
const candidateQueries = /* @__PURE__ */ new Set();
|
|
30798
31338
|
for (const probe of probeRows) {
|
|
30799
31339
|
if (!probe.bucket) continue;
|
|
@@ -30801,7 +31341,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30801
31341
|
if (bucket.success && bucketSet.has(bucket.data)) candidateQueries.add(probe.query);
|
|
30802
31342
|
}
|
|
30803
31343
|
const existingQueries = new Set(
|
|
30804
|
-
app.db.select({ query: queries.query }).from(queries).where(
|
|
31344
|
+
app.db.select({ query: queries.query }).from(queries).where(eq30(queries.projectId, project.id)).all().map((r) => r.query.toLowerCase())
|
|
30805
31345
|
);
|
|
30806
31346
|
const promotedQueries = [];
|
|
30807
31347
|
const skippedQueries = [];
|
|
@@ -30817,7 +31357,7 @@ async function discoveryRoutes(app, opts) {
|
|
|
30817
31357
|
const skippedCompetitors = [];
|
|
30818
31358
|
if (includeCompetitors) {
|
|
30819
31359
|
const existingCompetitors = new Set(
|
|
30820
|
-
app.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
31360
|
+
app.db.select({ domain: competitors.domain }).from(competitors).where(eq30(competitors.projectId, project.id)).all().map((r) => r.domain.toLowerCase())
|
|
30821
31361
|
);
|
|
30822
31362
|
const competitorMap = parseCompetitorMap(session.competitorMap);
|
|
30823
31363
|
for (const entry of selectEligibleCompetitors(competitorMap, competitorTypes)) {
|
|
@@ -30920,7 +31460,7 @@ function selectEligibleCompetitors(competitorMap, competitorTypes) {
|
|
|
30920
31460
|
|
|
30921
31461
|
// ../api-routes/src/discovery/orchestrate.ts
|
|
30922
31462
|
import crypto26 from "crypto";
|
|
30923
|
-
import { eq as
|
|
31463
|
+
import { eq as eq31 } from "drizzle-orm";
|
|
30924
31464
|
var DEFAULT_DEDUP_THRESHOLD = 0.85;
|
|
30925
31465
|
var DEFAULT_MAX_PROBES = 100;
|
|
30926
31466
|
var ABSOLUTE_MAX_PROBES = 500;
|
|
@@ -30975,7 +31515,7 @@ async function executeDiscovery(opts) {
|
|
|
30975
31515
|
status: DiscoverySessionStatuses.seeding,
|
|
30976
31516
|
dedupThreshold,
|
|
30977
31517
|
startedAt
|
|
30978
|
-
}).where(
|
|
31518
|
+
}).where(eq31(discoverySessions.id, opts.sessionId)).run();
|
|
30979
31519
|
const seedResult = await opts.deps.seed({
|
|
30980
31520
|
project: opts.project,
|
|
30981
31521
|
icpDescription: opts.icpDescription,
|
|
@@ -30995,7 +31535,7 @@ async function executeDiscovery(opts) {
|
|
|
30995
31535
|
seedProvider: seedResult.provider,
|
|
30996
31536
|
seedCountRaw,
|
|
30997
31537
|
seedCount
|
|
30998
|
-
}).where(
|
|
31538
|
+
}).where(eq31(discoverySessions.id, opts.sessionId)).run();
|
|
30999
31539
|
const probeRows = [];
|
|
31000
31540
|
const buckets = { cited: 0, aspirational: 0, "wasted-surface": 0 };
|
|
31001
31541
|
for (const query of probedCanonicals) {
|
|
@@ -31035,7 +31575,8 @@ async function executeDiscovery(opts) {
|
|
|
31035
31575
|
wastedCount: buckets["wasted-surface"],
|
|
31036
31576
|
competitorMap,
|
|
31037
31577
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
31038
|
-
}).where(
|
|
31578
|
+
}).where(eq31(discoverySessions.id, opts.sessionId)).run();
|
|
31579
|
+
upsertDomainClassifications(opts.db, opts.project.id, opts.sessionId, competitorMap);
|
|
31039
31580
|
return {
|
|
31040
31581
|
buckets,
|
|
31041
31582
|
competitorMap,
|
|
@@ -31044,12 +31585,37 @@ async function executeDiscovery(opts) {
|
|
|
31044
31585
|
seedProvider: seedResult.provider
|
|
31045
31586
|
};
|
|
31046
31587
|
}
|
|
31588
|
+
function upsertDomainClassifications(db, projectId, sessionId, competitorMap) {
|
|
31589
|
+
if (competitorMap.length === 0) return;
|
|
31590
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
31591
|
+
for (const entry of competitorMap) {
|
|
31592
|
+
const domain = normalizeDomain(entry.domain);
|
|
31593
|
+
if (!domain) continue;
|
|
31594
|
+
db.insert(domainClassifications).values({
|
|
31595
|
+
id: crypto26.randomUUID(),
|
|
31596
|
+
projectId,
|
|
31597
|
+
domain,
|
|
31598
|
+
competitorType: entry.competitorType,
|
|
31599
|
+
hits: entry.hits,
|
|
31600
|
+
sessionId,
|
|
31601
|
+
updatedAt: now
|
|
31602
|
+
}).onConflictDoUpdate({
|
|
31603
|
+
target: [domainClassifications.projectId, domainClassifications.domain],
|
|
31604
|
+
set: {
|
|
31605
|
+
competitorType: entry.competitorType,
|
|
31606
|
+
hits: entry.hits,
|
|
31607
|
+
sessionId,
|
|
31608
|
+
updatedAt: now
|
|
31609
|
+
}
|
|
31610
|
+
}).run();
|
|
31611
|
+
}
|
|
31612
|
+
}
|
|
31047
31613
|
function markSessionFailed(db, sessionId, error) {
|
|
31048
31614
|
db.update(discoverySessions).set({
|
|
31049
31615
|
status: DiscoverySessionStatuses.failed,
|
|
31050
31616
|
error,
|
|
31051
31617
|
finishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
31052
|
-
}).where(
|
|
31618
|
+
}).where(eq31(discoverySessions.id, sessionId)).run();
|
|
31053
31619
|
}
|
|
31054
31620
|
function dedupeStrings(input) {
|
|
31055
31621
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -31146,7 +31712,11 @@ async function apiRoutes(app, opts) {
|
|
|
31146
31712
|
await api.register(reportRoutes);
|
|
31147
31713
|
await api.register(citationRoutes);
|
|
31148
31714
|
await api.register(compositeRoutes);
|
|
31149
|
-
await api.register(contentRoutes, {
|
|
31715
|
+
await api.register(contentRoutes, {
|
|
31716
|
+
explainContentRecommendation: opts.explainContentRecommendation,
|
|
31717
|
+
briefContentRecommendation: opts.briefContentRecommendation,
|
|
31718
|
+
briefPromptVersion: opts.briefPromptVersion
|
|
31719
|
+
});
|
|
31150
31720
|
await api.register(settingsRoutes, {
|
|
31151
31721
|
providerSummary: opts.providerSummary,
|
|
31152
31722
|
providerAdapters: opts.providerAdapters,
|
|
@@ -31208,6 +31778,7 @@ async function apiRoutes(app, opts) {
|
|
|
31208
31778
|
pullWordpressTrafficEvents: opts.pullWordpressTrafficEvents,
|
|
31209
31779
|
vercelTrafficCredentialStore: opts.vercelTrafficCredentialStore,
|
|
31210
31780
|
pullVercelTrafficEvents: opts.pullVercelTrafficEvents,
|
|
31781
|
+
vercelSyncDeadlineMs: opts.vercelSyncDeadlineMs,
|
|
31211
31782
|
onTrafficSynced: opts.onTrafficSynced,
|
|
31212
31783
|
onScheduleUpdated: opts.onScheduleUpdated,
|
|
31213
31784
|
allowLoopbackWebhooks: opts.allowLoopbackWebhooks
|
|
@@ -31611,8 +32182,8 @@ var IntelligenceService = class {
|
|
|
31611
32182
|
analyzeAndPersist(runId, projectId) {
|
|
31612
32183
|
const recentRuns = this.db.select().from(runs).where(
|
|
31613
32184
|
and23(
|
|
31614
|
-
|
|
31615
|
-
or5(
|
|
32185
|
+
eq32(runs.projectId, projectId),
|
|
32186
|
+
or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
|
|
31616
32187
|
// Defensive: RunCoordinator already skips probes before this is
|
|
31617
32188
|
// called, but if a future call site invokes analyzeAndPersist
|
|
31618
32189
|
// directly for a probe, probes still must not pollute the
|
|
@@ -31694,7 +32265,7 @@ var IntelligenceService = class {
|
|
|
31694
32265
|
* Returns the persisted insights so the coordinator can count critical/high.
|
|
31695
32266
|
*/
|
|
31696
32267
|
analyzeAndPersistGbp(runId, projectId) {
|
|
31697
|
-
const runRow = this.db.select({ createdAt: runs.createdAt, startedAt: runs.startedAt, finishedAt: runs.finishedAt }).from(runs).where(
|
|
32268
|
+
const runRow = this.db.select({ createdAt: runs.createdAt, startedAt: runs.startedAt, finishedAt: runs.finishedAt }).from(runs).where(eq32(runs.id, runId)).get();
|
|
31698
32269
|
if (!runRow) {
|
|
31699
32270
|
log.info("gbp-intelligence.skip", { runId, reason: "run not found" });
|
|
31700
32271
|
this.persistGbpInsights(runId, projectId, [], []);
|
|
@@ -31703,8 +32274,8 @@ var IntelligenceService = class {
|
|
|
31703
32274
|
const windowStart = runRow.startedAt ?? runRow.createdAt;
|
|
31704
32275
|
const windowEnd = runRow.finishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
31705
32276
|
const selected = this.db.select().from(gbpLocations).where(and23(
|
|
31706
|
-
|
|
31707
|
-
|
|
32277
|
+
eq32(gbpLocations.projectId, projectId),
|
|
32278
|
+
eq32(gbpLocations.selected, true),
|
|
31708
32279
|
gte6(gbpLocations.syncedAt, windowStart),
|
|
31709
32280
|
lte3(gbpLocations.syncedAt, windowEnd)
|
|
31710
32281
|
)).all();
|
|
@@ -31739,10 +32310,10 @@ var IntelligenceService = class {
|
|
|
31739
32310
|
}
|
|
31740
32311
|
/** Build the per-location signal bundle the GBP analyzer consumes. */
|
|
31741
32312
|
buildGbpLocationSignals(projectId, locationName, displayName, fallbackDate) {
|
|
31742
|
-
const metricRows = this.db.select({ metric: gbpDailyMetrics.metric, date: gbpDailyMetrics.date, value: gbpDailyMetrics.value }).from(gbpDailyMetrics).where(and23(
|
|
31743
|
-
const placeActionRows = this.db.select({ placeActionType: gbpPlaceActions.placeActionType, providerType: gbpPlaceActions.providerType }).from(gbpPlaceActions).where(and23(
|
|
31744
|
-
const lodgingRow = this.db.select({ populatedGroupCount: gbpLodgingSnapshots.populatedGroupCount }).from(gbpLodgingSnapshots).where(and23(
|
|
31745
|
-
const placeRow = this.db.select({ attributes: gbpPlaceDetails.attributes }).from(gbpPlaceDetails).where(and23(
|
|
32313
|
+
const metricRows = this.db.select({ metric: gbpDailyMetrics.metric, date: gbpDailyMetrics.date, value: gbpDailyMetrics.value }).from(gbpDailyMetrics).where(and23(eq32(gbpDailyMetrics.projectId, projectId), eq32(gbpDailyMetrics.locationName, locationName))).all();
|
|
32314
|
+
const placeActionRows = this.db.select({ placeActionType: gbpPlaceActions.placeActionType, providerType: gbpPlaceActions.providerType }).from(gbpPlaceActions).where(and23(eq32(gbpPlaceActions.projectId, projectId), eq32(gbpPlaceActions.locationName, locationName))).all();
|
|
32315
|
+
const lodgingRow = this.db.select({ populatedGroupCount: gbpLodgingSnapshots.populatedGroupCount }).from(gbpLodgingSnapshots).where(and23(eq32(gbpLodgingSnapshots.projectId, projectId), eq32(gbpLodgingSnapshots.locationName, locationName))).orderBy(desc16(gbpLodgingSnapshots.syncedAt)).limit(1).get();
|
|
32316
|
+
const placeRow = this.db.select({ attributes: gbpPlaceDetails.attributes }).from(gbpPlaceDetails).where(and23(eq32(gbpPlaceDetails.projectId, projectId), eq32(gbpPlaceDetails.locationName, locationName))).orderBy(desc16(gbpPlaceDetails.syncedAt)).limit(1).get();
|
|
31746
32317
|
const placesAmenities = placeRow ? extractPlaceAmenities(placeRow.attributes) : [];
|
|
31747
32318
|
const summary = buildGbpSummary({
|
|
31748
32319
|
locationName,
|
|
@@ -31774,7 +32345,7 @@ var IntelligenceService = class {
|
|
|
31774
32345
|
/** Build the month-over-month keyword series for a location from the
|
|
31775
32346
|
* accumulating gbp_keyword_monthly table (latest complete month vs prior). */
|
|
31776
32347
|
buildGbpKeywordTrend(projectId, locationName) {
|
|
31777
|
-
const rows = this.db.select({ month: gbpKeywordMonthly.month, keyword: gbpKeywordMonthly.keyword, valueCount: gbpKeywordMonthly.valueCount }).from(gbpKeywordMonthly).where(and23(
|
|
32348
|
+
const rows = this.db.select({ month: gbpKeywordMonthly.month, keyword: gbpKeywordMonthly.keyword, valueCount: gbpKeywordMonthly.valueCount }).from(gbpKeywordMonthly).where(and23(eq32(gbpKeywordMonthly.projectId, projectId), eq32(gbpKeywordMonthly.locationName, locationName))).all();
|
|
31778
32349
|
if (rows.length === 0) return { recentMonth: null, priorMonth: null, points: [] };
|
|
31779
32350
|
const months = [...new Set(rows.map((r) => r.month))].sort().reverse();
|
|
31780
32351
|
const recentMonth = months[0] ?? null;
|
|
@@ -31805,7 +32376,7 @@ var IntelligenceService = class {
|
|
|
31805
32376
|
*/
|
|
31806
32377
|
persistGbpInsights(runId, projectId, gbpInsights, coveredLocationNames) {
|
|
31807
32378
|
const covered = new Set(coveredLocationNames);
|
|
31808
|
-
const existing = this.db.select({ id: insights.id, dismissed: insights.dismissed }).from(insights).where(and23(
|
|
32379
|
+
const existing = this.db.select({ id: insights.id, dismissed: insights.dismissed }).from(insights).where(and23(eq32(insights.projectId, projectId), eq32(insights.provider, GBP_INSIGHT_PROVIDER))).all();
|
|
31809
32380
|
const staleIds = [];
|
|
31810
32381
|
const dismissedSlots = /* @__PURE__ */ new Set();
|
|
31811
32382
|
for (const row of existing) {
|
|
@@ -31816,7 +32387,7 @@ var IntelligenceService = class {
|
|
|
31816
32387
|
}
|
|
31817
32388
|
this.db.transaction((tx) => {
|
|
31818
32389
|
for (const id of staleIds) {
|
|
31819
|
-
tx.delete(insights).where(
|
|
32390
|
+
tx.delete(insights).where(eq32(insights.id, id)).run();
|
|
31820
32391
|
}
|
|
31821
32392
|
for (const insight of gbpInsights) {
|
|
31822
32393
|
const parsed = parseGbpInsightId(insight.id);
|
|
@@ -31894,7 +32465,7 @@ var IntelligenceService = class {
|
|
|
31894
32465
|
* create per run + aggregate). DB is left untouched.
|
|
31895
32466
|
*/
|
|
31896
32467
|
backfill(projectName, opts, onProgress) {
|
|
31897
|
-
const project = this.db.select().from(projects).where(
|
|
32468
|
+
const project = this.db.select().from(projects).where(eq32(projects.name, projectName)).get();
|
|
31898
32469
|
if (!project) {
|
|
31899
32470
|
throw new Error(`Project "${projectName}" not found`);
|
|
31900
32471
|
}
|
|
@@ -31908,8 +32479,8 @@ var IntelligenceService = class {
|
|
|
31908
32479
|
}
|
|
31909
32480
|
const allRuns = this.db.select().from(runs).where(
|
|
31910
32481
|
and23(
|
|
31911
|
-
|
|
31912
|
-
or5(
|
|
32482
|
+
eq32(runs.projectId, project.id),
|
|
32483
|
+
or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
|
|
31913
32484
|
// Backfill must not replay probe runs as if they were real sweeps.
|
|
31914
32485
|
ne5(runs.trigger, RunTriggers.probe)
|
|
31915
32486
|
)
|
|
@@ -31988,7 +32559,7 @@ var IntelligenceService = class {
|
|
|
31988
32559
|
return { processed, skipped, totalInsights };
|
|
31989
32560
|
}
|
|
31990
32561
|
loadTrackedCompetitors(projectId) {
|
|
31991
|
-
return this.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
32562
|
+
return this.db.select({ domain: competitors.domain }).from(competitors).where(eq32(competitors.projectId, projectId)).all().map((r) => r.domain);
|
|
31992
32563
|
}
|
|
31993
32564
|
/**
|
|
31994
32565
|
* Wipe transition signals from an analysis result while keeping health.
|
|
@@ -32009,15 +32580,15 @@ var IntelligenceService = class {
|
|
|
32009
32580
|
}
|
|
32010
32581
|
persistResult(result, runId, projectId) {
|
|
32011
32582
|
const previouslyDismissed = /* @__PURE__ */ new Set();
|
|
32012
|
-
const existingInsights = this.db.select({ query: insights.query, provider: insights.provider, type: insights.type, dismissed: insights.dismissed }).from(insights).where(
|
|
32583
|
+
const existingInsights = this.db.select({ query: insights.query, provider: insights.provider, type: insights.type, dismissed: insights.dismissed }).from(insights).where(eq32(insights.runId, runId)).all();
|
|
32013
32584
|
for (const row of existingInsights) {
|
|
32014
32585
|
if (row.dismissed) {
|
|
32015
32586
|
previouslyDismissed.add(`${row.query}:${row.provider}:${row.type}`);
|
|
32016
32587
|
}
|
|
32017
32588
|
}
|
|
32018
32589
|
this.db.transaction((tx) => {
|
|
32019
|
-
tx.delete(insights).where(
|
|
32020
|
-
tx.delete(healthSnapshots).where(
|
|
32590
|
+
tx.delete(insights).where(eq32(insights.runId, runId)).run();
|
|
32591
|
+
tx.delete(healthSnapshots).where(eq32(healthSnapshots.runId, runId)).run();
|
|
32021
32592
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
32022
32593
|
for (const insight of result.insights) {
|
|
32023
32594
|
const wasDismissed = previouslyDismissed.has(`${insight.query}:${insight.provider}:${insight.type}`);
|
|
@@ -32068,14 +32639,14 @@ var IntelligenceService = class {
|
|
|
32068
32639
|
applySeverityTiering(rawInsights, excludeRunId, projectId) {
|
|
32069
32640
|
const regressions = rawInsights.filter((i) => i.type === "regression");
|
|
32070
32641
|
if (regressions.length === 0) return rawInsights;
|
|
32071
|
-
const gscRows = this.db.select({ query: gscSearchData.query, impressions: gscSearchData.impressions }).from(gscSearchData).where(
|
|
32642
|
+
const gscRows = this.db.select({ query: gscSearchData.query, impressions: gscSearchData.impressions }).from(gscSearchData).where(eq32(gscSearchData.projectId, projectId)).all();
|
|
32072
32643
|
const gscConnected = gscRows.length > 0;
|
|
32073
32644
|
const gscImpressionsByQuery = /* @__PURE__ */ new Map();
|
|
32074
32645
|
for (const row of gscRows) {
|
|
32075
32646
|
const key = row.query.toLowerCase();
|
|
32076
32647
|
gscImpressionsByQuery.set(key, (gscImpressionsByQuery.get(key) ?? 0) + row.impressions);
|
|
32077
32648
|
}
|
|
32078
|
-
const projectRow = this.db.select({ locations: projects.locations }).from(projects).where(
|
|
32649
|
+
const projectRow = this.db.select({ locations: projects.locations }).from(projects).where(eq32(projects.id, projectId)).get();
|
|
32079
32650
|
const locationCount = Math.max(
|
|
32080
32651
|
1,
|
|
32081
32652
|
(projectRow?.locations ?? []).length
|
|
@@ -32083,9 +32654,9 @@ var IntelligenceService = class {
|
|
|
32083
32654
|
const ROWS_PER_GROUP_BUDGET = Math.max(2, locationCount);
|
|
32084
32655
|
const recentRunRows = this.db.select({ id: runs.id, createdAt: runs.createdAt }).from(runs).where(
|
|
32085
32656
|
and23(
|
|
32086
|
-
|
|
32087
|
-
|
|
32088
|
-
or5(
|
|
32657
|
+
eq32(runs.projectId, projectId),
|
|
32658
|
+
eq32(runs.kind, RunKinds["answer-visibility"]),
|
|
32659
|
+
or5(eq32(runs.status, "completed"), eq32(runs.status, "partial")),
|
|
32089
32660
|
// Defensive — see top of file.
|
|
32090
32661
|
ne5(runs.trigger, RunTriggers.probe)
|
|
32091
32662
|
)
|
|
@@ -32105,7 +32676,7 @@ var IntelligenceService = class {
|
|
|
32105
32676
|
const haveHistory = recentRunIds.length > 0;
|
|
32106
32677
|
const priorRegressionsByPair = /* @__PURE__ */ new Map();
|
|
32107
32678
|
if (haveHistory) {
|
|
32108
|
-
const priorRows = this.db.select({ query: insights.query, provider: insights.provider, runId: insights.runId }).from(insights).where(and23(
|
|
32679
|
+
const priorRows = this.db.select({ query: insights.query, provider: insights.provider, runId: insights.runId }).from(insights).where(and23(eq32(insights.type, "regression"), inArray11(insights.runId, recentRunIds))).all();
|
|
32109
32680
|
const regressionGroups = /* @__PURE__ */ new Map();
|
|
32110
32681
|
for (const row of priorRows) {
|
|
32111
32682
|
if (!row.runId) continue;
|
|
@@ -32134,7 +32705,7 @@ var IntelligenceService = class {
|
|
|
32134
32705
|
});
|
|
32135
32706
|
}
|
|
32136
32707
|
buildRunData(runId, projectId, completedAt, location = null) {
|
|
32137
|
-
const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(
|
|
32708
|
+
const projectDomainRow = this.db.select({ canonicalDomain: projects.canonicalDomain, ownedDomains: projects.ownedDomains }).from(projects).where(eq32(projects.id, projectId)).get();
|
|
32138
32709
|
const projectDomains = projectDomainRow ? effectiveDomains({
|
|
32139
32710
|
canonicalDomain: projectDomainRow.canonicalDomain,
|
|
32140
32711
|
ownedDomains: projectDomainRow.ownedDomains
|
|
@@ -32150,7 +32721,7 @@ var IntelligenceService = class {
|
|
|
32150
32721
|
citedDomains: querySnapshots.citedDomains,
|
|
32151
32722
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
32152
32723
|
snapshotLocation: querySnapshots.location
|
|
32153
|
-
}).from(querySnapshots).leftJoin(queries,
|
|
32724
|
+
}).from(querySnapshots).leftJoin(queries, eq32(querySnapshots.queryId, queries.id)).where(eq32(querySnapshots.runId, runId)).all();
|
|
32154
32725
|
const snapshots = [];
|
|
32155
32726
|
let orphanCount = 0;
|
|
32156
32727
|
for (const r of rows) {
|