@ainyc/canonry 3.3.8 → 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.
@@ -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-24C7RMIS.js";
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 and3, desc as desc5, eq as eq12 } from "drizzle-orm";
2390
- var TOP_QUERIES_LIMIT = 20;
2391
- var TOP_LANDING_PAGES_LIMIT = 20;
2392
- var TOP_AI_REFERRAL_PAGES_LIMIT = 10;
2393
- var TOP_SOURCE_DOMAINS_LIMIT = 20;
2394
- var TOP_CAMPAIGN_LIMIT = 10;
2395
- function safeNum(value) {
2396
- if (typeof value === "number") return value;
2397
- if (typeof value === "string") {
2398
- const parsed = Number(value);
2399
- return Number.isFinite(parsed) ? parsed : 0;
2400
- }
2401
- return 0;
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 citedDomainBelongsToProject(citedDomain, projectDomains) {
2404
- const candidate = normalizeProjectDomain(citedDomain);
2405
- for (const domain of projectDomains) {
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 categorizeQuery(query, projectName, canonicalDomain) {
2412
- const lower = query.toLowerCase();
2413
- const projectTokens = [
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 loadSnapshotsForRun(db, runId) {
2427
- const rows = db.select().from(querySnapshots).where(eq12(querySnapshots.runId, runId)).all();
2428
- return rows.map((r) => ({
2429
- id: r.id,
2430
- runId: r.runId,
2431
- keywordId: r.keywordId,
2432
- provider: r.provider,
2433
- model: r.model,
2434
- citationState: r.citationState,
2435
- answerMentioned: r.answerMentioned,
2436
- answerText: r.answerText,
2437
- citedDomains: parseJsonColumn(r.citedDomains, []),
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 loadKeywordLookup(db, projectId) {
2443
- const rows = db.select().from(keywords).where(eq12(keywords.projectId, projectId)).all();
2444
- const byId = /* @__PURE__ */ new Map();
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 buildCitationScorecard(snapshots, keywordLookup) {
2449
- if (snapshots.length === 0) {
2450
- return { keywords: [], providers: [], matrix: [], providerRates: [] };
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
- const keywordSet = /* @__PURE__ */ new Set();
2453
- const providerSet = /* @__PURE__ */ new Set();
2454
- for (const snap of snapshots) {
2455
- const kw = keywordLookup.byId.get(snap.keywordId);
2456
- if (!kw) continue;
2457
- keywordSet.add(kw);
2458
- providerSet.add(snap.provider);
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 keywordList = [...keywordSet].sort();
2461
- const providerList = [...providerSet].sort();
2462
- const matrix = keywordList.map(
2463
- () => providerList.map(() => null)
2464
- );
2465
- const providerCounts = /* @__PURE__ */ new Map();
2466
- for (const snap of snapshots) {
2467
- const kw = keywordLookup.byId.get(snap.keywordId);
2468
- if (!kw) continue;
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 providerRates = providerList.map((provider) => {
2483
- const counts = providerCounts.get(provider) ?? { cited: 0, total: 0 };
2484
- const citationRate = counts.total > 0 ? Math.round(counts.cited / counts.total * 100) : 0;
2485
- return {
2486
- provider,
2487
- citedCount: counts.cited,
2488
- totalCount: counts.total,
2489
- citationRate
2490
- };
2491
- });
2492
- return { keywords: keywordList, providers: providerList, matrix, providerRates };
2493
- }
2494
- function buildCompetitorLandscape(snapshots, competitorDomains, projectDomains, keywordLookup) {
2495
- let projectCitationCount = 0;
2496
- const competitorMap = /* @__PURE__ */ new Map();
2497
- for (const c of competitorDomains) competitorMap.set(c, { count: 0, keywords: /* @__PURE__ */ new Set() });
2498
- for (const snap of snapshots) {
2499
- const kw = keywordLookup.byId.get(snap.keywordId);
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, projectName, canonicalDomain) {
2561
- const rows = db.select().from(gscSearchData).where(eq12(gscSearchData.projectId, projectId)).all();
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, projectName, canonicalDomain)
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, projectName, canonicalDomain);
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(eq12(gaTrafficSummaries.projectId, projectId)).orderBy(desc5(gaTrafficSummaries.syncedAt)).limit(1).get();
2619
- const snapshotRows = db.select().from(gaTrafficSnapshots).where(eq12(gaTrafficSnapshots.projectId, projectId)).all();
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(eq12(gaSocialReferrals.projectId, projectId)).all();
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(eq12(gaAiReferrals.projectId, projectId)).all();
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(eq12(gscCoverageSnapshots.projectId, projectId)).orderBy(desc5(gscCoverageSnapshots.date)).limit(1).get();
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(eq12(bingCoverageSnapshots.projectId, projectId)).orderBy(desc5(bingCoverageSnapshots.date)).limit(1).get();
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(and3(eq12(runs.projectId, projectId), eq12(runs.kind, RunKinds["answer-visibility"]))).all();
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 rows = db.select().from(insights).where(eq12(insights.projectId, projectId)).orderBy(desc5(insights.createdAt)).all();
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
- return rows.filter((r) => !r.dismissed).map((r) => {
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
- }).sort((a, b) => severityRank[a.severity] - severityRank[b.severity]);
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
- switch (trend) {
2883
- case "up":
2884
- detail = "Up from the previous run.";
2885
- break;
2886
- case "down":
2887
- detail = "Down from the previous run.";
2888
- break;
2889
- case "flat":
2890
- detail = "Flat compared to the previous run.";
2891
- break;
2892
- case "unknown":
2893
- detail = "No prior run to compare against.";
2894
- break;
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(eq12(runs.projectId, project.id)).orderBy(desc5(runs.createdAt)).all();
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(eq12(competitors.projectId, project.id)).all();
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 gscSection = buildGscSection(app.db, project.id, project.name, project.canonicalDomain);
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 recommendedNextSteps = buildRecommendedNextSteps(insightList);
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 (latestPoint && previousPoint) {
2962
- if (latestPoint.citationRate > previousPoint.citationRate) trend = "up";
2963
- else if (latestPoint.citationRate < previousPoint.citationRate) trend = "down";
2964
- else trend = "flat";
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 eq13, inArray as inArray3 } from "drizzle-orm";
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(eq13(keywords.projectId, project.id)).all();
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(eq13(runs.projectId, project.id)).all();
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(inArray3(querySnapshots.runId, projectRuns.map((r) => r.id))).all();
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(eq13(competitors.projectId, project.id)).all().map((c) => normalizeDomain(c.domain)).filter((d) => d.length > 0);
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) => normalizeDomain(d)).filter((d) => d.length > 0)
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 normalizeDomain(domain) {
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 eq14, and as and4, desc as desc6, sql as sql3, like, or as or2 } from "drizzle-orm";
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(eq14(runs.projectId, project.id)).get();
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(eq14(runs.projectId, project.id)).orderBy(desc6(runs.createdAt)).limit(2).all();
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(eq14(healthSnapshots.projectId, project.id)).orderBy(desc6(healthSnapshots.createdAt)).limit(1).get();
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(eq14(insights.projectId, project.id)).orderBy(desc6(insights.createdAt)).all();
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, eq14(querySnapshots.keywordId, keywords.id)).where(
3241
- and4(
3242
- eq14(keywords.projectId, project.id),
3243
- or2(
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(desc6(querySnapshots.createdAt)).limit(limit + 1).all();
3606
+ ).orderBy(desc7(querySnapshots.createdAt)).limit(limit + 1).all();
3251
3607
  const insightMatches = app.db.select().from(insights).where(
3252
- and4(
3253
- eq14(insights.projectId, project.id),
3254
- or2(
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(desc6(insights.createdAt)).limit(limit + 1).all();
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(eq14(querySnapshots.runId, run.id)).all();
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(eq14(querySnapshots.runId, runId)).all();
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 inArray5, sql as sql7 } from "drizzle-orm";
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(inArray5(runs.status, ["running", "queued"])).all();
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 or3 } from "drizzle-orm";
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
- or3(eq30(runs.status, "completed"), eq30(runs.status, "partial"))
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 [];