@ainyc/canonry 3.3.9 → 3.4.5
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/dist/{chunk-24C7RMIS.js → chunk-5OYYYY4I.js} +76 -1967
- package/dist/chunk-D4YFX3X4.js +1918 -0
- package/dist/{chunk-P6D3O5JB.js → chunk-P3PS3ZSN.js} +571 -492
- package/dist/{chunk-ZCPZOVVE.js → chunk-ZOJLW6WR.js} +167 -6
- package/dist/cli.js +93 -33
- package/dist/index.js +4 -4
- package/dist/{intelligence-service-FNJTFSI3.js → intelligence-service-GV6CAJ3Q.js} +2 -2
- package/dist/mcp.js +2 -2
- package/package.json +7 -7
- package/dist/chunk-MLKGABMK.js +0 -9
|
@@ -1,8 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApiClient,
|
|
3
|
+
canonryMcpTools,
|
|
4
|
+
configExists,
|
|
5
|
+
loadConfig,
|
|
6
|
+
saveConfigPatch
|
|
7
|
+
} from "./chunk-5OYYYY4I.js";
|
|
8
|
+
import {
|
|
9
|
+
IntelligenceService,
|
|
10
|
+
MIN_TREND_POINTS,
|
|
11
|
+
agentMemory,
|
|
12
|
+
agentSessions,
|
|
13
|
+
apiKeys,
|
|
14
|
+
auditLog,
|
|
15
|
+
backlinkDomains,
|
|
16
|
+
backlinkSummaries,
|
|
17
|
+
bingCoverageSnapshots,
|
|
18
|
+
bingUrlInspections,
|
|
19
|
+
buildBrandTokens,
|
|
20
|
+
buildContentGapRows,
|
|
21
|
+
buildContentSourceRows,
|
|
22
|
+
buildContentTargetRows,
|
|
23
|
+
buildInventory,
|
|
24
|
+
categorizeQueryByIntent,
|
|
25
|
+
ccReleaseSyncs,
|
|
26
|
+
competitors,
|
|
27
|
+
createLogger,
|
|
28
|
+
dropLegacyCredentialColumns,
|
|
29
|
+
extractLegacyCredentials,
|
|
30
|
+
gaAiReferrals,
|
|
31
|
+
gaSocialReferrals,
|
|
32
|
+
gaTrafficSnapshots,
|
|
33
|
+
gaTrafficSummaries,
|
|
34
|
+
groupInsights,
|
|
35
|
+
gscCoverageSnapshots,
|
|
36
|
+
gscSearchData,
|
|
37
|
+
gscUrlInspections,
|
|
38
|
+
healthSnapshots,
|
|
39
|
+
insights,
|
|
40
|
+
isBlogShapedQuery,
|
|
41
|
+
isTrendBaseline,
|
|
42
|
+
keywords,
|
|
43
|
+
mapOpportunitiesToNextSteps,
|
|
44
|
+
notifications,
|
|
45
|
+
parseJsonColumn,
|
|
46
|
+
projects,
|
|
47
|
+
querySnapshots,
|
|
48
|
+
runs,
|
|
49
|
+
schedules,
|
|
50
|
+
usageCounters
|
|
51
|
+
} from "./chunk-ZOJLW6WR.js";
|
|
1
52
|
import {
|
|
2
53
|
AGENT_MEMORY_VALUE_MAX_BYTES,
|
|
3
54
|
AGENT_PROVIDER_IDS,
|
|
4
55
|
AgentProviderIds,
|
|
5
|
-
ApiClient,
|
|
6
56
|
AppError,
|
|
7
57
|
CcReleaseSyncStatuses,
|
|
8
58
|
CheckCategories,
|
|
@@ -21,12 +71,10 @@ import {
|
|
|
21
71
|
brandKeyFromText,
|
|
22
72
|
brandLabelFromDomain,
|
|
23
73
|
buildRunErrorFromMessages,
|
|
24
|
-
canonryMcpTools,
|
|
25
74
|
categorizeSource,
|
|
26
75
|
categoryLabel,
|
|
27
76
|
citationStateToCited,
|
|
28
77
|
competitorBatchRequestSchema,
|
|
29
|
-
configExists,
|
|
30
78
|
deliveryFailed,
|
|
31
79
|
determineAnswerMentioned,
|
|
32
80
|
effectiveDomains,
|
|
@@ -38,7 +86,6 @@ import {
|
|
|
38
86
|
isAgentProviderId,
|
|
39
87
|
isBrowserProvider,
|
|
40
88
|
keywordGenerateRequestSchema,
|
|
41
|
-
loadConfig,
|
|
42
89
|
locationContextSchema,
|
|
43
90
|
missingDependency,
|
|
44
91
|
normalizeProjectDomain,
|
|
@@ -54,7 +101,6 @@ import {
|
|
|
54
101
|
runInProgress,
|
|
55
102
|
runNotCancellable,
|
|
56
103
|
runTriggerRequestSchema,
|
|
57
|
-
saveConfigPatch,
|
|
58
104
|
scheduleUpsertRequestSchema,
|
|
59
105
|
serializeRunError,
|
|
60
106
|
snapshotRequestSchema,
|
|
@@ -64,45 +110,7 @@ import {
|
|
|
64
110
|
visibilityStateFromAnswerMentioned,
|
|
65
111
|
windowCutoff,
|
|
66
112
|
wordpressEnvSchema
|
|
67
|
-
} from "./chunk-
|
|
68
|
-
import {
|
|
69
|
-
IntelligenceService,
|
|
70
|
-
agentMemory,
|
|
71
|
-
agentSessions,
|
|
72
|
-
apiKeys,
|
|
73
|
-
auditLog,
|
|
74
|
-
backlinkDomains,
|
|
75
|
-
backlinkSummaries,
|
|
76
|
-
bingCoverageSnapshots,
|
|
77
|
-
bingUrlInspections,
|
|
78
|
-
buildContentGapRows,
|
|
79
|
-
buildContentSourceRows,
|
|
80
|
-
buildContentTargetRows,
|
|
81
|
-
buildInventory,
|
|
82
|
-
ccReleaseSyncs,
|
|
83
|
-
competitors,
|
|
84
|
-
createLogger,
|
|
85
|
-
dropLegacyCredentialColumns,
|
|
86
|
-
extractLegacyCredentials,
|
|
87
|
-
gaAiReferrals,
|
|
88
|
-
gaSocialReferrals,
|
|
89
|
-
gaTrafficSnapshots,
|
|
90
|
-
gaTrafficSummaries,
|
|
91
|
-
gscCoverageSnapshots,
|
|
92
|
-
gscSearchData,
|
|
93
|
-
gscUrlInspections,
|
|
94
|
-
healthSnapshots,
|
|
95
|
-
insights,
|
|
96
|
-
isBlogShapedQuery,
|
|
97
|
-
keywords,
|
|
98
|
-
notifications,
|
|
99
|
-
parseJsonColumn,
|
|
100
|
-
projects,
|
|
101
|
-
querySnapshots,
|
|
102
|
-
runs,
|
|
103
|
-
schedules,
|
|
104
|
-
usageCounters
|
|
105
|
-
} from "./chunk-ZCPZOVVE.js";
|
|
113
|
+
} from "./chunk-D4YFX3X4.js";
|
|
106
114
|
|
|
107
115
|
// src/telemetry.ts
|
|
108
116
|
import crypto from "crypto";
|
|
@@ -2386,117 +2394,388 @@ async function intelligenceRoutes(app) {
|
|
|
2386
2394
|
}
|
|
2387
2395
|
|
|
2388
2396
|
// ../api-routes/src/report.ts
|
|
2389
|
-
import { and as
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
var
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2397
|
+
import { and as and4, desc as desc6, eq as eq13, inArray as inArray4, or as or2 } from "drizzle-orm";
|
|
2398
|
+
|
|
2399
|
+
// ../api-routes/src/content-data.ts
|
|
2400
|
+
import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
|
|
2401
|
+
var RECENT_RUNS_WINDOW = 5;
|
|
2402
|
+
function loadOrchestratorInput(db, project) {
|
|
2403
|
+
const projectId = project.id;
|
|
2404
|
+
const ownDomain = normalizeDomain(project.canonicalDomain);
|
|
2405
|
+
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
2406
|
+
const ourDomains = /* @__PURE__ */ new Set([ownDomain, ...ownedDomains.map(normalizeDomain)]);
|
|
2407
|
+
const trackedKeywords = listKeywords(db, projectId);
|
|
2408
|
+
const candidateQueryStrings = trackedKeywords.filter(isBlogShapedQuery);
|
|
2409
|
+
const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain);
|
|
2410
|
+
const competitorSet = new Set(trackedCompetitors);
|
|
2411
|
+
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
|
|
2412
|
+
const latestRunId = recentRunIds[0] ?? "";
|
|
2413
|
+
const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
|
|
2414
|
+
const candidateQueries = buildCandidateQueries({
|
|
2415
|
+
db,
|
|
2416
|
+
projectId,
|
|
2417
|
+
candidateQueryStrings,
|
|
2418
|
+
recentRunIds,
|
|
2419
|
+
latestRunId,
|
|
2420
|
+
ourDomains,
|
|
2421
|
+
competitorSet
|
|
2422
|
+
});
|
|
2423
|
+
const inventory = buildInventory({
|
|
2424
|
+
gscPages: listGscPagesForProject(db, projectId),
|
|
2425
|
+
ga4LandingPages: listGa4LandingPagesForProject(db, projectId),
|
|
2426
|
+
sitemapUrls: [],
|
|
2427
|
+
wpPosts: []
|
|
2428
|
+
});
|
|
2429
|
+
const gaTrafficByPage = buildGaTrafficByPage(db, projectId);
|
|
2430
|
+
const totalAiReferralSessions = sumAiReferralSessions(db, projectId);
|
|
2431
|
+
return {
|
|
2432
|
+
projectId,
|
|
2433
|
+
ownDomain,
|
|
2434
|
+
competitors: trackedCompetitors,
|
|
2435
|
+
candidateQueries,
|
|
2436
|
+
inventory,
|
|
2437
|
+
wpSchemaAudit: /* @__PURE__ */ new Map(),
|
|
2438
|
+
gaTrafficByPage,
|
|
2439
|
+
totalAiReferralSessions,
|
|
2440
|
+
latestRunId,
|
|
2441
|
+
latestRunTimestamp,
|
|
2442
|
+
inProgressActions: /* @__PURE__ */ new Map()
|
|
2443
|
+
};
|
|
2402
2444
|
}
|
|
2403
|
-
function
|
|
2404
|
-
const
|
|
2405
|
-
|
|
2406
|
-
const normalized = normalizeProjectDomain(domain);
|
|
2407
|
-
if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
|
|
2408
|
-
}
|
|
2409
|
-
return false;
|
|
2445
|
+
function listKeywords(db, projectId) {
|
|
2446
|
+
const rows = db.select({ text: keywords.keyword }).from(keywords).where(eq12(keywords.projectId, projectId)).all();
|
|
2447
|
+
return rows.map((r) => r.text);
|
|
2410
2448
|
}
|
|
2411
|
-
function
|
|
2412
|
-
const
|
|
2413
|
-
|
|
2414
|
-
projectName.toLowerCase(),
|
|
2415
|
-
canonicalDomain.toLowerCase().replace(/\.[^.]+$/, "")
|
|
2416
|
-
].filter((t) => t.length >= 3);
|
|
2417
|
-
if (projectTokens.some((token) => lower.includes(token))) return "brand";
|
|
2418
|
-
if (/\b(buy|price|pricing|cost|hire|near me|services?|agency|consultant|company)\b/.test(lower)) {
|
|
2419
|
-
return "lead-gen";
|
|
2420
|
-
}
|
|
2421
|
-
if (/\b(what|how|why|when|guide|tutorial|vs|versus|alternatives?|examples?|definition)\b/.test(lower)) {
|
|
2422
|
-
return "industry";
|
|
2423
|
-
}
|
|
2424
|
-
return "other";
|
|
2449
|
+
function listCompetitorDomains(db, projectId) {
|
|
2450
|
+
const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq12(competitors.projectId, projectId)).all();
|
|
2451
|
+
return rows.map((r) => r.domain);
|
|
2425
2452
|
}
|
|
2426
|
-
function
|
|
2427
|
-
const rows = db.select().from(
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
competitorOverlap: parseJsonColumn(r.competitorOverlap, []),
|
|
2439
|
-
createdAt: r.createdAt
|
|
2440
|
-
}));
|
|
2453
|
+
function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
2454
|
+
const rows = db.select({ id: runs.id }).from(runs).where(
|
|
2455
|
+
and3(
|
|
2456
|
+
eq12(runs.projectId, projectId),
|
|
2457
|
+
eq12(runs.kind, RunKinds["answer-visibility"]),
|
|
2458
|
+
// Queued/running/failed/cancelled runs may have partial or no
|
|
2459
|
+
// snapshots; including them risks pointing latestRunId at a run with
|
|
2460
|
+
// no usable evidence.
|
|
2461
|
+
inArray3(runs.status, [RunStatuses.completed, RunStatuses.partial])
|
|
2462
|
+
)
|
|
2463
|
+
).orderBy(desc5(runs.createdAt)).limit(limit).all();
|
|
2464
|
+
return rows.map((r) => r.id);
|
|
2441
2465
|
}
|
|
2442
|
-
function
|
|
2443
|
-
const
|
|
2444
|
-
|
|
2445
|
-
for (const row of rows) byId.set(row.id, row.keyword);
|
|
2446
|
-
return { byId };
|
|
2466
|
+
function lookupRunTimestamp(db, runId) {
|
|
2467
|
+
const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq12(runs.id, runId)).get();
|
|
2468
|
+
return row?.createdAt ?? "";
|
|
2447
2469
|
}
|
|
2448
|
-
function
|
|
2449
|
-
|
|
2450
|
-
|
|
2470
|
+
function listGscPagesForProject(db, projectId) {
|
|
2471
|
+
const rows = db.selectDistinct({ page: gscSearchData.page }).from(gscSearchData).where(eq12(gscSearchData.projectId, projectId)).all();
|
|
2472
|
+
return rows.map((r) => r.page);
|
|
2473
|
+
}
|
|
2474
|
+
function listGa4LandingPagesForProject(db, projectId) {
|
|
2475
|
+
const rows = db.selectDistinct({ landingPage: gaTrafficSnapshots.landingPage }).from(gaTrafficSnapshots).where(eq12(gaTrafficSnapshots.projectId, projectId)).all();
|
|
2476
|
+
return rows.map((r) => r.landingPage);
|
|
2477
|
+
}
|
|
2478
|
+
function buildGaTrafficByPage(db, projectId) {
|
|
2479
|
+
const rows = db.select({
|
|
2480
|
+
landingPage: gaTrafficSnapshots.landingPage,
|
|
2481
|
+
sessions: gaTrafficSnapshots.sessions
|
|
2482
|
+
}).from(gaTrafficSnapshots).where(eq12(gaTrafficSnapshots.projectId, projectId)).all();
|
|
2483
|
+
const map = /* @__PURE__ */ new Map();
|
|
2484
|
+
for (const row of rows) {
|
|
2485
|
+
const path15 = extractPath(row.landingPage);
|
|
2486
|
+
if (!path15) continue;
|
|
2487
|
+
map.set(path15, (map.get(path15) ?? 0) + (row.sessions ?? 0));
|
|
2451
2488
|
}
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2489
|
+
return map;
|
|
2490
|
+
}
|
|
2491
|
+
function sumAiReferralSessions(db, projectId) {
|
|
2492
|
+
const rows = db.select({ sessions: gaAiReferrals.sessions }).from(gaAiReferrals).where(eq12(gaAiReferrals.projectId, projectId)).all();
|
|
2493
|
+
return rows.reduce((acc, r) => acc + (r.sessions ?? 0), 0);
|
|
2494
|
+
}
|
|
2495
|
+
function buildCandidateQueries(opts) {
|
|
2496
|
+
if (opts.candidateQueryStrings.length === 0 || opts.recentRunIds.length === 0) {
|
|
2497
|
+
return opts.candidateQueryStrings.map((query) => emptyCandidate(query));
|
|
2459
2498
|
}
|
|
2460
|
-
const
|
|
2461
|
-
const
|
|
2462
|
-
const
|
|
2463
|
-
|
|
2464
|
-
);
|
|
2465
|
-
const
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
const ki = keywordList.indexOf(kw);
|
|
2470
|
-
const pi = providerList.indexOf(snap.provider);
|
|
2471
|
-
if (ki < 0 || pi < 0) continue;
|
|
2472
|
-
matrix[ki][pi] = {
|
|
2473
|
-
citationState: snap.citationState === "cited" ? "cited" : "not-cited",
|
|
2474
|
-
answerMentioned: snap.answerMentioned ?? null,
|
|
2475
|
-
model: snap.model
|
|
2476
|
-
};
|
|
2477
|
-
const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
2478
|
-
counts.total++;
|
|
2479
|
-
if (snap.citationState === "cited") counts.cited++;
|
|
2480
|
-
providerCounts.set(snap.provider, counts);
|
|
2499
|
+
const keywordRows = opts.db.select({ id: keywords.id, text: keywords.keyword }).from(keywords).where(eq12(keywords.projectId, opts.projectId)).all();
|
|
2500
|
+
const keywordIdByText = new Map(keywordRows.map((r) => [r.text, r.id]));
|
|
2501
|
+
const candidateKeywordIds = opts.candidateQueryStrings.map((q) => keywordIdByText.get(q)).filter((id) => Boolean(id));
|
|
2502
|
+
const snapshotRows = opts.db.select().from(querySnapshots).where(inArray3(querySnapshots.runId, opts.recentRunIds)).all().filter((r) => candidateKeywordIds.includes(r.keywordId));
|
|
2503
|
+
const snapshotsByKeyword = /* @__PURE__ */ new Map();
|
|
2504
|
+
for (const row of snapshotRows) {
|
|
2505
|
+
const list = snapshotsByKeyword.get(row.keywordId) ?? [];
|
|
2506
|
+
list.push(row);
|
|
2507
|
+
snapshotsByKeyword.set(row.keywordId, list);
|
|
2481
2508
|
}
|
|
2482
|
-
const
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2509
|
+
const gscRows = opts.db.select().from(gscSearchData).where(eq12(gscSearchData.projectId, opts.projectId)).all();
|
|
2510
|
+
const gscByQuery = aggregateGscByQuery(gscRows);
|
|
2511
|
+
return opts.candidateQueryStrings.map((query) => {
|
|
2512
|
+
const keywordId = keywordIdByText.get(query);
|
|
2513
|
+
const snaps = keywordId ? snapshotsByKeyword.get(keywordId) ?? [] : [];
|
|
2514
|
+
const gsc = gscByQuery.get(query) ?? null;
|
|
2515
|
+
return aggregateCandidate({
|
|
2516
|
+
query,
|
|
2517
|
+
snapshots: snaps,
|
|
2518
|
+
gsc,
|
|
2519
|
+
ourDomains: opts.ourDomains,
|
|
2520
|
+
competitorSet: opts.competitorSet,
|
|
2521
|
+
latestRunId: opts.latestRunId
|
|
2522
|
+
});
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
function aggregateGscByQuery(rows) {
|
|
2526
|
+
const byQuery = /* @__PURE__ */ new Map();
|
|
2527
|
+
for (const r of rows) {
|
|
2528
|
+
const existing = byQuery.get(r.query);
|
|
2529
|
+
const candidate = {
|
|
2530
|
+
// GSC stores `page` as a full URL for url-prefix properties; normalize to
|
|
2531
|
+
// a path so it can be joined against `gaTrafficByPage` (which is keyed by
|
|
2532
|
+
// path) and so `ourBestPage.url` / `targetRef` stay consistent regardless
|
|
2533
|
+
// of whether the page is sourced from GSC or from inventory.
|
|
2534
|
+
page: extractPath(r.page),
|
|
2535
|
+
position: Number(r.position) || 0,
|
|
2536
|
+
impressions: r.impressions,
|
|
2537
|
+
clicks: r.clicks,
|
|
2538
|
+
ctr: Number(r.ctr) || 0
|
|
2539
|
+
};
|
|
2540
|
+
if (!existing) {
|
|
2541
|
+
byQuery.set(r.query, candidate);
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
if (candidate.impressions > existing.impressions) {
|
|
2545
|
+
byQuery.set(r.query, candidate);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return byQuery;
|
|
2549
|
+
}
|
|
2550
|
+
function aggregateCandidate(opts) {
|
|
2551
|
+
const totalSnaps = opts.snapshots.length;
|
|
2552
|
+
if (totalSnaps === 0) {
|
|
2553
|
+
return {
|
|
2554
|
+
...emptyCandidate(opts.query),
|
|
2555
|
+
gscPage: opts.gsc?.page ?? null,
|
|
2556
|
+
gscPosition: opts.gsc ? opts.gsc.position : null,
|
|
2557
|
+
gscImpressions: opts.gsc?.impressions ?? 0,
|
|
2558
|
+
gscClicks: opts.gsc?.clicks ?? 0,
|
|
2559
|
+
gscCtr: opts.gsc?.ctr ?? 0
|
|
2560
|
+
};
|
|
2561
|
+
}
|
|
2562
|
+
const citedCount = opts.snapshots.filter((s) => s.citationState === CitationStates.cited).length;
|
|
2563
|
+
const ourCitedRate = citedCount / totalSnaps;
|
|
2564
|
+
const recentMissRate = 1 - ourCitedRate;
|
|
2565
|
+
const competitorTally = /* @__PURE__ */ new Map();
|
|
2566
|
+
const competitorGroundingTally = /* @__PURE__ */ new Map();
|
|
2567
|
+
const ourGroundingTally = /* @__PURE__ */ new Map();
|
|
2568
|
+
let ourCitedInLatestRun = false;
|
|
2569
|
+
for (const snap of opts.snapshots) {
|
|
2570
|
+
const isLatestRun = snap.runId === opts.latestRunId;
|
|
2571
|
+
const competitorOverlap = parseJsonColumn(snap.competitorOverlap, []);
|
|
2572
|
+
for (const domain of competitorOverlap) {
|
|
2573
|
+
const normalized = normalizeDomain(domain);
|
|
2574
|
+
if (!opts.competitorSet.has(normalized)) continue;
|
|
2575
|
+
competitorTally.set(normalized, (competitorTally.get(normalized) ?? 0) + 1);
|
|
2576
|
+
}
|
|
2577
|
+
const grounding = extractGroundingSources(snap.rawResponse);
|
|
2578
|
+
for (const g of grounding) {
|
|
2579
|
+
const domain = normalizeDomain(extractHostFromUri(g.uri));
|
|
2580
|
+
if (!domain) continue;
|
|
2581
|
+
if (opts.ourDomains.has(domain)) {
|
|
2582
|
+
if (isLatestRun) ourCitedInLatestRun = true;
|
|
2583
|
+
recordGroundingHit(ourGroundingTally, g, domain, snap.provider);
|
|
2584
|
+
continue;
|
|
2585
|
+
}
|
|
2586
|
+
if (!opts.competitorSet.has(domain)) continue;
|
|
2587
|
+
recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
return {
|
|
2591
|
+
query: opts.query,
|
|
2592
|
+
gscPage: opts.gsc?.page ?? null,
|
|
2593
|
+
gscPosition: opts.gsc ? opts.gsc.position : null,
|
|
2594
|
+
gscImpressions: opts.gsc?.impressions ?? 0,
|
|
2595
|
+
gscClicks: opts.gsc?.clicks ?? 0,
|
|
2596
|
+
gscCtr: opts.gsc?.ctr ?? 0,
|
|
2597
|
+
ourCitedRate,
|
|
2598
|
+
ourCitedInLatestRun,
|
|
2599
|
+
competitorDomains: Array.from(competitorTally.keys()),
|
|
2600
|
+
competitorCitationCount: Array.from(competitorTally.values()).reduce((a, b) => a + b, 0),
|
|
2601
|
+
recentMissRate,
|
|
2602
|
+
ourGroundingUrls: Array.from(ourGroundingTally.values()),
|
|
2603
|
+
competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
|
|
2604
|
+
runsOfHistory: new Set(opts.snapshots.map((s) => s.runId)).size
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
function recordGroundingHit(tally, g, domain, provider) {
|
|
2608
|
+
const existing = tally.get(g.uri);
|
|
2609
|
+
if (existing) {
|
|
2610
|
+
existing.citationCount += 1;
|
|
2611
|
+
if (provider && !existing.providers.includes(provider)) {
|
|
2612
|
+
existing.providers.push(provider);
|
|
2613
|
+
}
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
tally.set(g.uri, {
|
|
2617
|
+
uri: g.uri,
|
|
2618
|
+
title: g.title,
|
|
2619
|
+
domain,
|
|
2620
|
+
citationCount: 1,
|
|
2621
|
+
providers: provider ? [provider] : []
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
function emptyCandidate(query) {
|
|
2625
|
+
return {
|
|
2626
|
+
query,
|
|
2627
|
+
gscPage: null,
|
|
2628
|
+
gscPosition: null,
|
|
2629
|
+
gscImpressions: 0,
|
|
2630
|
+
gscClicks: 0,
|
|
2631
|
+
gscCtr: 0,
|
|
2632
|
+
ourCitedRate: 0,
|
|
2633
|
+
ourCitedInLatestRun: false,
|
|
2634
|
+
competitorDomains: [],
|
|
2635
|
+
competitorCitationCount: 0,
|
|
2636
|
+
recentMissRate: 0,
|
|
2637
|
+
ourGroundingUrls: [],
|
|
2638
|
+
competitorGroundingUrls: [],
|
|
2639
|
+
runsOfHistory: 0
|
|
2640
|
+
};
|
|
2641
|
+
}
|
|
2642
|
+
function extractGroundingSources(rawResponse) {
|
|
2643
|
+
if (!rawResponse) return [];
|
|
2644
|
+
try {
|
|
2645
|
+
const parsed = JSON.parse(rawResponse);
|
|
2646
|
+
if (parsed && typeof parsed === "object" && "groundingSources" in parsed) {
|
|
2647
|
+
const grounding = parsed.groundingSources;
|
|
2648
|
+
if (Array.isArray(grounding)) {
|
|
2649
|
+
return grounding.filter(
|
|
2650
|
+
(g) => typeof g === "object" && g !== null && typeof g.uri === "string"
|
|
2651
|
+
).map((g) => ({ uri: g.uri, title: g.title ?? "" }));
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
} catch {
|
|
2655
|
+
}
|
|
2656
|
+
return [];
|
|
2657
|
+
}
|
|
2658
|
+
function extractHostFromUri(uri) {
|
|
2659
|
+
try {
|
|
2660
|
+
return new URL(uri).hostname;
|
|
2661
|
+
} catch {
|
|
2662
|
+
return "";
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
function normalizeDomain(domain) {
|
|
2666
|
+
return domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
|
|
2667
|
+
}
|
|
2668
|
+
function extractPath(url) {
|
|
2669
|
+
if (!url) return "";
|
|
2670
|
+
const match = /^https?:\/\/[^/]+(.*)$/.exec(url.trim());
|
|
2671
|
+
const path15 = match ? match[1] : url.trim();
|
|
2672
|
+
const stripped = path15.replace(/\/+$/, "");
|
|
2673
|
+
return stripped || "/";
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// ../api-routes/src/report.ts
|
|
2677
|
+
var TOP_QUERIES_LIMIT = 20;
|
|
2678
|
+
var TOP_LANDING_PAGES_LIMIT = 20;
|
|
2679
|
+
var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
|
|
2680
|
+
var TOP_SOURCE_DOMAINS_LIMIT = 20;
|
|
2681
|
+
var TOP_CAMPAIGN_LIMIT = 10;
|
|
2682
|
+
var INSIGHT_LOOKBACK_RUNS = 5;
|
|
2683
|
+
function safeNum(value) {
|
|
2684
|
+
if (typeof value === "number") return value;
|
|
2685
|
+
if (typeof value === "string") {
|
|
2686
|
+
const parsed = Number(value);
|
|
2687
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
2688
|
+
}
|
|
2689
|
+
return 0;
|
|
2690
|
+
}
|
|
2691
|
+
function citedDomainBelongsToProject(citedDomain, projectDomains) {
|
|
2692
|
+
const candidate = normalizeProjectDomain(citedDomain);
|
|
2693
|
+
for (const domain of projectDomains) {
|
|
2694
|
+
const normalized = normalizeProjectDomain(domain);
|
|
2695
|
+
if (candidate === normalized || candidate.endsWith(`.${normalized}`)) return true;
|
|
2696
|
+
}
|
|
2697
|
+
return false;
|
|
2698
|
+
}
|
|
2699
|
+
function categorizeQuery(query, projectDisplayName, canonicalDomain) {
|
|
2700
|
+
return categorizeQueryByIntent(query, buildBrandTokens(canonicalDomain, projectDisplayName));
|
|
2701
|
+
}
|
|
2702
|
+
function loadSnapshotsForRun(db, runId) {
|
|
2703
|
+
const rows = db.select().from(querySnapshots).where(eq13(querySnapshots.runId, runId)).all();
|
|
2704
|
+
return rows.map((r) => ({
|
|
2705
|
+
id: r.id,
|
|
2706
|
+
runId: r.runId,
|
|
2707
|
+
keywordId: r.keywordId,
|
|
2708
|
+
provider: r.provider,
|
|
2709
|
+
model: r.model,
|
|
2710
|
+
citationState: r.citationState,
|
|
2711
|
+
answerMentioned: r.answerMentioned,
|
|
2712
|
+
answerText: r.answerText,
|
|
2713
|
+
citedDomains: parseJsonColumn(r.citedDomains, []),
|
|
2714
|
+
competitorOverlap: parseJsonColumn(r.competitorOverlap, []),
|
|
2715
|
+
groundingSources: extractGroundingSources(r.rawResponse),
|
|
2716
|
+
createdAt: r.createdAt
|
|
2717
|
+
}));
|
|
2718
|
+
}
|
|
2719
|
+
function loadKeywordLookup(db, projectId) {
|
|
2720
|
+
const rows = db.select().from(keywords).where(eq13(keywords.projectId, projectId)).all();
|
|
2721
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2722
|
+
for (const row of rows) byId.set(row.id, row.keyword);
|
|
2723
|
+
return { byId };
|
|
2724
|
+
}
|
|
2725
|
+
function buildCitationScorecard(snapshots, keywordLookup) {
|
|
2726
|
+
if (snapshots.length === 0) {
|
|
2727
|
+
return { keywords: [], providers: [], matrix: [], providerRates: [] };
|
|
2728
|
+
}
|
|
2729
|
+
const keywordSet = /* @__PURE__ */ new Set();
|
|
2730
|
+
const providerSet = /* @__PURE__ */ new Set();
|
|
2731
|
+
for (const snap of snapshots) {
|
|
2732
|
+
const kw = keywordLookup.byId.get(snap.keywordId);
|
|
2733
|
+
if (!kw) continue;
|
|
2734
|
+
keywordSet.add(kw);
|
|
2735
|
+
providerSet.add(snap.provider);
|
|
2736
|
+
}
|
|
2737
|
+
const keywordList = [...keywordSet].sort();
|
|
2738
|
+
const providerList = [...providerSet].sort();
|
|
2739
|
+
const matrix = keywordList.map(
|
|
2740
|
+
() => providerList.map(() => null)
|
|
2741
|
+
);
|
|
2742
|
+
const providerCounts = /* @__PURE__ */ new Map();
|
|
2743
|
+
for (const snap of snapshots) {
|
|
2744
|
+
const kw = keywordLookup.byId.get(snap.keywordId);
|
|
2745
|
+
if (!kw) continue;
|
|
2746
|
+
const ki = keywordList.indexOf(kw);
|
|
2747
|
+
const pi = providerList.indexOf(snap.provider);
|
|
2748
|
+
if (ki < 0 || pi < 0) continue;
|
|
2749
|
+
matrix[ki][pi] = {
|
|
2750
|
+
citationState: snap.citationState === "cited" ? "cited" : "not-cited",
|
|
2751
|
+
answerMentioned: snap.answerMentioned ?? null,
|
|
2752
|
+
model: snap.model
|
|
2753
|
+
};
|
|
2754
|
+
const counts = providerCounts.get(snap.provider) ?? { cited: 0, total: 0 };
|
|
2755
|
+
counts.total++;
|
|
2756
|
+
if (snap.citationState === "cited") counts.cited++;
|
|
2757
|
+
providerCounts.set(snap.provider, counts);
|
|
2758
|
+
}
|
|
2759
|
+
const providerRates = providerList.map((provider) => {
|
|
2760
|
+
const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
|
|
2761
|
+
const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
|
|
2762
|
+
return {
|
|
2763
|
+
provider,
|
|
2764
|
+
citedCount: counts.cited,
|
|
2765
|
+
totalCount: counts.total,
|
|
2766
|
+
citationRate
|
|
2767
|
+
};
|
|
2768
|
+
});
|
|
2769
|
+
return { keywords: keywordList, providers: providerList, matrix, providerRates };
|
|
2770
|
+
}
|
|
2771
|
+
function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, keywordLookup) {
|
|
2772
|
+
let projectCitationCount = 0;
|
|
2773
|
+
const competitorMap = /* @__PURE__ */ new Map();
|
|
2774
|
+
for (const c of competitorDomains) {
|
|
2775
|
+
competitorMap.set(c, { count: 0, keywords: /* @__PURE__ */ new Set(), pages: /* @__PURE__ */ new Map() });
|
|
2776
|
+
}
|
|
2777
|
+
for (const snap of snapshots) {
|
|
2778
|
+
const kw = keywordLookup.byId.get(snap.keywordId);
|
|
2500
2779
|
const allDomains = [...snap.citedDomains, ...snap.competitorOverlap];
|
|
2501
2780
|
if (allDomains.some((d) => citedDomainBelongsToProject(d, projectDomains))) {
|
|
2502
2781
|
projectCitationCount++;
|
|
@@ -2507,8 +2786,20 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
2507
2786
|
entry.count++;
|
|
2508
2787
|
if (kw) entry.keywords.add(kw);
|
|
2509
2788
|
}
|
|
2789
|
+
const competitorNorm = normalizeDomain(competitor);
|
|
2790
|
+
for (const gs of snap.groundingSources) {
|
|
2791
|
+
const host = normalizeDomain(extractHostFromUri(gs.uri));
|
|
2792
|
+
if (!host) continue;
|
|
2793
|
+
if (host === competitorNorm || host.endsWith(`.${competitorNorm}`)) {
|
|
2794
|
+
const entry = competitorMap.get(competitor);
|
|
2795
|
+
const pageKeywords = entry.pages.get(gs.uri) ?? /* @__PURE__ */ new Set();
|
|
2796
|
+
if (kw) pageKeywords.add(kw);
|
|
2797
|
+
entry.pages.set(gs.uri, pageKeywords);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2510
2800
|
}
|
|
2511
2801
|
}
|
|
2802
|
+
const totalCitedSlots = projectCitationCount + [...competitorMap.values()].reduce((sum, v) => sum + v.count, 0);
|
|
2512
2803
|
const competitorRows = [...competitorMap.entries()].map(([domain, data]) => {
|
|
2513
2804
|
const total = snapshots.length;
|
|
2514
2805
|
const ratio = total > 0 ? data.count / total : 0;
|
|
@@ -2518,12 +2809,16 @@ function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains,
|
|
|
2518
2809
|
else if (ratio >= 0.2) pressureLabel = "Moderate";
|
|
2519
2810
|
else pressureLabel = "Low";
|
|
2520
2811
|
}
|
|
2812
|
+
const sharePct = totalCitedSlots > 0 ? Math.round(data.count / totalCitedSlots * 100) : 0;
|
|
2813
|
+
const theirCitedPages = [...data.pages.entries()].map(([url, kws]) => ({ url, citedFor: [...kws].sort() })).sort((a, b) => b.citedFor.length - a.citedFor.length);
|
|
2521
2814
|
return {
|
|
2522
2815
|
domain,
|
|
2523
2816
|
citationCount: data.count,
|
|
2524
2817
|
totalCount: total,
|
|
2525
2818
|
pressureLabel,
|
|
2526
|
-
citedKeywords: [...data.keywords].sort()
|
|
2819
|
+
citedKeywords: [...data.keywords].sort(),
|
|
2820
|
+
sharePct,
|
|
2821
|
+
theirCitedPages
|
|
2527
2822
|
};
|
|
2528
2823
|
});
|
|
2529
2824
|
competitorRows.sort((a, b) => b.citationCount - a.citationCount);
|
|
@@ -2557,8 +2852,8 @@ function buildAiSourceOrigin(snapshots, projectDomains, competitorDomains) {
|
|
|
2557
2852
|
})).sort((a, b) => b.count - a.count).slice(0, TOP_SOURCE_DOMAINS_LIMIT);
|
|
2558
2853
|
return { categories, topDomains };
|
|
2559
2854
|
}
|
|
2560
|
-
function buildGscSection(db, projectId,
|
|
2561
|
-
const rows = db.select().from(gscSearchData).where(
|
|
2855
|
+
function buildGscSection(db, projectId, projectDisplayName, canonicalDomain, trackedKeywords) {
|
|
2856
|
+
const rows = db.select().from(gscSearchData).where(eq13(gscSearchData.projectId, projectId)).all();
|
|
2562
2857
|
if (rows.length === 0) return null;
|
|
2563
2858
|
let totalClicks = 0;
|
|
2564
2859
|
let totalImpressions = 0;
|
|
@@ -2587,11 +2882,11 @@ function buildGscSection(db, projectId, projectName, canonicalDomain) {
|
|
|
2587
2882
|
impressions: agg.impressions,
|
|
2588
2883
|
ctr: agg.impressions > 0 ? agg.clicks / agg.impressions : 0,
|
|
2589
2884
|
avgPosition: agg.impressions > 0 ? agg.weightedPositionSum / agg.impressions : 0,
|
|
2590
|
-
category: categorizeQuery(query,
|
|
2885
|
+
category: categorizeQuery(query, projectDisplayName, canonicalDomain)
|
|
2591
2886
|
})).sort((a, b) => b.clicks - a.clicks).slice(0, TOP_QUERIES_LIMIT);
|
|
2592
2887
|
const categoryAgg = /* @__PURE__ */ new Map();
|
|
2593
2888
|
for (const [query, agg] of queryAgg) {
|
|
2594
|
-
const cat = categorizeQuery(query,
|
|
2889
|
+
const cat = categorizeQuery(query, projectDisplayName, canonicalDomain);
|
|
2595
2890
|
const bucket = categoryAgg.get(cat) ?? { clicks: 0, impressions: 0 };
|
|
2596
2891
|
bucket.clicks += agg.clicks;
|
|
2597
2892
|
bucket.impressions += agg.impressions;
|
|
@@ -2604,6 +2899,10 @@ function buildGscSection(db, projectId, projectName, canonicalDomain) {
|
|
|
2604
2899
|
sharePct: totalClicks > 0 ? Math.round(agg.clicks / totalClicks * 100) : 0
|
|
2605
2900
|
})).sort((a, b) => b.clicks - a.clicks);
|
|
2606
2901
|
const trend = [...trendAgg.entries()].map(([date, agg]) => ({ date, clicks: agg.clicks, impressions: agg.impressions })).sort((a, b) => a.date.localeCompare(b.date));
|
|
2902
|
+
const trackedSet = new Set(trackedKeywords.map((k) => k.toLowerCase()));
|
|
2903
|
+
const gscQuerySet = new Set([...queryAgg.keys()].map((q) => q.toLowerCase()));
|
|
2904
|
+
const trackedButNoGsc = trackedKeywords.filter((k) => !gscQuerySet.has(k.toLowerCase())).sort();
|
|
2905
|
+
const gscButNotTracked = [...queryAgg.entries()].filter(([q]) => !trackedSet.has(q.toLowerCase())).filter(([q]) => categorizeQuery(q, projectDisplayName, canonicalDomain) !== "brand").sort((a, b) => b[1].impressions - a[1].impressions).map(([q]) => q).slice(0, TOP_QUERIES_LIMIT);
|
|
2607
2906
|
return {
|
|
2608
2907
|
totalClicks,
|
|
2609
2908
|
totalImpressions,
|
|
@@ -2611,12 +2910,14 @@ function buildGscSection(db, projectId, projectName, canonicalDomain) {
|
|
|
2611
2910
|
avgPosition,
|
|
2612
2911
|
topQueries,
|
|
2613
2912
|
categoryBreakdown,
|
|
2614
|
-
trend
|
|
2913
|
+
trend,
|
|
2914
|
+
trackedButNoGsc,
|
|
2915
|
+
gscButNotTracked
|
|
2615
2916
|
};
|
|
2616
2917
|
}
|
|
2617
2918
|
function buildGaSection(db, projectId) {
|
|
2618
|
-
const summaryRow = db.select().from(gaTrafficSummaries).where(
|
|
2619
|
-
const snapshotRows = db.select().from(gaTrafficSnapshots).where(
|
|
2919
|
+
const summaryRow = db.select().from(gaTrafficSummaries).where(eq13(gaTrafficSummaries.projectId, projectId)).orderBy(desc6(gaTrafficSummaries.syncedAt)).limit(1).get();
|
|
2920
|
+
const snapshotRows = db.select().from(gaTrafficSnapshots).where(eq13(gaTrafficSnapshots.projectId, projectId)).all();
|
|
2620
2921
|
if (!summaryRow && snapshotRows.length === 0) return null;
|
|
2621
2922
|
const totalSessions = summaryRow?.totalSessions ?? snapshotRows.reduce((s, r) => s + r.sessions, 0);
|
|
2622
2923
|
const totalUsers = summaryRow?.totalUsers ?? snapshotRows.reduce((s, r) => s + r.users, 0);
|
|
@@ -2669,7 +2970,7 @@ function buildGaSection(db, projectId) {
|
|
|
2669
2970
|
};
|
|
2670
2971
|
}
|
|
2671
2972
|
function buildSocialReferrals(db, projectId) {
|
|
2672
|
-
const rows = db.select().from(gaSocialReferrals).where(
|
|
2973
|
+
const rows = db.select().from(gaSocialReferrals).where(eq13(gaSocialReferrals.projectId, projectId)).all();
|
|
2673
2974
|
if (rows.length === 0) return null;
|
|
2674
2975
|
let total = 0;
|
|
2675
2976
|
let organic = 0;
|
|
@@ -2701,7 +3002,7 @@ function buildSocialReferrals(db, projectId) {
|
|
|
2701
3002
|
};
|
|
2702
3003
|
}
|
|
2703
3004
|
function buildAiReferrals(db, projectId) {
|
|
2704
|
-
const rows = db.select().from(gaAiReferrals).where(
|
|
3005
|
+
const rows = db.select().from(gaAiReferrals).where(eq13(gaAiReferrals.projectId, projectId)).all();
|
|
2705
3006
|
if (rows.length === 0) return null;
|
|
2706
3007
|
const dimSessionsByTuple = /* @__PURE__ */ new Map();
|
|
2707
3008
|
for (const r of rows) {
|
|
@@ -2758,7 +3059,7 @@ function buildAiReferrals(db, projectId) {
|
|
|
2758
3059
|
return { totalSessions: total, totalUsers, bySource, trend, topLandingPages };
|
|
2759
3060
|
}
|
|
2760
3061
|
function buildIndexingHealth(db, projectId) {
|
|
2761
|
-
const gsc = db.select().from(gscCoverageSnapshots).where(
|
|
3062
|
+
const gsc = db.select().from(gscCoverageSnapshots).where(eq13(gscCoverageSnapshots.projectId, projectId)).orderBy(desc6(gscCoverageSnapshots.date)).limit(1).get();
|
|
2762
3063
|
if (gsc) {
|
|
2763
3064
|
const total = gsc.indexed + gsc.notIndexed;
|
|
2764
3065
|
return {
|
|
@@ -2771,7 +3072,7 @@ function buildIndexingHealth(db, projectId) {
|
|
|
2771
3072
|
indexedPct: total > 0 ? Math.round(gsc.indexed / total * 100) : 0
|
|
2772
3073
|
};
|
|
2773
3074
|
}
|
|
2774
|
-
const bing = db.select().from(bingCoverageSnapshots).where(
|
|
3075
|
+
const bing = db.select().from(bingCoverageSnapshots).where(eq13(bingCoverageSnapshots.projectId, projectId)).orderBy(desc6(bingCoverageSnapshots.date)).limit(1).get();
|
|
2775
3076
|
if (bing) {
|
|
2776
3077
|
const total = bing.indexed + bing.notIndexed + bing.unknown;
|
|
2777
3078
|
return {
|
|
@@ -2787,7 +3088,7 @@ function buildIndexingHealth(db, projectId) {
|
|
|
2787
3088
|
return null;
|
|
2788
3089
|
}
|
|
2789
3090
|
function buildCitationsTrend(db, projectId, keywordLookup) {
|
|
2790
|
-
const visibilityRuns = db.select().from(runs).where(
|
|
3091
|
+
const visibilityRuns = db.select().from(runs).where(and4(eq13(runs.projectId, projectId), eq13(runs.kind, RunKinds["answer-visibility"]))).all();
|
|
2791
3092
|
const points = [];
|
|
2792
3093
|
for (const run of visibilityRuns) {
|
|
2793
3094
|
if (run.status !== RunStatuses.completed) continue;
|
|
@@ -2822,9 +3123,17 @@ function buildCitationsTrend(db, projectId, keywordLookup) {
|
|
|
2822
3123
|
return points;
|
|
2823
3124
|
}
|
|
2824
3125
|
function buildInsightList(db, projectId) {
|
|
2825
|
-
const
|
|
3126
|
+
const recentRunIds = db.select({ id: runs.id }).from(runs).where(
|
|
3127
|
+
and4(
|
|
3128
|
+
eq13(runs.projectId, projectId),
|
|
3129
|
+
eq13(runs.kind, RunKinds["answer-visibility"]),
|
|
3130
|
+
or2(eq13(runs.status, RunStatuses.completed), eq13(runs.status, RunStatuses.partial))
|
|
3131
|
+
)
|
|
3132
|
+
).orderBy(desc6(runs.createdAt)).limit(INSIGHT_LOOKBACK_RUNS).all().map((r) => r.id);
|
|
3133
|
+
if (recentRunIds.length === 0) return [];
|
|
3134
|
+
const rows = db.select().from(insights).where(and4(eq13(insights.projectId, projectId), inArray4(insights.runId, recentRunIds))).orderBy(desc6(insights.createdAt)).all();
|
|
2826
3135
|
const severityRank = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
2827
|
-
|
|
3136
|
+
const flat = rows.filter((r) => !r.dismissed).map((r) => {
|
|
2828
3137
|
const recommendation = parseJsonColumn(r.recommendation, null);
|
|
2829
3138
|
let recText = null;
|
|
2830
3139
|
if (recommendation) {
|
|
@@ -2842,9 +3151,27 @@ function buildInsightList(db, projectId) {
|
|
|
2842
3151
|
keyword: r.keyword,
|
|
2843
3152
|
provider: r.provider,
|
|
2844
3153
|
recommendation: recText,
|
|
2845
|
-
createdAt: r.createdAt
|
|
3154
|
+
createdAt: r.createdAt,
|
|
3155
|
+
instanceCount: 1,
|
|
3156
|
+
_sortRank: severityRank[r.severity] ?? 99
|
|
3157
|
+
};
|
|
3158
|
+
});
|
|
3159
|
+
const groups = groupInsights(flat);
|
|
3160
|
+
return groups.map((g) => {
|
|
3161
|
+
const rep = g.representative;
|
|
3162
|
+
const rest = {
|
|
3163
|
+
id: rep.id,
|
|
3164
|
+
type: rep.type,
|
|
3165
|
+
severity: rep.severity,
|
|
3166
|
+
title: rep.title,
|
|
3167
|
+
keyword: rep.keyword,
|
|
3168
|
+
provider: rep.provider,
|
|
3169
|
+
recommendation: rep.recommendation,
|
|
3170
|
+
createdAt: rep.createdAt,
|
|
3171
|
+
instanceCount: g.count
|
|
2846
3172
|
};
|
|
2847
|
-
|
|
3173
|
+
return { ...rest, _sortRank: rep._sortRank };
|
|
3174
|
+
}).sort((a, b) => a._sortRank - b._sortRank).map(({ _sortRank: _drop, ...rest }) => rest);
|
|
2848
3175
|
}
|
|
2849
3176
|
function buildRecommendedNextSteps(insightList) {
|
|
2850
3177
|
const steps = [];
|
|
@@ -2874,24 +3201,28 @@ function buildRecommendedNextSteps(insightList) {
|
|
|
2874
3201
|
}
|
|
2875
3202
|
return steps;
|
|
2876
3203
|
}
|
|
2877
|
-
function buildExecutiveFindings(citationRate, trend, trendsPoints, insightList, competitorRows) {
|
|
3204
|
+
function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline, insightList, competitorRows) {
|
|
2878
3205
|
const findings = [];
|
|
2879
3206
|
if (trendsPoints.length > 0) {
|
|
2880
|
-
const tone = trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
3207
|
+
const tone = trendBaseline ? "neutral" : trend === "up" ? "positive" : trend === "down" ? "negative" : "neutral";
|
|
2881
3208
|
let detail;
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
3209
|
+
if (trendBaseline) {
|
|
3210
|
+
detail = `Establishing baseline (${trendsPoints.length} of ${MIN_TREND_POINTS} runs collected).`;
|
|
3211
|
+
} else {
|
|
3212
|
+
switch (trend) {
|
|
3213
|
+
case "up":
|
|
3214
|
+
detail = "Up from the previous run.";
|
|
3215
|
+
break;
|
|
3216
|
+
case "down":
|
|
3217
|
+
detail = "Down from the previous run.";
|
|
3218
|
+
break;
|
|
3219
|
+
case "flat":
|
|
3220
|
+
detail = "Flat compared to the previous run.";
|
|
3221
|
+
break;
|
|
3222
|
+
case "unknown":
|
|
3223
|
+
detail = "No prior run to compare against.";
|
|
3224
|
+
break;
|
|
3225
|
+
}
|
|
2895
3226
|
}
|
|
2896
3227
|
findings.push({
|
|
2897
3228
|
title: `Citation rate at ${citationRate}%`,
|
|
@@ -2921,13 +3252,13 @@ async function reportRoutes(app) {
|
|
|
2921
3252
|
app.get("/projects/:name/report", async (request, reply) => {
|
|
2922
3253
|
const project = resolveProject(app.db, request.params.name);
|
|
2923
3254
|
const keywordLookup = loadKeywordLookup(app.db, project.id);
|
|
2924
|
-
const allRuns = app.db.select().from(runs).where(
|
|
3255
|
+
const allRuns = app.db.select().from(runs).where(eq13(runs.projectId, project.id)).orderBy(desc6(runs.createdAt)).all();
|
|
2925
3256
|
const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
|
|
2926
3257
|
const latestRun = visibilityRuns.find(
|
|
2927
3258
|
(r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
|
|
2928
3259
|
) ?? visibilityRuns[0];
|
|
2929
3260
|
const latestSnapshots = latestRun ? loadSnapshotsForRun(app.db, latestRun.id) : [];
|
|
2930
|
-
const competitorRows = app.db.select().from(competitors).where(
|
|
3261
|
+
const competitorRows = app.db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
|
|
2931
3262
|
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
2932
3263
|
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
2933
3264
|
const projectDomains = [project.canonicalDomain, ...ownedDomains];
|
|
@@ -2939,14 +3270,29 @@ async function reportRoutes(app) {
|
|
|
2939
3270
|
keywordLookup
|
|
2940
3271
|
);
|
|
2941
3272
|
const aiSourceOrigin = buildAiSourceOrigin(latestSnapshots, projectDomains, competitorDomains);
|
|
2942
|
-
const
|
|
3273
|
+
const trackedKeywords = [...keywordLookup.byId.values()];
|
|
3274
|
+
const gscSection = buildGscSection(
|
|
3275
|
+
app.db,
|
|
3276
|
+
project.id,
|
|
3277
|
+
project.displayName,
|
|
3278
|
+
project.canonicalDomain,
|
|
3279
|
+
trackedKeywords
|
|
3280
|
+
);
|
|
2943
3281
|
const gaSection = buildGaSection(app.db, project.id);
|
|
2944
3282
|
const socialSection = buildSocialReferrals(app.db, project.id);
|
|
2945
3283
|
const aiReferralsSection = buildAiReferrals(app.db, project.id);
|
|
2946
3284
|
const indexingHealthSection = buildIndexingHealth(app.db, project.id);
|
|
2947
3285
|
const citationsTrend = buildCitationsTrend(app.db, project.id, keywordLookup);
|
|
2948
3286
|
const insightList = buildInsightList(app.db, project.id);
|
|
2949
|
-
const
|
|
3287
|
+
const orchestratorInput = loadOrchestratorInput(app.db, project);
|
|
3288
|
+
const contentOpportunities = buildContentTargetRows(orchestratorInput);
|
|
3289
|
+
const contentGaps = buildContentGapRows(orchestratorInput);
|
|
3290
|
+
const groundingSources = buildContentSourceRows(orchestratorInput);
|
|
3291
|
+
const insightDerivedSteps = buildRecommendedNextSteps(insightList);
|
|
3292
|
+
const recommendedNextSteps = mapOpportunitiesToNextSteps(
|
|
3293
|
+
contentOpportunities,
|
|
3294
|
+
insightDerivedSteps
|
|
3295
|
+
);
|
|
2950
3296
|
let latestCited = 0;
|
|
2951
3297
|
let latestConsidered = 0;
|
|
2952
3298
|
for (const snap of latestSnapshots) {
|
|
@@ -2955,18 +3301,25 @@ async function reportRoutes(app) {
|
|
|
2955
3301
|
if (snap.citationState === "cited") latestCited++;
|
|
2956
3302
|
}
|
|
2957
3303
|
const citationRate = latestConsidered > 0 ? Math.round(latestCited / latestConsidered * 100) : 0;
|
|
3304
|
+
const trendBaseline = isTrendBaseline(citationsTrend);
|
|
2958
3305
|
const latestPoint = citationsTrend.at(-1);
|
|
2959
3306
|
const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
2960
3307
|
let trend = "unknown";
|
|
2961
|
-
if (
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
3308
|
+
if (!trendBaseline && latestPoint) {
|
|
3309
|
+
const latestRunOnTrend = latestRun?.id === latestPoint.runId;
|
|
3310
|
+
const currentRate = latestRunOnTrend ? latestPoint.citationRate : citationRate;
|
|
3311
|
+
const priorRate = latestRunOnTrend ? previousPoint?.citationRate : latestPoint.citationRate;
|
|
3312
|
+
if (priorRate !== void 0) {
|
|
3313
|
+
if (currentRate > priorRate) trend = "up";
|
|
3314
|
+
else if (currentRate < priorRate) trend = "down";
|
|
3315
|
+
else trend = "flat";
|
|
3316
|
+
}
|
|
2965
3317
|
}
|
|
2966
3318
|
const findings = buildExecutiveFindings(
|
|
2967
3319
|
citationRate,
|
|
2968
3320
|
trend,
|
|
2969
3321
|
citationsTrend,
|
|
3322
|
+
trendBaseline,
|
|
2970
3323
|
insightList,
|
|
2971
3324
|
competitorLandscape.competitors
|
|
2972
3325
|
);
|
|
@@ -3016,23 +3369,26 @@ async function reportRoutes(app) {
|
|
|
3016
3369
|
indexingHealth: indexingHealthSection,
|
|
3017
3370
|
citationsTrend,
|
|
3018
3371
|
insights: insightList,
|
|
3019
|
-
recommendedNextSteps
|
|
3372
|
+
recommendedNextSteps,
|
|
3373
|
+
contentOpportunities,
|
|
3374
|
+
contentGaps,
|
|
3375
|
+
groundingSources
|
|
3020
3376
|
};
|
|
3021
3377
|
return reply.send(dto);
|
|
3022
3378
|
});
|
|
3023
3379
|
}
|
|
3024
3380
|
|
|
3025
3381
|
// ../api-routes/src/citations.ts
|
|
3026
|
-
import { eq as
|
|
3382
|
+
import { eq as eq14, inArray as inArray5 } from "drizzle-orm";
|
|
3027
3383
|
async function citationRoutes(app) {
|
|
3028
3384
|
app.get("/projects/:name/citations/visibility", async (request, reply) => {
|
|
3029
3385
|
const project = resolveProject(app.db, request.params.name);
|
|
3030
3386
|
const configuredProviders = parseJsonColumn(project.providers, []);
|
|
3031
|
-
const projectKeywords = app.db.select().from(keywords).where(
|
|
3387
|
+
const projectKeywords = app.db.select().from(keywords).where(eq14(keywords.projectId, project.id)).all();
|
|
3032
3388
|
if (projectKeywords.length === 0) {
|
|
3033
3389
|
return reply.send(emptyCitationVisibility("no-keywords"));
|
|
3034
3390
|
}
|
|
3035
|
-
const projectRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt }).from(runs).where(
|
|
3391
|
+
const projectRuns = app.db.select({ id: runs.id, createdAt: runs.createdAt }).from(runs).where(eq14(runs.projectId, project.id)).all();
|
|
3036
3392
|
if (projectRuns.length === 0) {
|
|
3037
3393
|
return reply.send(emptyCitationVisibility("no-runs-yet"));
|
|
3038
3394
|
}
|
|
@@ -3047,7 +3403,7 @@ async function citationRoutes(app) {
|
|
|
3047
3403
|
competitorOverlap: querySnapshots.competitorOverlap,
|
|
3048
3404
|
answerMentioned: querySnapshots.answerMentioned,
|
|
3049
3405
|
createdAt: querySnapshots.createdAt
|
|
3050
|
-
}).from(querySnapshots).where(
|
|
3406
|
+
}).from(querySnapshots).where(inArray5(querySnapshots.runId, projectRuns.map((r) => r.id))).all();
|
|
3051
3407
|
if (rawSnapshots.length === 0) {
|
|
3052
3408
|
return reply.send(emptyCitationVisibility("no-runs-yet"));
|
|
3053
3409
|
}
|
|
@@ -3055,7 +3411,7 @@ async function citationRoutes(app) {
|
|
|
3055
3411
|
...s,
|
|
3056
3412
|
runCreatedAt: runCreatedAt.get(s.runId) ?? s.createdAt
|
|
3057
3413
|
}));
|
|
3058
|
-
const projectCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(
|
|
3414
|
+
const projectCompetitors = app.db.select({ domain: competitors.domain }).from(competitors).where(eq14(competitors.projectId, project.id)).all().map((c) => normalizeDomain2(c.domain)).filter((d) => d.length > 0);
|
|
3059
3415
|
const response = computeCitationVisibility({
|
|
3060
3416
|
keywords: projectKeywords.map((k) => ({ id: k.id, keyword: k.keyword })),
|
|
3061
3417
|
snapshots,
|
|
@@ -3138,7 +3494,7 @@ function computeCitationVisibility(input) {
|
|
|
3138
3494
|
const cited = parseJsonColumn(snap.citedDomains, []);
|
|
3139
3495
|
const overlap = parseJsonColumn(snap.competitorOverlap, []);
|
|
3140
3496
|
const candidates = new Set(
|
|
3141
|
-
[...cited, ...overlap].map((d) =>
|
|
3497
|
+
[...cited, ...overlap].map((d) => normalizeDomain2(d)).filter((d) => d.length > 0)
|
|
3142
3498
|
);
|
|
3143
3499
|
const citingCompetitors = Array.from(candidates).filter((d) => competitorSet.has(d));
|
|
3144
3500
|
if (citingCompetitors.length === 0) continue;
|
|
@@ -3182,26 +3538,26 @@ function computeCitationVisibility(input) {
|
|
|
3182
3538
|
status: "ready"
|
|
3183
3539
|
};
|
|
3184
3540
|
}
|
|
3185
|
-
function
|
|
3541
|
+
function normalizeDomain2(domain) {
|
|
3186
3542
|
return domain.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
|
|
3187
3543
|
}
|
|
3188
3544
|
|
|
3189
3545
|
// ../api-routes/src/composites.ts
|
|
3190
|
-
import { eq as
|
|
3546
|
+
import { eq as eq15, and as and5, desc as desc7, sql as sql3, like, or as or3 } from "drizzle-orm";
|
|
3191
3547
|
var TOP_INSIGHT_LIMIT = 5;
|
|
3192
3548
|
var SEARCH_HIT_HARD_LIMIT = 50;
|
|
3193
3549
|
var SEARCH_SNIPPET_RADIUS = 80;
|
|
3194
3550
|
async function compositeRoutes(app) {
|
|
3195
3551
|
app.get("/projects/:name/overview", async (request, reply) => {
|
|
3196
3552
|
const project = resolveProject(app.db, request.params.name);
|
|
3197
|
-
const totalRunsRow = app.db.select({ count: sql3`count(*)` }).from(runs).where(
|
|
3553
|
+
const totalRunsRow = app.db.select({ count: sql3`count(*)` }).from(runs).where(eq15(runs.projectId, project.id)).get();
|
|
3198
3554
|
const totalRuns = totalRunsRow?.count ?? 0;
|
|
3199
|
-
const recentRuns = app.db.select().from(runs).where(
|
|
3555
|
+
const recentRuns = app.db.select().from(runs).where(eq15(runs.projectId, project.id)).orderBy(desc7(runs.createdAt)).limit(2).all();
|
|
3200
3556
|
const [latestRunRow, previousRunRow] = recentRuns;
|
|
3201
3557
|
const latestRun = latestRunRow ? { totalRuns, run: summarizeRun(latestRunRow) } : { totalRuns: 0, run: null };
|
|
3202
|
-
const healthRow = app.db.select().from(healthSnapshots).where(
|
|
3558
|
+
const healthRow = app.db.select().from(healthSnapshots).where(eq15(healthSnapshots.projectId, project.id)).orderBy(desc7(healthSnapshots.createdAt)).limit(1).get();
|
|
3203
3559
|
const health = healthRow ? mapHealthRow2(healthRow) : null;
|
|
3204
|
-
const insightRows = app.db.select().from(insights).where(
|
|
3560
|
+
const insightRows = app.db.select().from(insights).where(eq15(insights.projectId, project.id)).orderBy(desc7(insights.createdAt)).all();
|
|
3205
3561
|
const topInsights = insightRows.filter((row) => !row.dismissed).slice(0, TOP_INSIGHT_LIMIT).map(mapInsightRow2);
|
|
3206
3562
|
const { keywordCounts, providers } = summarizeLatestRun(app, latestRunRow ?? null);
|
|
3207
3563
|
const transitions = summarizeTransitions(app, latestRunRow ?? null, previousRunRow ?? null);
|
|
@@ -3237,28 +3593,28 @@ async function compositeRoutes(app) {
|
|
|
3237
3593
|
citedDomains: querySnapshots.citedDomains,
|
|
3238
3594
|
rawResponse: querySnapshots.rawResponse,
|
|
3239
3595
|
createdAt: querySnapshots.createdAt
|
|
3240
|
-
}).from(querySnapshots).innerJoin(keywords,
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3596
|
+
}).from(querySnapshots).innerJoin(keywords, eq15(querySnapshots.keywordId, keywords.id)).where(
|
|
3597
|
+
and5(
|
|
3598
|
+
eq15(keywords.projectId, project.id),
|
|
3599
|
+
or3(
|
|
3244
3600
|
sql3`${querySnapshots.answerText} LIKE ${pattern} ESCAPE '\\'`,
|
|
3245
3601
|
sql3`${querySnapshots.citedDomains} LIKE ${pattern} ESCAPE '\\'`,
|
|
3246
3602
|
sql3`${querySnapshots.rawResponse} LIKE ${pattern} ESCAPE '\\'`,
|
|
3247
3603
|
like(keywords.keyword, pattern)
|
|
3248
3604
|
)
|
|
3249
3605
|
)
|
|
3250
|
-
).orderBy(
|
|
3606
|
+
).orderBy(desc7(querySnapshots.createdAt)).limit(limit + 1).all();
|
|
3251
3607
|
const insightMatches = app.db.select().from(insights).where(
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3608
|
+
and5(
|
|
3609
|
+
eq15(insights.projectId, project.id),
|
|
3610
|
+
or3(
|
|
3255
3611
|
like(insights.title, pattern),
|
|
3256
3612
|
like(insights.keyword, pattern),
|
|
3257
3613
|
sql3`${insights.recommendation} LIKE ${pattern} ESCAPE '\\'`,
|
|
3258
3614
|
sql3`${insights.cause} LIKE ${pattern} ESCAPE '\\'`
|
|
3259
3615
|
)
|
|
3260
3616
|
)
|
|
3261
|
-
).orderBy(
|
|
3617
|
+
).orderBy(desc7(insights.createdAt)).limit(limit + 1).all();
|
|
3262
3618
|
const hits = [];
|
|
3263
3619
|
for (const row of snapshotMatches) {
|
|
3264
3620
|
hits.push(buildSnapshotHit(row, rawQuery));
|
|
@@ -3313,7 +3669,7 @@ function summarizeLatestRun(app, run) {
|
|
|
3313
3669
|
keywordId: querySnapshots.keywordId,
|
|
3314
3670
|
provider: querySnapshots.provider,
|
|
3315
3671
|
citationState: querySnapshots.citationState
|
|
3316
|
-
}).from(querySnapshots).where(
|
|
3672
|
+
}).from(querySnapshots).where(eq15(querySnapshots.runId, run.id)).all();
|
|
3317
3673
|
if (rows.length === 0) return empty;
|
|
3318
3674
|
const perKeyword = /* @__PURE__ */ new Map();
|
|
3319
3675
|
const perProvider = /* @__PURE__ */ new Map();
|
|
@@ -3352,7 +3708,7 @@ function summarizeTransitions(app, latest, previous) {
|
|
|
3352
3708
|
const rows = app.db.select({
|
|
3353
3709
|
keywordId: querySnapshots.keywordId,
|
|
3354
3710
|
citationState: querySnapshots.citationState
|
|
3355
|
-
}).from(querySnapshots).where(
|
|
3711
|
+
}).from(querySnapshots).where(eq15(querySnapshots.runId, runId)).all();
|
|
3356
3712
|
const map = /* @__PURE__ */ new Map();
|
|
3357
3713
|
for (const row of rows) {
|
|
3358
3714
|
const cited = row.citationState === "cited";
|
|
@@ -3508,283 +3864,6 @@ function makeSnippet(text, query) {
|
|
|
3508
3864
|
return `${prefix}${text.slice(start, end)}${suffix}`;
|
|
3509
3865
|
}
|
|
3510
3866
|
|
|
3511
|
-
// ../api-routes/src/content-data.ts
|
|
3512
|
-
import { and as and5, eq as eq15, desc as desc7, inArray as inArray4 } from "drizzle-orm";
|
|
3513
|
-
var RECENT_RUNS_WINDOW = 5;
|
|
3514
|
-
function loadOrchestratorInput(db, project) {
|
|
3515
|
-
const projectId = project.id;
|
|
3516
|
-
const ownDomain = normalizeDomain2(project.canonicalDomain);
|
|
3517
|
-
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
3518
|
-
const ourDomains = /* @__PURE__ */ new Set([ownDomain, ...ownedDomains.map(normalizeDomain2)]);
|
|
3519
|
-
const trackedKeywords = listKeywords(db, projectId);
|
|
3520
|
-
const candidateQueryStrings = trackedKeywords.filter(isBlogShapedQuery);
|
|
3521
|
-
const trackedCompetitors = listCompetitorDomains(db, projectId).map(normalizeDomain2);
|
|
3522
|
-
const competitorSet = new Set(trackedCompetitors);
|
|
3523
|
-
const recentRunIds = listRecentAnswerVisibilityRunIds(db, projectId, RECENT_RUNS_WINDOW);
|
|
3524
|
-
const latestRunId = recentRunIds[0] ?? "";
|
|
3525
|
-
const latestRunTimestamp = latestRunId ? lookupRunTimestamp(db, latestRunId) : "";
|
|
3526
|
-
const candidateQueries = buildCandidateQueries({
|
|
3527
|
-
db,
|
|
3528
|
-
projectId,
|
|
3529
|
-
candidateQueryStrings,
|
|
3530
|
-
recentRunIds,
|
|
3531
|
-
latestRunId,
|
|
3532
|
-
ourDomains,
|
|
3533
|
-
competitorSet
|
|
3534
|
-
});
|
|
3535
|
-
const inventory = buildInventory({
|
|
3536
|
-
gscPages: listGscPagesForProject(db, projectId),
|
|
3537
|
-
ga4LandingPages: listGa4LandingPagesForProject(db, projectId),
|
|
3538
|
-
sitemapUrls: [],
|
|
3539
|
-
wpPosts: []
|
|
3540
|
-
});
|
|
3541
|
-
const gaTrafficByPage = buildGaTrafficByPage(db, projectId);
|
|
3542
|
-
const totalAiReferralSessions = sumAiReferralSessions(db, projectId);
|
|
3543
|
-
return {
|
|
3544
|
-
projectId,
|
|
3545
|
-
ownDomain,
|
|
3546
|
-
competitors: trackedCompetitors,
|
|
3547
|
-
candidateQueries,
|
|
3548
|
-
inventory,
|
|
3549
|
-
wpSchemaAudit: /* @__PURE__ */ new Map(),
|
|
3550
|
-
gaTrafficByPage,
|
|
3551
|
-
totalAiReferralSessions,
|
|
3552
|
-
latestRunId,
|
|
3553
|
-
latestRunTimestamp,
|
|
3554
|
-
inProgressActions: /* @__PURE__ */ new Map()
|
|
3555
|
-
};
|
|
3556
|
-
}
|
|
3557
|
-
function listKeywords(db, projectId) {
|
|
3558
|
-
const rows = db.select({ text: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, projectId)).all();
|
|
3559
|
-
return rows.map((r) => r.text);
|
|
3560
|
-
}
|
|
3561
|
-
function listCompetitorDomains(db, projectId) {
|
|
3562
|
-
const rows = db.select({ domain: competitors.domain }).from(competitors).where(eq15(competitors.projectId, projectId)).all();
|
|
3563
|
-
return rows.map((r) => r.domain);
|
|
3564
|
-
}
|
|
3565
|
-
function listRecentAnswerVisibilityRunIds(db, projectId, limit) {
|
|
3566
|
-
const rows = db.select({ id: runs.id }).from(runs).where(
|
|
3567
|
-
and5(
|
|
3568
|
-
eq15(runs.projectId, projectId),
|
|
3569
|
-
eq15(runs.kind, RunKinds["answer-visibility"]),
|
|
3570
|
-
// Queued/running/failed/cancelled runs may have partial or no
|
|
3571
|
-
// snapshots; including them risks pointing latestRunId at a run with
|
|
3572
|
-
// no usable evidence.
|
|
3573
|
-
inArray4(runs.status, [RunStatuses.completed, RunStatuses.partial])
|
|
3574
|
-
)
|
|
3575
|
-
).orderBy(desc7(runs.createdAt)).limit(limit).all();
|
|
3576
|
-
return rows.map((r) => r.id);
|
|
3577
|
-
}
|
|
3578
|
-
function lookupRunTimestamp(db, runId) {
|
|
3579
|
-
const row = db.select({ createdAt: runs.createdAt }).from(runs).where(eq15(runs.id, runId)).get();
|
|
3580
|
-
return row?.createdAt ?? "";
|
|
3581
|
-
}
|
|
3582
|
-
function listGscPagesForProject(db, projectId) {
|
|
3583
|
-
const rows = db.selectDistinct({ page: gscSearchData.page }).from(gscSearchData).where(eq15(gscSearchData.projectId, projectId)).all();
|
|
3584
|
-
return rows.map((r) => r.page);
|
|
3585
|
-
}
|
|
3586
|
-
function listGa4LandingPagesForProject(db, projectId) {
|
|
3587
|
-
const rows = db.selectDistinct({ landingPage: gaTrafficSnapshots.landingPage }).from(gaTrafficSnapshots).where(eq15(gaTrafficSnapshots.projectId, projectId)).all();
|
|
3588
|
-
return rows.map((r) => r.landingPage);
|
|
3589
|
-
}
|
|
3590
|
-
function buildGaTrafficByPage(db, projectId) {
|
|
3591
|
-
const rows = db.select({
|
|
3592
|
-
landingPage: gaTrafficSnapshots.landingPage,
|
|
3593
|
-
sessions: gaTrafficSnapshots.sessions
|
|
3594
|
-
}).from(gaTrafficSnapshots).where(eq15(gaTrafficSnapshots.projectId, projectId)).all();
|
|
3595
|
-
const map = /* @__PURE__ */ new Map();
|
|
3596
|
-
for (const row of rows) {
|
|
3597
|
-
const path15 = extractPath(row.landingPage);
|
|
3598
|
-
if (!path15) continue;
|
|
3599
|
-
map.set(path15, (map.get(path15) ?? 0) + (row.sessions ?? 0));
|
|
3600
|
-
}
|
|
3601
|
-
return map;
|
|
3602
|
-
}
|
|
3603
|
-
function sumAiReferralSessions(db, projectId) {
|
|
3604
|
-
const rows = db.select({ sessions: gaAiReferrals.sessions }).from(gaAiReferrals).where(eq15(gaAiReferrals.projectId, projectId)).all();
|
|
3605
|
-
return rows.reduce((acc, r) => acc + (r.sessions ?? 0), 0);
|
|
3606
|
-
}
|
|
3607
|
-
function buildCandidateQueries(opts) {
|
|
3608
|
-
if (opts.candidateQueryStrings.length === 0 || opts.recentRunIds.length === 0) {
|
|
3609
|
-
return opts.candidateQueryStrings.map((query) => emptyCandidate(query));
|
|
3610
|
-
}
|
|
3611
|
-
const keywordRows = opts.db.select({ id: keywords.id, text: keywords.keyword }).from(keywords).where(eq15(keywords.projectId, opts.projectId)).all();
|
|
3612
|
-
const keywordIdByText = new Map(keywordRows.map((r) => [r.text, r.id]));
|
|
3613
|
-
const candidateKeywordIds = opts.candidateQueryStrings.map((q) => keywordIdByText.get(q)).filter((id) => Boolean(id));
|
|
3614
|
-
const snapshotRows = opts.db.select().from(querySnapshots).where(inArray4(querySnapshots.runId, opts.recentRunIds)).all().filter((r) => candidateKeywordIds.includes(r.keywordId));
|
|
3615
|
-
const snapshotsByKeyword = /* @__PURE__ */ new Map();
|
|
3616
|
-
for (const row of snapshotRows) {
|
|
3617
|
-
const list = snapshotsByKeyword.get(row.keywordId) ?? [];
|
|
3618
|
-
list.push(row);
|
|
3619
|
-
snapshotsByKeyword.set(row.keywordId, list);
|
|
3620
|
-
}
|
|
3621
|
-
const gscRows = opts.db.select().from(gscSearchData).where(eq15(gscSearchData.projectId, opts.projectId)).all();
|
|
3622
|
-
const gscByQuery = aggregateGscByQuery(gscRows);
|
|
3623
|
-
return opts.candidateQueryStrings.map((query) => {
|
|
3624
|
-
const keywordId = keywordIdByText.get(query);
|
|
3625
|
-
const snaps = keywordId ? snapshotsByKeyword.get(keywordId) ?? [] : [];
|
|
3626
|
-
const gsc = gscByQuery.get(query) ?? null;
|
|
3627
|
-
return aggregateCandidate({
|
|
3628
|
-
query,
|
|
3629
|
-
snapshots: snaps,
|
|
3630
|
-
gsc,
|
|
3631
|
-
ourDomains: opts.ourDomains,
|
|
3632
|
-
competitorSet: opts.competitorSet,
|
|
3633
|
-
latestRunId: opts.latestRunId
|
|
3634
|
-
});
|
|
3635
|
-
});
|
|
3636
|
-
}
|
|
3637
|
-
function aggregateGscByQuery(rows) {
|
|
3638
|
-
const byQuery = /* @__PURE__ */ new Map();
|
|
3639
|
-
for (const r of rows) {
|
|
3640
|
-
const existing = byQuery.get(r.query);
|
|
3641
|
-
const candidate = {
|
|
3642
|
-
// GSC stores `page` as a full URL for url-prefix properties; normalize to
|
|
3643
|
-
// a path so it can be joined against `gaTrafficByPage` (which is keyed by
|
|
3644
|
-
// path) and so `ourBestPage.url` / `targetRef` stay consistent regardless
|
|
3645
|
-
// of whether the page is sourced from GSC or from inventory.
|
|
3646
|
-
page: extractPath(r.page),
|
|
3647
|
-
position: Number(r.position) || 0,
|
|
3648
|
-
impressions: r.impressions,
|
|
3649
|
-
clicks: r.clicks,
|
|
3650
|
-
ctr: Number(r.ctr) || 0
|
|
3651
|
-
};
|
|
3652
|
-
if (!existing) {
|
|
3653
|
-
byQuery.set(r.query, candidate);
|
|
3654
|
-
continue;
|
|
3655
|
-
}
|
|
3656
|
-
if (candidate.impressions > existing.impressions) {
|
|
3657
|
-
byQuery.set(r.query, candidate);
|
|
3658
|
-
}
|
|
3659
|
-
}
|
|
3660
|
-
return byQuery;
|
|
3661
|
-
}
|
|
3662
|
-
function aggregateCandidate(opts) {
|
|
3663
|
-
const totalSnaps = opts.snapshots.length;
|
|
3664
|
-
if (totalSnaps === 0) {
|
|
3665
|
-
return {
|
|
3666
|
-
...emptyCandidate(opts.query),
|
|
3667
|
-
gscPage: opts.gsc?.page ?? null,
|
|
3668
|
-
gscPosition: opts.gsc ? opts.gsc.position : null,
|
|
3669
|
-
gscImpressions: opts.gsc?.impressions ?? 0,
|
|
3670
|
-
gscClicks: opts.gsc?.clicks ?? 0,
|
|
3671
|
-
gscCtr: opts.gsc?.ctr ?? 0
|
|
3672
|
-
};
|
|
3673
|
-
}
|
|
3674
|
-
const citedCount = opts.snapshots.filter((s) => s.citationState === CitationStates.cited).length;
|
|
3675
|
-
const ourCitedRate = citedCount / totalSnaps;
|
|
3676
|
-
const recentMissRate = 1 - ourCitedRate;
|
|
3677
|
-
const competitorTally = /* @__PURE__ */ new Map();
|
|
3678
|
-
const competitorGroundingTally = /* @__PURE__ */ new Map();
|
|
3679
|
-
const ourGroundingTally = /* @__PURE__ */ new Map();
|
|
3680
|
-
let ourCitedInLatestRun = false;
|
|
3681
|
-
for (const snap of opts.snapshots) {
|
|
3682
|
-
const isLatestRun = snap.runId === opts.latestRunId;
|
|
3683
|
-
const competitorOverlap = parseJsonColumn(snap.competitorOverlap, []);
|
|
3684
|
-
for (const domain of competitorOverlap) {
|
|
3685
|
-
const normalized = normalizeDomain2(domain);
|
|
3686
|
-
if (!opts.competitorSet.has(normalized)) continue;
|
|
3687
|
-
competitorTally.set(normalized, (competitorTally.get(normalized) ?? 0) + 1);
|
|
3688
|
-
}
|
|
3689
|
-
const grounding = extractGroundingSources(snap.rawResponse);
|
|
3690
|
-
for (const g of grounding) {
|
|
3691
|
-
const domain = normalizeDomain2(extractHostFromUri(g.uri));
|
|
3692
|
-
if (!domain) continue;
|
|
3693
|
-
if (opts.ourDomains.has(domain)) {
|
|
3694
|
-
if (isLatestRun) ourCitedInLatestRun = true;
|
|
3695
|
-
recordGroundingHit(ourGroundingTally, g, domain, snap.provider);
|
|
3696
|
-
continue;
|
|
3697
|
-
}
|
|
3698
|
-
if (!opts.competitorSet.has(domain)) continue;
|
|
3699
|
-
recordGroundingHit(competitorGroundingTally, g, domain, snap.provider);
|
|
3700
|
-
}
|
|
3701
|
-
}
|
|
3702
|
-
return {
|
|
3703
|
-
query: opts.query,
|
|
3704
|
-
gscPage: opts.gsc?.page ?? null,
|
|
3705
|
-
gscPosition: opts.gsc ? opts.gsc.position : null,
|
|
3706
|
-
gscImpressions: opts.gsc?.impressions ?? 0,
|
|
3707
|
-
gscClicks: opts.gsc?.clicks ?? 0,
|
|
3708
|
-
gscCtr: opts.gsc?.ctr ?? 0,
|
|
3709
|
-
ourCitedRate,
|
|
3710
|
-
ourCitedInLatestRun,
|
|
3711
|
-
competitorDomains: Array.from(competitorTally.keys()),
|
|
3712
|
-
competitorCitationCount: Array.from(competitorTally.values()).reduce((a, b) => a + b, 0),
|
|
3713
|
-
recentMissRate,
|
|
3714
|
-
ourGroundingUrls: Array.from(ourGroundingTally.values()),
|
|
3715
|
-
competitorGroundingUrls: Array.from(competitorGroundingTally.values()),
|
|
3716
|
-
runsOfHistory: new Set(opts.snapshots.map((s) => s.runId)).size
|
|
3717
|
-
};
|
|
3718
|
-
}
|
|
3719
|
-
function recordGroundingHit(tally, g, domain, provider) {
|
|
3720
|
-
const existing = tally.get(g.uri);
|
|
3721
|
-
if (existing) {
|
|
3722
|
-
existing.citationCount += 1;
|
|
3723
|
-
if (provider && !existing.providers.includes(provider)) {
|
|
3724
|
-
existing.providers.push(provider);
|
|
3725
|
-
}
|
|
3726
|
-
return;
|
|
3727
|
-
}
|
|
3728
|
-
tally.set(g.uri, {
|
|
3729
|
-
uri: g.uri,
|
|
3730
|
-
title: g.title,
|
|
3731
|
-
domain,
|
|
3732
|
-
citationCount: 1,
|
|
3733
|
-
providers: provider ? [provider] : []
|
|
3734
|
-
});
|
|
3735
|
-
}
|
|
3736
|
-
function emptyCandidate(query) {
|
|
3737
|
-
return {
|
|
3738
|
-
query,
|
|
3739
|
-
gscPage: null,
|
|
3740
|
-
gscPosition: null,
|
|
3741
|
-
gscImpressions: 0,
|
|
3742
|
-
gscClicks: 0,
|
|
3743
|
-
gscCtr: 0,
|
|
3744
|
-
ourCitedRate: 0,
|
|
3745
|
-
ourCitedInLatestRun: false,
|
|
3746
|
-
competitorDomains: [],
|
|
3747
|
-
competitorCitationCount: 0,
|
|
3748
|
-
recentMissRate: 0,
|
|
3749
|
-
ourGroundingUrls: [],
|
|
3750
|
-
competitorGroundingUrls: [],
|
|
3751
|
-
runsOfHistory: 0
|
|
3752
|
-
};
|
|
3753
|
-
}
|
|
3754
|
-
function extractGroundingSources(rawResponse) {
|
|
3755
|
-
if (!rawResponse) return [];
|
|
3756
|
-
try {
|
|
3757
|
-
const parsed = JSON.parse(rawResponse);
|
|
3758
|
-
if (parsed && typeof parsed === "object" && "groundingSources" in parsed) {
|
|
3759
|
-
const grounding = parsed.groundingSources;
|
|
3760
|
-
if (Array.isArray(grounding)) {
|
|
3761
|
-
return grounding.filter(
|
|
3762
|
-
(g) => typeof g === "object" && g !== null && typeof g.uri === "string"
|
|
3763
|
-
).map((g) => ({ uri: g.uri, title: g.title ?? "" }));
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
} catch {
|
|
3767
|
-
}
|
|
3768
|
-
return [];
|
|
3769
|
-
}
|
|
3770
|
-
function extractHostFromUri(uri) {
|
|
3771
|
-
try {
|
|
3772
|
-
return new URL(uri).hostname;
|
|
3773
|
-
} catch {
|
|
3774
|
-
return "";
|
|
3775
|
-
}
|
|
3776
|
-
}
|
|
3777
|
-
function normalizeDomain2(domain) {
|
|
3778
|
-
return domain.toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/$/, "");
|
|
3779
|
-
}
|
|
3780
|
-
function extractPath(url) {
|
|
3781
|
-
if (!url) return "";
|
|
3782
|
-
const match = /^https?:\/\/[^/]+(.*)$/.exec(url.trim());
|
|
3783
|
-
const path15 = match ? match[1] : url.trim();
|
|
3784
|
-
const stripped = path15.replace(/\/+$/, "");
|
|
3785
|
-
return stripped || "/";
|
|
3786
|
-
}
|
|
3787
|
-
|
|
3788
3867
|
// ../api-routes/src/content.ts
|
|
3789
3868
|
async function contentRoutes(app) {
|
|
3790
3869
|
app.get("/projects/:name/content/targets", async (request) => {
|
|
@@ -15753,7 +15832,7 @@ import crypto19 from "crypto";
|
|
|
15753
15832
|
import fs7 from "fs";
|
|
15754
15833
|
import path9 from "path";
|
|
15755
15834
|
import os4 from "os";
|
|
15756
|
-
import { and as and12, eq as eq23, inArray as
|
|
15835
|
+
import { and as and12, eq as eq23, inArray as inArray6, sql as sql7 } from "drizzle-orm";
|
|
15757
15836
|
|
|
15758
15837
|
// src/citation-utils.ts
|
|
15759
15838
|
function domainMatches(domain, canonicalDomain) {
|
|
@@ -16010,7 +16089,7 @@ var JobRunner = class {
|
|
|
16010
16089
|
this.registry = registry;
|
|
16011
16090
|
}
|
|
16012
16091
|
recoverStaleRuns() {
|
|
16013
|
-
const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(
|
|
16092
|
+
const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray6(runs.status, ["running", "queued"])).all();
|
|
16014
16093
|
if (stale.length === 0) return;
|
|
16015
16094
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
16016
16095
|
for (const run of stale) {
|
|
@@ -17429,7 +17508,7 @@ var Scheduler = class {
|
|
|
17429
17508
|
};
|
|
17430
17509
|
|
|
17431
17510
|
// src/notifier.ts
|
|
17432
|
-
import { eq as eq30, desc as desc14, and as and17, or as
|
|
17511
|
+
import { eq as eq30, desc as desc14, and as and17, or as or4 } from "drizzle-orm";
|
|
17433
17512
|
import crypto25 from "crypto";
|
|
17434
17513
|
var log9 = createLogger("Notifier");
|
|
17435
17514
|
var Notifier = class {
|
|
@@ -17537,7 +17616,7 @@ var Notifier = class {
|
|
|
17537
17616
|
const recentRuns = this.db.select().from(runs).where(
|
|
17538
17617
|
and17(
|
|
17539
17618
|
eq30(runs.projectId, projectId),
|
|
17540
|
-
|
|
17619
|
+
or4(eq30(runs.status, "completed"), eq30(runs.status, "partial"))
|
|
17541
17620
|
)
|
|
17542
17621
|
).orderBy(desc14(runs.createdAt)).limit(2).all();
|
|
17543
17622
|
if (recentRuns.length < 2) return [];
|