@ainyc/canonry 3.4.6 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/assets/index-BfwQqd05.css +1 -0
- package/assets/assets/index-CFtdvSnQ.js +302 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-UJRPODX3.js → chunk-5G7S6SEP.js} +1167 -143
- package/dist/cli.js +2 -999
- package/dist/index.js +1 -1
- package/package.json +9 -9
- package/assets/assets/index-C6qeFqvR.js +0 -302
- package/assets/assets/index-JG7aBJrz.css +0 -1
|
@@ -2396,6 +2396,999 @@ async function intelligenceRoutes(app) {
|
|
|
2396
2396
|
// ../api-routes/src/report.ts
|
|
2397
2397
|
import { and as and4, desc as desc6, eq as eq13, inArray as inArray4, or as or2 } from "drizzle-orm";
|
|
2398
2398
|
|
|
2399
|
+
// ../api-routes/src/report-renderer.ts
|
|
2400
|
+
var COLORS = {
|
|
2401
|
+
bg: "#09090b",
|
|
2402
|
+
surface: "#18181b4d",
|
|
2403
|
+
border: "#27272a99",
|
|
2404
|
+
text: "#fafafa",
|
|
2405
|
+
textMuted: "#a1a1aa",
|
|
2406
|
+
textFaint: "#71717a",
|
|
2407
|
+
positive: "#10b981",
|
|
2408
|
+
caution: "#f59e0b",
|
|
2409
|
+
negative: "#f43f5e",
|
|
2410
|
+
neutral: "#71717a",
|
|
2411
|
+
accent: "#3b82f6",
|
|
2412
|
+
series: ["#10b981", "#3b82f6", "#ec4899", "#eab308", "#a855f7", "#f97316", "#06b6d4", "#ef4444"]
|
|
2413
|
+
};
|
|
2414
|
+
function escapeHtml(value) {
|
|
2415
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2416
|
+
}
|
|
2417
|
+
function formatRatio(value) {
|
|
2418
|
+
if (!Number.isFinite(value) || value === 0) return "0%";
|
|
2419
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
2420
|
+
}
|
|
2421
|
+
function formatNumber(value) {
|
|
2422
|
+
if (!Number.isFinite(value)) return "\u2014";
|
|
2423
|
+
if (Math.abs(value) >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
|
|
2424
|
+
if (Math.abs(value) >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
|
|
2425
|
+
return value.toLocaleString("en-US");
|
|
2426
|
+
}
|
|
2427
|
+
function summarizeQueryParams(params) {
|
|
2428
|
+
const keys = Array.from(params.keys());
|
|
2429
|
+
const total = keys.length;
|
|
2430
|
+
if (total === 0) return "";
|
|
2431
|
+
const noun = total === 1 ? "param" : "params";
|
|
2432
|
+
const tag = inferAdSource(params);
|
|
2433
|
+
return tag ? `${tag} \xB7 ${total} ${noun}` : `${total} tracking ${noun}`;
|
|
2434
|
+
}
|
|
2435
|
+
function inferAdSource(params) {
|
|
2436
|
+
if (params.has("fbclid")) return "Facebook Ad";
|
|
2437
|
+
if (params.has("gclid") || params.has("gbraid") || params.has("wbraid")) return "Google Ad";
|
|
2438
|
+
if (params.has("msclkid")) return "Microsoft Ad";
|
|
2439
|
+
if (params.has("ttclid")) return "TikTok Ad";
|
|
2440
|
+
if (params.has("li_fat_id")) return "LinkedIn Ad";
|
|
2441
|
+
if (params.has("twclid")) return "X / Twitter Ad";
|
|
2442
|
+
if (params.has("epik")) return "Pinterest Ad";
|
|
2443
|
+
for (const k of params.keys()) {
|
|
2444
|
+
if (k.startsWith("hsa_")) return "Search Ad";
|
|
2445
|
+
}
|
|
2446
|
+
const src = params.get("utm_source");
|
|
2447
|
+
const med = params.get("utm_medium");
|
|
2448
|
+
if (src && med) return `${src} / ${med}`;
|
|
2449
|
+
if (src) return `Source: ${src}`;
|
|
2450
|
+
if (med) return `Medium: ${med}`;
|
|
2451
|
+
return null;
|
|
2452
|
+
}
|
|
2453
|
+
function formatLandingPageHtml(raw) {
|
|
2454
|
+
const value = raw ?? "";
|
|
2455
|
+
const queryIdx = value.indexOf("?");
|
|
2456
|
+
const path15 = queryIdx === -1 ? value : value.slice(0, queryIdx);
|
|
2457
|
+
const query = queryIdx === -1 ? "" : value.slice(queryIdx + 1);
|
|
2458
|
+
const pathHtml = `<span class="page-path">${escapeHtml(path15 || "/")}</span>`;
|
|
2459
|
+
if (!query) return pathHtml;
|
|
2460
|
+
let summary = "";
|
|
2461
|
+
try {
|
|
2462
|
+
summary = summarizeQueryParams(new URLSearchParams(query));
|
|
2463
|
+
} catch {
|
|
2464
|
+
summary = "tracking params";
|
|
2465
|
+
}
|
|
2466
|
+
if (!summary) return pathHtml;
|
|
2467
|
+
return `${pathHtml}<span class="page-query" title="${escapeHtml(value)}">${escapeHtml(summary)}</span>`;
|
|
2468
|
+
}
|
|
2469
|
+
function formatDate(iso) {
|
|
2470
|
+
if (!iso) return "\u2014";
|
|
2471
|
+
try {
|
|
2472
|
+
const d = new Date(iso);
|
|
2473
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
2474
|
+
} catch {
|
|
2475
|
+
return iso;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
function pressureTone(label) {
|
|
2479
|
+
if (label === "High") return "negative";
|
|
2480
|
+
if (label === "Moderate") return "caution";
|
|
2481
|
+
if (label === "Low") return "positive";
|
|
2482
|
+
return "neutral";
|
|
2483
|
+
}
|
|
2484
|
+
function severityTone(severity) {
|
|
2485
|
+
switch (severity) {
|
|
2486
|
+
case "critical":
|
|
2487
|
+
return "negative";
|
|
2488
|
+
case "high":
|
|
2489
|
+
return "negative";
|
|
2490
|
+
case "medium":
|
|
2491
|
+
return "caution";
|
|
2492
|
+
case "low":
|
|
2493
|
+
return "neutral";
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
var STYLE = `
|
|
2497
|
+
:root {
|
|
2498
|
+
color-scheme: dark;
|
|
2499
|
+
}
|
|
2500
|
+
* { box-sizing: border-box; }
|
|
2501
|
+
html, body { margin: 0; padding: 0; }
|
|
2502
|
+
body {
|
|
2503
|
+
background: ${COLORS.bg};
|
|
2504
|
+
color: ${COLORS.text};
|
|
2505
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
2506
|
+
font-size: 14px;
|
|
2507
|
+
line-height: 1.5;
|
|
2508
|
+
-webkit-font-smoothing: antialiased;
|
|
2509
|
+
}
|
|
2510
|
+
.container {
|
|
2511
|
+
max-width: 1100px;
|
|
2512
|
+
margin: 0 auto;
|
|
2513
|
+
padding: 48px 24px 96px;
|
|
2514
|
+
}
|
|
2515
|
+
.header {
|
|
2516
|
+
border-bottom: 1px solid ${COLORS.border};
|
|
2517
|
+
padding-bottom: 32px;
|
|
2518
|
+
margin-bottom: 48px;
|
|
2519
|
+
}
|
|
2520
|
+
.header h1 {
|
|
2521
|
+
font-size: 32px;
|
|
2522
|
+
font-weight: 700;
|
|
2523
|
+
margin: 0 0 8px;
|
|
2524
|
+
letter-spacing: -0.02em;
|
|
2525
|
+
}
|
|
2526
|
+
.header .subtitle {
|
|
2527
|
+
color: ${COLORS.textMuted};
|
|
2528
|
+
font-size: 14px;
|
|
2529
|
+
}
|
|
2530
|
+
.eyebrow {
|
|
2531
|
+
text-transform: uppercase;
|
|
2532
|
+
letter-spacing: 0.08em;
|
|
2533
|
+
font-size: 10px;
|
|
2534
|
+
color: ${COLORS.textFaint};
|
|
2535
|
+
font-weight: 600;
|
|
2536
|
+
margin-bottom: 8px;
|
|
2537
|
+
}
|
|
2538
|
+
section.report-section {
|
|
2539
|
+
margin: 64px 0;
|
|
2540
|
+
}
|
|
2541
|
+
section.report-section h2 {
|
|
2542
|
+
font-size: 22px;
|
|
2543
|
+
font-weight: 700;
|
|
2544
|
+
margin: 0 0 24px;
|
|
2545
|
+
letter-spacing: -0.01em;
|
|
2546
|
+
}
|
|
2547
|
+
section.report-section .section-intro {
|
|
2548
|
+
color: ${COLORS.textMuted};
|
|
2549
|
+
margin-bottom: 24px;
|
|
2550
|
+
}
|
|
2551
|
+
.metric-grid {
|
|
2552
|
+
display: grid;
|
|
2553
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
2554
|
+
gap: 16px;
|
|
2555
|
+
}
|
|
2556
|
+
.metric {
|
|
2557
|
+
background: ${COLORS.surface};
|
|
2558
|
+
border: 1px solid ${COLORS.border};
|
|
2559
|
+
border-radius: 8px;
|
|
2560
|
+
padding: 16px 20px;
|
|
2561
|
+
}
|
|
2562
|
+
.metric .label {
|
|
2563
|
+
text-transform: uppercase;
|
|
2564
|
+
letter-spacing: 0.08em;
|
|
2565
|
+
font-size: 10px;
|
|
2566
|
+
color: ${COLORS.textFaint};
|
|
2567
|
+
font-weight: 600;
|
|
2568
|
+
margin-bottom: 8px;
|
|
2569
|
+
}
|
|
2570
|
+
.metric .value {
|
|
2571
|
+
font-size: 28px;
|
|
2572
|
+
font-weight: 700;
|
|
2573
|
+
letter-spacing: -0.02em;
|
|
2574
|
+
}
|
|
2575
|
+
.metric .delta {
|
|
2576
|
+
font-size: 12px;
|
|
2577
|
+
color: ${COLORS.textMuted};
|
|
2578
|
+
margin-top: 4px;
|
|
2579
|
+
}
|
|
2580
|
+
.findings {
|
|
2581
|
+
margin-top: 24px;
|
|
2582
|
+
display: grid;
|
|
2583
|
+
gap: 12px;
|
|
2584
|
+
}
|
|
2585
|
+
.finding {
|
|
2586
|
+
background: ${COLORS.surface};
|
|
2587
|
+
border: 1px solid ${COLORS.border};
|
|
2588
|
+
border-left-width: 3px;
|
|
2589
|
+
border-radius: 6px;
|
|
2590
|
+
padding: 12px 16px;
|
|
2591
|
+
}
|
|
2592
|
+
.finding.tone-positive { border-left-color: ${COLORS.positive}; }
|
|
2593
|
+
.finding.tone-caution { border-left-color: ${COLORS.caution}; }
|
|
2594
|
+
.finding.tone-negative { border-left-color: ${COLORS.negative}; }
|
|
2595
|
+
.finding.tone-neutral { border-left-color: ${COLORS.neutral}; }
|
|
2596
|
+
.finding strong { display: block; margin-bottom: 4px; }
|
|
2597
|
+
.finding span { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2598
|
+
table.report-table {
|
|
2599
|
+
width: 100%;
|
|
2600
|
+
border-collapse: collapse;
|
|
2601
|
+
font-size: 13px;
|
|
2602
|
+
}
|
|
2603
|
+
table.report-table th, table.report-table td {
|
|
2604
|
+
text-align: left;
|
|
2605
|
+
padding: 10px 12px;
|
|
2606
|
+
border-bottom: 1px solid ${COLORS.border};
|
|
2607
|
+
vertical-align: top;
|
|
2608
|
+
overflow-wrap: anywhere;
|
|
2609
|
+
word-break: break-word;
|
|
2610
|
+
}
|
|
2611
|
+
table.report-table th {
|
|
2612
|
+
font-weight: 600;
|
|
2613
|
+
color: ${COLORS.textMuted};
|
|
2614
|
+
text-transform: uppercase;
|
|
2615
|
+
letter-spacing: 0.06em;
|
|
2616
|
+
font-size: 10px;
|
|
2617
|
+
}
|
|
2618
|
+
table.report-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
2619
|
+
table.report-table td.page-cell { max-width: 0; }
|
|
2620
|
+
table.report-table td.page-cell .page-path {
|
|
2621
|
+
display: block;
|
|
2622
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
2623
|
+
font-size: 12px;
|
|
2624
|
+
color: ${COLORS.text};
|
|
2625
|
+
}
|
|
2626
|
+
table.report-table td.page-cell .page-query {
|
|
2627
|
+
display: inline-block;
|
|
2628
|
+
margin-top: 4px;
|
|
2629
|
+
padding: 1px 8px;
|
|
2630
|
+
font-size: 11px;
|
|
2631
|
+
color: ${COLORS.textMuted};
|
|
2632
|
+
background: ${COLORS.surface};
|
|
2633
|
+
border: 1px solid ${COLORS.border};
|
|
2634
|
+
border-radius: 999px;
|
|
2635
|
+
cursor: help;
|
|
2636
|
+
}
|
|
2637
|
+
table.report-table td .badge {
|
|
2638
|
+
display: inline-block;
|
|
2639
|
+
padding: 2px 8px;
|
|
2640
|
+
border-radius: 999px;
|
|
2641
|
+
font-size: 11px;
|
|
2642
|
+
font-weight: 600;
|
|
2643
|
+
border: 1px solid;
|
|
2644
|
+
}
|
|
2645
|
+
.cell-cited { color: ${COLORS.positive}; font-weight: 600; }
|
|
2646
|
+
.cell-not-cited { color: ${COLORS.textFaint}; }
|
|
2647
|
+
.cell-pending { color: ${COLORS.textFaint}; font-style: italic; }
|
|
2648
|
+
.tone-positive { color: ${COLORS.positive}; }
|
|
2649
|
+
.tone-caution { color: ${COLORS.caution}; }
|
|
2650
|
+
.tone-negative { color: ${COLORS.negative}; }
|
|
2651
|
+
.tone-neutral { color: ${COLORS.neutral}; }
|
|
2652
|
+
.badge.tone-positive { color: ${COLORS.positive}; border-color: ${COLORS.positive}40; background: ${COLORS.positive}14; }
|
|
2653
|
+
.badge.tone-caution { color: ${COLORS.caution}; border-color: ${COLORS.caution}40; background: ${COLORS.caution}14; }
|
|
2654
|
+
.badge.tone-negative { color: ${COLORS.negative}; border-color: ${COLORS.negative}40; background: ${COLORS.negative}14; }
|
|
2655
|
+
.badge.tone-neutral { color: ${COLORS.textMuted}; border-color: ${COLORS.border}; background: transparent; }
|
|
2656
|
+
.chart-card {
|
|
2657
|
+
background: ${COLORS.surface};
|
|
2658
|
+
border: 1px solid ${COLORS.border};
|
|
2659
|
+
border-radius: 8px;
|
|
2660
|
+
padding: 20px;
|
|
2661
|
+
margin-bottom: 16px;
|
|
2662
|
+
}
|
|
2663
|
+
.chart-card h3 {
|
|
2664
|
+
font-size: 14px;
|
|
2665
|
+
font-weight: 600;
|
|
2666
|
+
margin: 0 0 16px;
|
|
2667
|
+
}
|
|
2668
|
+
.chart-grid {
|
|
2669
|
+
display: grid;
|
|
2670
|
+
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
|
2671
|
+
gap: 16px;
|
|
2672
|
+
}
|
|
2673
|
+
.legend {
|
|
2674
|
+
display: flex;
|
|
2675
|
+
flex-wrap: wrap;
|
|
2676
|
+
gap: 12px;
|
|
2677
|
+
font-size: 12px;
|
|
2678
|
+
margin-top: 12px;
|
|
2679
|
+
}
|
|
2680
|
+
.legend-swatch {
|
|
2681
|
+
display: inline-block;
|
|
2682
|
+
width: 10px;
|
|
2683
|
+
height: 10px;
|
|
2684
|
+
border-radius: 2px;
|
|
2685
|
+
margin-right: 6px;
|
|
2686
|
+
vertical-align: middle;
|
|
2687
|
+
}
|
|
2688
|
+
.empty-state {
|
|
2689
|
+
background: ${COLORS.surface};
|
|
2690
|
+
border: 1px dashed ${COLORS.border};
|
|
2691
|
+
border-radius: 8px;
|
|
2692
|
+
padding: 32px;
|
|
2693
|
+
color: ${COLORS.textMuted};
|
|
2694
|
+
text-align: center;
|
|
2695
|
+
font-size: 13px;
|
|
2696
|
+
}
|
|
2697
|
+
.steps {
|
|
2698
|
+
display: grid;
|
|
2699
|
+
gap: 12px;
|
|
2700
|
+
}
|
|
2701
|
+
.step {
|
|
2702
|
+
background: ${COLORS.surface};
|
|
2703
|
+
border: 1px solid ${COLORS.border};
|
|
2704
|
+
border-radius: 8px;
|
|
2705
|
+
padding: 16px 20px;
|
|
2706
|
+
display: grid;
|
|
2707
|
+
gap: 4px;
|
|
2708
|
+
}
|
|
2709
|
+
.step .horizon {
|
|
2710
|
+
text-transform: uppercase;
|
|
2711
|
+
font-size: 10px;
|
|
2712
|
+
letter-spacing: 0.08em;
|
|
2713
|
+
color: ${COLORS.textFaint};
|
|
2714
|
+
font-weight: 600;
|
|
2715
|
+
}
|
|
2716
|
+
.step .title { font-weight: 600; }
|
|
2717
|
+
.step .rationale { color: ${COLORS.textMuted}; font-size: 13px; }
|
|
2718
|
+
.footer {
|
|
2719
|
+
margin-top: 96px;
|
|
2720
|
+
padding-top: 24px;
|
|
2721
|
+
border-top: 1px solid ${COLORS.border};
|
|
2722
|
+
text-align: center;
|
|
2723
|
+
color: ${COLORS.textFaint};
|
|
2724
|
+
font-size: 12px;
|
|
2725
|
+
}
|
|
2726
|
+
@media print {
|
|
2727
|
+
body { background: white; color: black; }
|
|
2728
|
+
section.report-section { break-inside: avoid; }
|
|
2729
|
+
}
|
|
2730
|
+
`;
|
|
2731
|
+
function section(opts, body) {
|
|
2732
|
+
return `<section class="report-section" id="${escapeHtml(opts.id)}">
|
|
2733
|
+
<div class="eyebrow">${escapeHtml(opts.eyebrow)}</div>
|
|
2734
|
+
<h2>${escapeHtml(opts.title)}</h2>
|
|
2735
|
+
${opts.intro ? `<p class="section-intro">${escapeHtml(opts.intro)}</p>` : ""}
|
|
2736
|
+
${body}
|
|
2737
|
+
</section>`;
|
|
2738
|
+
}
|
|
2739
|
+
function renderEmpty(message) {
|
|
2740
|
+
return `<div class="empty-state">${escapeHtml(message)}</div>`;
|
|
2741
|
+
}
|
|
2742
|
+
function renderExecutiveSummary(report) {
|
|
2743
|
+
const s = report.executiveSummary;
|
|
2744
|
+
const trendLabel = s.trend === "up" ? "\u2191 Up" : s.trend === "down" ? "\u2193 Down" : s.trend === "flat" ? "\u2192 Flat" : "\u2014";
|
|
2745
|
+
const trendTone = s.trend === "up" ? "positive" : s.trend === "down" ? "negative" : "neutral";
|
|
2746
|
+
const metrics = [
|
|
2747
|
+
{
|
|
2748
|
+
label: "Citation rate",
|
|
2749
|
+
value: `${s.citationRate}%`,
|
|
2750
|
+
delta: `<span class="tone-${trendTone}">${trendLabel}</span> \xB7 ${s.providerCount} provider${s.providerCount === 1 ? "" : "s"}`
|
|
2751
|
+
},
|
|
2752
|
+
{
|
|
2753
|
+
label: "Keywords tracked",
|
|
2754
|
+
value: formatNumber(s.keywordCount),
|
|
2755
|
+
delta: `${s.competitorCount} competitor${s.competitorCount === 1 ? "" : "s"} tracked`
|
|
2756
|
+
}
|
|
2757
|
+
];
|
|
2758
|
+
if (s.gsc) {
|
|
2759
|
+
metrics.push({
|
|
2760
|
+
label: "GSC clicks",
|
|
2761
|
+
value: formatNumber(s.gsc.clicks),
|
|
2762
|
+
delta: `${formatNumber(s.gsc.impressions)} imp \xB7 ${formatRatio(s.gsc.ctr)} CTR`
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
if (s.ga) {
|
|
2766
|
+
metrics.push({
|
|
2767
|
+
label: "GA sessions",
|
|
2768
|
+
value: formatNumber(s.ga.sessions),
|
|
2769
|
+
delta: `${formatNumber(s.ga.users)} users \xB7 ${formatDate(s.ga.periodStart)} \u2192 ${formatDate(s.ga.periodEnd)}`
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
const metricsHtml = `<div class="metric-grid">
|
|
2773
|
+
${metrics.map((m) => `<div class="metric">
|
|
2774
|
+
<div class="label">${escapeHtml(m.label)}</div>
|
|
2775
|
+
<div class="value">${m.value}</div>
|
|
2776
|
+
<div class="delta">${m.delta}</div>
|
|
2777
|
+
</div>`).join("")}
|
|
2778
|
+
</div>`;
|
|
2779
|
+
const findingsHtml = s.findings.length > 0 ? `<div class="findings">${s.findings.map((f) => `
|
|
2780
|
+
<div class="finding tone-${f.tone}">
|
|
2781
|
+
<strong>${escapeHtml(f.title)}</strong>
|
|
2782
|
+
<span>${escapeHtml(f.detail)}</span>
|
|
2783
|
+
</div>`).join("")}</div>` : "";
|
|
2784
|
+
return section(
|
|
2785
|
+
{ id: "executive-summary", eyebrow: "Section 1", title: "Executive Summary" },
|
|
2786
|
+
metricsHtml + findingsHtml
|
|
2787
|
+
);
|
|
2788
|
+
}
|
|
2789
|
+
function renderProviderBars(rates) {
|
|
2790
|
+
if (rates.length === 0) return "";
|
|
2791
|
+
const max = Math.max(...rates.map((r) => r.citationRate), 100);
|
|
2792
|
+
const width = 600;
|
|
2793
|
+
const height = Math.max(rates.length * 32 + 24, 80);
|
|
2794
|
+
const labelWidth = 80;
|
|
2795
|
+
const padding = 8;
|
|
2796
|
+
const barWidth = width - labelWidth - padding * 2;
|
|
2797
|
+
const bars = rates.map((r, i) => {
|
|
2798
|
+
const y = i * 32 + padding;
|
|
2799
|
+
const barHeight = 22;
|
|
2800
|
+
const w = max > 0 ? r.citationRate / max * barWidth : 0;
|
|
2801
|
+
const color = COLORS.series[i % COLORS.series.length];
|
|
2802
|
+
return `
|
|
2803
|
+
<text x="${labelWidth - 8}" y="${y + 16}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(r.provider)}</text>
|
|
2804
|
+
<rect x="${labelWidth}" y="${y}" width="${barWidth}" height="${barHeight}" fill="${COLORS.border}" opacity="0.4" rx="3" />
|
|
2805
|
+
<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
|
|
2806
|
+
<text x="${labelWidth + w + 6}" y="${y + 16}" fill="${COLORS.text}" font-size="11">${r.citationRate}% (${r.citedCount}/${r.totalCount})</text>`;
|
|
2807
|
+
}).join("");
|
|
2808
|
+
return `<div class="chart-card">
|
|
2809
|
+
<h3>Provider citation rate</h3>
|
|
2810
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Provider citation rate bar chart">
|
|
2811
|
+
${bars}
|
|
2812
|
+
</svg>
|
|
2813
|
+
</div>`;
|
|
2814
|
+
}
|
|
2815
|
+
function renderCitationMatrix(scorecard) {
|
|
2816
|
+
if (scorecard.keywords.length === 0 || scorecard.providers.length === 0) {
|
|
2817
|
+
return renderEmpty("Run a visibility sweep to populate the citation matrix.");
|
|
2818
|
+
}
|
|
2819
|
+
const headers = scorecard.providers.map((p) => `<th>${escapeHtml(p)}</th>`).join("");
|
|
2820
|
+
const rows = scorecard.keywords.map((kw, ki) => {
|
|
2821
|
+
const cells = scorecard.providers.map((_, pi) => {
|
|
2822
|
+
const cell = scorecard.matrix[ki]?.[pi];
|
|
2823
|
+
if (!cell) {
|
|
2824
|
+
return '<td><span class="cell-pending">\u2014</span></td>';
|
|
2825
|
+
}
|
|
2826
|
+
if (cell.citationState === "cited") {
|
|
2827
|
+
return '<td><span class="cell-cited">Cited</span></td>';
|
|
2828
|
+
}
|
|
2829
|
+
return '<td><span class="cell-not-cited">Not cited</span></td>';
|
|
2830
|
+
}).join("");
|
|
2831
|
+
return `<tr><td>${escapeHtml(kw)}</td>${cells}</tr>`;
|
|
2832
|
+
}).join("");
|
|
2833
|
+
return `<table class="report-table">
|
|
2834
|
+
<thead><tr><th>Keyword</th>${headers}</tr></thead>
|
|
2835
|
+
<tbody>${rows}</tbody>
|
|
2836
|
+
</table>`;
|
|
2837
|
+
}
|
|
2838
|
+
function renderCitationScorecard(report) {
|
|
2839
|
+
const body = `
|
|
2840
|
+
${renderProviderBars(report.citationScorecard.providerRates)}
|
|
2841
|
+
${renderCitationMatrix(report.citationScorecard)}
|
|
2842
|
+
`;
|
|
2843
|
+
return section(
|
|
2844
|
+
{ id: "citation-scorecard", eyebrow: "Section 2", title: "Citation Scorecard", intro: "Per-keyword \xD7 per-provider citation matrix from the latest visibility sweep." },
|
|
2845
|
+
body
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
function renderCompetitorBars(landscape, canonical) {
|
|
2849
|
+
const data = [
|
|
2850
|
+
{ label: canonical, count: landscape.projectCitationCount, isProject: true },
|
|
2851
|
+
...landscape.competitors.map((c) => ({ label: c.domain, count: c.citationCount, isProject: false }))
|
|
2852
|
+
];
|
|
2853
|
+
if (data.length <= 1) return "";
|
|
2854
|
+
const max = Math.max(...data.map((d) => d.count), 1);
|
|
2855
|
+
const width = 600;
|
|
2856
|
+
const height = data.length * 28 + 16;
|
|
2857
|
+
const labelWidth = 160;
|
|
2858
|
+
const bars = data.map((d, i) => {
|
|
2859
|
+
const y = i * 28 + 8;
|
|
2860
|
+
const barHeight = 18;
|
|
2861
|
+
const w = d.count / max * (width - labelWidth - 60);
|
|
2862
|
+
const color = d.isProject ? COLORS.accent : COLORS.series[(i + 1) % COLORS.series.length];
|
|
2863
|
+
return `
|
|
2864
|
+
<text x="${labelWidth - 8}" y="${y + 13}" fill="${COLORS.textMuted}" font-size="11" text-anchor="end">${escapeHtml(d.label)}</text>
|
|
2865
|
+
<rect x="${labelWidth}" y="${y}" width="${w}" height="${barHeight}" fill="${color}" rx="3" />
|
|
2866
|
+
<text x="${labelWidth + w + 6}" y="${y + 13}" fill="${COLORS.text}" font-size="11">${d.count}</text>`;
|
|
2867
|
+
}).join("");
|
|
2868
|
+
return `<div class="chart-card">
|
|
2869
|
+
<h3>Citations per domain</h3>
|
|
2870
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Citations per domain bar chart">
|
|
2871
|
+
${bars}
|
|
2872
|
+
</svg>
|
|
2873
|
+
</div>`;
|
|
2874
|
+
}
|
|
2875
|
+
function renderCompetitorLandscape(report) {
|
|
2876
|
+
const competitors2 = report.competitorLandscape.competitors;
|
|
2877
|
+
if (competitors2.length === 0 && report.competitorLandscape.projectCitationCount === 0) {
|
|
2878
|
+
return section(
|
|
2879
|
+
{ id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape" },
|
|
2880
|
+
renderEmpty("No competitor data yet. Add competitors and run a visibility sweep.")
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
const rows = competitors2.map((c) => {
|
|
2884
|
+
const tone = pressureTone(c.pressureLabel);
|
|
2885
|
+
const pagesDisclosure = c.theirCitedPages.length > 0 ? `<details class="cited-pages"><summary>${c.theirCitedPages.length} cited URL${c.theirCitedPages.length > 1 ? "s" : ""}</summary>
|
|
2886
|
+
<ul>${c.theirCitedPages.map((p) => `<li><a href="${escapeHtml(p.url)}">${escapeHtml(p.url)}</a> <span class="cited-for">${escapeHtml(p.citedFor.join(", "))}</span></li>`).join("")}</ul>
|
|
2887
|
+
</details>` : "";
|
|
2888
|
+
return `<tr>
|
|
2889
|
+
<td>${escapeHtml(c.domain)}</td>
|
|
2890
|
+
<td><span class="badge tone-${tone}">${escapeHtml(c.pressureLabel)}</span></td>
|
|
2891
|
+
<td class="numeric">${c.citationCount} / ${c.totalCount}</td>
|
|
2892
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
2893
|
+
<td>${escapeHtml(c.citedKeywords.slice(0, 5).join(", "))}${c.citedKeywords.length > 5 ? "\u2026" : ""}${pagesDisclosure}</td>
|
|
2894
|
+
</tr>`;
|
|
2895
|
+
}).join("");
|
|
2896
|
+
const table = competitors2.length > 0 ? `<table class="report-table">
|
|
2897
|
+
<thead><tr><th>Domain</th><th>Pressure</th><th>Citations</th><th class="numeric">SOV</th><th>Cited keywords</th></tr></thead>
|
|
2898
|
+
<tbody>${rows}</tbody>
|
|
2899
|
+
</table>` : renderEmpty("No competitors configured.");
|
|
2900
|
+
return section(
|
|
2901
|
+
{ id: "competitor-landscape", eyebrow: "Section 3", title: "Competitor Landscape", intro: "Where tracked competitors appear in AI answers compared to your domain." },
|
|
2902
|
+
`${renderCompetitorBars(report.competitorLandscape, report.meta.project.canonicalDomain)}${table}`
|
|
2903
|
+
);
|
|
2904
|
+
}
|
|
2905
|
+
function renderDonut(buckets) {
|
|
2906
|
+
if (buckets.length === 0) return "";
|
|
2907
|
+
const total = buckets.reduce((s, b) => s + b.count, 0);
|
|
2908
|
+
if (total === 0) return "";
|
|
2909
|
+
const cx = 110;
|
|
2910
|
+
const cy = 110;
|
|
2911
|
+
const r = 80;
|
|
2912
|
+
const innerR = 48;
|
|
2913
|
+
let cumulative = 0;
|
|
2914
|
+
const slices = [];
|
|
2915
|
+
const legend = [];
|
|
2916
|
+
buckets.forEach((b, i) => {
|
|
2917
|
+
const startAngle = cumulative / total * Math.PI * 2 - Math.PI / 2;
|
|
2918
|
+
const endAngle = (cumulative + b.count) / total * Math.PI * 2 - Math.PI / 2;
|
|
2919
|
+
cumulative += b.count;
|
|
2920
|
+
const x1 = cx + Math.cos(startAngle) * r;
|
|
2921
|
+
const y1 = cy + Math.sin(startAngle) * r;
|
|
2922
|
+
const x2 = cx + Math.cos(endAngle) * r;
|
|
2923
|
+
const y2 = cy + Math.sin(endAngle) * r;
|
|
2924
|
+
const ix1 = cx + Math.cos(endAngle) * innerR;
|
|
2925
|
+
const iy1 = cy + Math.sin(endAngle) * innerR;
|
|
2926
|
+
const ix2 = cx + Math.cos(startAngle) * innerR;
|
|
2927
|
+
const iy2 = cy + Math.sin(startAngle) * innerR;
|
|
2928
|
+
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
|
2929
|
+
const color = COLORS.series[i % COLORS.series.length];
|
|
2930
|
+
if (b.count > 0) {
|
|
2931
|
+
slices.push(`<path d="M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z" fill="${color}" />`);
|
|
2932
|
+
legend.push(`<span><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(b.label)} (${b.count})</span>`);
|
|
2933
|
+
}
|
|
2934
|
+
});
|
|
2935
|
+
return `<div class="chart-card">
|
|
2936
|
+
<h3>AI source categories</h3>
|
|
2937
|
+
<div style="display:flex;align-items:center;gap:24px;flex-wrap:wrap;">
|
|
2938
|
+
<svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="AI source category donut chart">
|
|
2939
|
+
${slices.join("")}
|
|
2940
|
+
</svg>
|
|
2941
|
+
<div class="legend" style="flex-direction:column;align-items:flex-start;gap:6px;">${legend.join("")}</div>
|
|
2942
|
+
</div>
|
|
2943
|
+
</div>`;
|
|
2944
|
+
}
|
|
2945
|
+
function renderAiSourceOrigin(report) {
|
|
2946
|
+
const origin = report.aiSourceOrigin;
|
|
2947
|
+
if (origin.categories.length === 0 && origin.topDomains.length === 0) {
|
|
2948
|
+
return section(
|
|
2949
|
+
{ id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin" },
|
|
2950
|
+
renderEmpty("No source data yet. Run a visibility sweep first.")
|
|
2951
|
+
);
|
|
2952
|
+
}
|
|
2953
|
+
const rows = origin.topDomains.map((d) => `
|
|
2954
|
+
<tr>
|
|
2955
|
+
<td>${escapeHtml(d.domain)}</td>
|
|
2956
|
+
<td class="numeric">${d.count}</td>
|
|
2957
|
+
<td>${d.isCompetitor ? '<span class="badge tone-negative">Competitor</span>' : '<span class="badge tone-neutral">External</span>'}</td>
|
|
2958
|
+
</tr>`).join("");
|
|
2959
|
+
const table = origin.topDomains.length > 0 ? `<table class="report-table">
|
|
2960
|
+
<thead><tr><th>Domain</th><th>Citations</th><th>Tag</th></tr></thead>
|
|
2961
|
+
<tbody>${rows}</tbody>
|
|
2962
|
+
</table>` : "";
|
|
2963
|
+
return section(
|
|
2964
|
+
{ id: "ai-source-origin", eyebrow: "Section 4", title: "AI Source Origin", intro: "Where AI answers pull from, aggregated across the latest sweep." },
|
|
2965
|
+
`${renderDonut(origin.categories)}${table}`
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
function renderLineChart(points, color, title, height = 200) {
|
|
2969
|
+
if (points.length === 0) return "";
|
|
2970
|
+
const width = 600;
|
|
2971
|
+
const padX = 32;
|
|
2972
|
+
const padY = 24;
|
|
2973
|
+
const usableW = width - padX * 2;
|
|
2974
|
+
const usableH = height - padY * 2;
|
|
2975
|
+
const max = Math.max(...points.map((p) => p.y), 1);
|
|
2976
|
+
const stepX = points.length > 1 ? usableW / (points.length - 1) : 0;
|
|
2977
|
+
const xy = points.map((p, i) => ({
|
|
2978
|
+
x: padX + i * stepX,
|
|
2979
|
+
y: padY + usableH - p.y / max * usableH,
|
|
2980
|
+
raw: p
|
|
2981
|
+
}));
|
|
2982
|
+
const path15 = xy.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ");
|
|
2983
|
+
const dots = xy.map((p) => `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${color}" />`).join("");
|
|
2984
|
+
const xLabels = xy.map((p, i) => {
|
|
2985
|
+
if (points.length > 8 && i % Math.ceil(points.length / 6) !== 0 && i !== points.length - 1) return "";
|
|
2986
|
+
return `<text x="${p.x.toFixed(1)}" y="${(height - 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="middle">${escapeHtml(p.raw.label ?? p.raw.x)}</text>`;
|
|
2987
|
+
}).join("");
|
|
2988
|
+
return `<div class="chart-card">
|
|
2989
|
+
<h3>${escapeHtml(title)}</h3>
|
|
2990
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="${escapeHtml(title)} line chart">
|
|
2991
|
+
<line x1="${padX}" y1="${padY + usableH}" x2="${padX + usableW}" y2="${padY + usableH}" stroke="${COLORS.border}" stroke-width="1" />
|
|
2992
|
+
<text x="${padX - 6}" y="${(padY + 4).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">${formatNumber(max)}</text>
|
|
2993
|
+
<text x="${padX - 6}" y="${(padY + usableH).toFixed(1)}" fill="${COLORS.textFaint}" font-size="9" text-anchor="end">0</text>
|
|
2994
|
+
<path d="${path15}" stroke="${color}" stroke-width="2" fill="none" />
|
|
2995
|
+
${dots}
|
|
2996
|
+
${xLabels}
|
|
2997
|
+
</svg>
|
|
2998
|
+
</div>`;
|
|
2999
|
+
}
|
|
3000
|
+
function renderGsc(report) {
|
|
3001
|
+
const gsc = report.gsc;
|
|
3002
|
+
if (!gsc) {
|
|
3003
|
+
return section(
|
|
3004
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance" },
|
|
3005
|
+
renderEmpty("Connect Google Search Console to populate this section.")
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
const rows = gsc.topQueries.map((q) => `
|
|
3009
|
+
<tr>
|
|
3010
|
+
<td>${escapeHtml(q.query)}</td>
|
|
3011
|
+
<td class="numeric">${formatNumber(q.clicks)}</td>
|
|
3012
|
+
<td class="numeric">${formatNumber(q.impressions)}</td>
|
|
3013
|
+
<td class="numeric">${formatRatio(q.ctr)}</td>
|
|
3014
|
+
<td class="numeric">${q.avgPosition.toFixed(1)}</td>
|
|
3015
|
+
<td><span class="badge tone-neutral">${escapeHtml(q.category)}</span></td>
|
|
3016
|
+
</tr>`).join("");
|
|
3017
|
+
const breakdownRows = gsc.categoryBreakdown.map((c) => `
|
|
3018
|
+
<tr>
|
|
3019
|
+
<td>${escapeHtml(c.category)}</td>
|
|
3020
|
+
<td class="numeric">${formatNumber(c.clicks)}</td>
|
|
3021
|
+
<td class="numeric">${formatNumber(c.impressions)}</td>
|
|
3022
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
3023
|
+
</tr>`).join("");
|
|
3024
|
+
const trendChart = renderLineChart(
|
|
3025
|
+
gsc.trend.map((t) => ({ x: t.date, y: t.clicks, label: t.date.slice(5) })),
|
|
3026
|
+
COLORS.accent,
|
|
3027
|
+
"Clicks over time"
|
|
3028
|
+
);
|
|
3029
|
+
const crossoverBlocks = [];
|
|
3030
|
+
if (gsc.trackedButNoGsc.length > 0) {
|
|
3031
|
+
crossoverBlocks.push(`<div class="chart-card"><h3>AEO keywords without search demand</h3>
|
|
3032
|
+
<p class="section-intro">Tracked AEO keywords with no GSC impressions in this window \u2014 review whether they represent real search demand.</p>
|
|
3033
|
+
<ul>${gsc.trackedButNoGsc.map((k) => `<li>${escapeHtml(k)}</li>`).join("")}</ul>
|
|
3034
|
+
</div>`);
|
|
3035
|
+
}
|
|
3036
|
+
if (gsc.gscButNotTracked.length > 0) {
|
|
3037
|
+
crossoverBlocks.push(`<div class="chart-card"><h3>Search queries you should track</h3>
|
|
3038
|
+
<p class="section-intro">GSC top queries (by impressions) that aren't tracked in your AEO project \u2014 candidates to add as keywords.</p>
|
|
3039
|
+
<ul>${gsc.gscButNotTracked.map((q) => `<li>${escapeHtml(q)}</li>`).join("")}</ul>
|
|
3040
|
+
</div>`);
|
|
3041
|
+
}
|
|
3042
|
+
return section(
|
|
3043
|
+
{ id: "gsc", eyebrow: "Section 5", title: "GSC Performance", intro: "Top queries, category breakdown, and traffic trend from Google Search Console." },
|
|
3044
|
+
`<div class="metric-grid">
|
|
3045
|
+
<div class="metric"><div class="label">Total clicks</div><div class="value">${formatNumber(gsc.totalClicks)}</div></div>
|
|
3046
|
+
<div class="metric"><div class="label">Total impressions</div><div class="value">${formatNumber(gsc.totalImpressions)}</div></div>
|
|
3047
|
+
<div class="metric"><div class="label">Avg CTR</div><div class="value">${formatRatio(gsc.ctr)}</div></div>
|
|
3048
|
+
<div class="metric"><div class="label">Avg position</div><div class="value">${gsc.avgPosition.toFixed(1)}</div></div>
|
|
3049
|
+
</div>
|
|
3050
|
+
${trendChart}
|
|
3051
|
+
<div class="chart-card"><h3>Top queries</h3>
|
|
3052
|
+
<table class="report-table">
|
|
3053
|
+
<thead><tr><th>Query</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">CTR</th><th class="numeric">Pos.</th><th>Category</th></tr></thead>
|
|
3054
|
+
<tbody>${rows}</tbody>
|
|
3055
|
+
</table>
|
|
3056
|
+
</div>
|
|
3057
|
+
<div class="chart-card"><h3>Category breakdown</h3>
|
|
3058
|
+
<table class="report-table">
|
|
3059
|
+
<thead><tr><th>Category</th><th class="numeric">Clicks</th><th class="numeric">Imp.</th><th class="numeric">Share</th></tr></thead>
|
|
3060
|
+
<tbody>${breakdownRows}</tbody>
|
|
3061
|
+
</table>
|
|
3062
|
+
</div>
|
|
3063
|
+
${crossoverBlocks.join("\n")}`
|
|
3064
|
+
);
|
|
3065
|
+
}
|
|
3066
|
+
function renderGa(report) {
|
|
3067
|
+
const ga = report.ga;
|
|
3068
|
+
if (!ga) {
|
|
3069
|
+
return section(
|
|
3070
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic" },
|
|
3071
|
+
renderEmpty("Connect Google Analytics 4 to populate this section.")
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
const pageRows = ga.topLandingPages.map((p) => `
|
|
3075
|
+
<tr>
|
|
3076
|
+
<td class="page-cell">${formatLandingPageHtml(p.page)}</td>
|
|
3077
|
+
<td class="numeric">${formatNumber(p.sessions)}</td>
|
|
3078
|
+
<td class="numeric">${formatNumber(p.users)}</td>
|
|
3079
|
+
<td class="numeric">${formatNumber(p.organicSessions)}</td>
|
|
3080
|
+
</tr>`).join("");
|
|
3081
|
+
const channelRows = ga.channelBreakdown.map((c) => `
|
|
3082
|
+
<tr>
|
|
3083
|
+
<td>${escapeHtml(c.channel)}</td>
|
|
3084
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
3085
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
3086
|
+
</tr>`).join("");
|
|
3087
|
+
return section(
|
|
3088
|
+
{ id: "ga", eyebrow: "Section 6", title: "GA4 Traffic", intro: `Sessions and users for ${formatDate(ga.periodStart)} \u2192 ${formatDate(ga.periodEnd)}.` },
|
|
3089
|
+
`<div class="metric-grid">
|
|
3090
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ga.totalSessions)}</div></div>
|
|
3091
|
+
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ga.totalUsers)}</div></div>
|
|
3092
|
+
<div class="metric"><div class="label">Organic sessions</div><div class="value">${formatNumber(ga.totalOrganicSessions)}</div></div>
|
|
3093
|
+
</div>
|
|
3094
|
+
<div class="chart-card"><h3>Top landing pages</h3>
|
|
3095
|
+
<table class="report-table">
|
|
3096
|
+
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Organic</th></tr></thead>
|
|
3097
|
+
<tbody>${pageRows}</tbody>
|
|
3098
|
+
</table>
|
|
3099
|
+
</div>
|
|
3100
|
+
<div class="chart-card"><h3>Channel breakdown</h3>
|
|
3101
|
+
<table class="report-table">
|
|
3102
|
+
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
3103
|
+
<tbody>${channelRows}</tbody>
|
|
3104
|
+
</table>
|
|
3105
|
+
</div>`
|
|
3106
|
+
);
|
|
3107
|
+
}
|
|
3108
|
+
function renderSocial(report) {
|
|
3109
|
+
const social = report.socialReferrals;
|
|
3110
|
+
if (!social) {
|
|
3111
|
+
return section(
|
|
3112
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals" },
|
|
3113
|
+
renderEmpty("No social referral data yet.")
|
|
3114
|
+
);
|
|
3115
|
+
}
|
|
3116
|
+
const channelRows = social.channels.map((c) => `
|
|
3117
|
+
<tr>
|
|
3118
|
+
<td>${escapeHtml(c.channelGroup)}</td>
|
|
3119
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
3120
|
+
<td class="numeric">${c.sharePct}%</td>
|
|
3121
|
+
</tr>`).join("");
|
|
3122
|
+
const campaignRows = social.topCampaigns.map((c) => `
|
|
3123
|
+
<tr>
|
|
3124
|
+
<td>${escapeHtml(c.source)}</td>
|
|
3125
|
+
<td>${escapeHtml(c.medium)}</td>
|
|
3126
|
+
<td class="numeric">${formatNumber(c.sessions)}</td>
|
|
3127
|
+
</tr>`).join("");
|
|
3128
|
+
return section(
|
|
3129
|
+
{ id: "social-referrals", eyebrow: "Section 7", title: "Social Referrals", intro: "Paid vs organic split with top campaigns." },
|
|
3130
|
+
`<div class="metric-grid">
|
|
3131
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(social.totalSessions)}</div></div>
|
|
3132
|
+
<div class="metric"><div class="label">Organic social</div><div class="value">${formatNumber(social.organicSessions)}</div></div>
|
|
3133
|
+
<div class="metric"><div class="label">Paid social</div><div class="value">${formatNumber(social.paidSessions)}</div></div>
|
|
3134
|
+
</div>
|
|
3135
|
+
<div class="chart-card"><h3>Channel groups</h3>
|
|
3136
|
+
<table class="report-table">
|
|
3137
|
+
<thead><tr><th>Channel</th><th class="numeric">Sessions</th><th class="numeric">Share</th></tr></thead>
|
|
3138
|
+
<tbody>${channelRows}</tbody>
|
|
3139
|
+
</table>
|
|
3140
|
+
</div>
|
|
3141
|
+
<div class="chart-card"><h3>Top campaigns</h3>
|
|
3142
|
+
<table class="report-table">
|
|
3143
|
+
<thead><tr><th>Source</th><th>Medium</th><th class="numeric">Sessions</th></tr></thead>
|
|
3144
|
+
<tbody>${campaignRows}</tbody>
|
|
3145
|
+
</table>
|
|
3146
|
+
</div>`
|
|
3147
|
+
);
|
|
3148
|
+
}
|
|
3149
|
+
function renderAiReferrals(report) {
|
|
3150
|
+
const ai = report.aiReferrals;
|
|
3151
|
+
if (!ai) {
|
|
3152
|
+
return section(
|
|
3153
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic" },
|
|
3154
|
+
renderEmpty("No AI referral traffic detected yet.")
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
const sourceRows = ai.bySource.map((s) => `
|
|
3158
|
+
<tr>
|
|
3159
|
+
<td>${escapeHtml(s.source)}</td>
|
|
3160
|
+
<td class="numeric">${formatNumber(s.sessions)}</td>
|
|
3161
|
+
<td class="numeric">${formatNumber(s.users)}</td>
|
|
3162
|
+
<td class="numeric">${s.sharePct}%</td>
|
|
3163
|
+
</tr>`).join("");
|
|
3164
|
+
const pageRows = ai.topLandingPages.map((p) => `
|
|
3165
|
+
<tr>
|
|
3166
|
+
<td class="page-cell">${formatLandingPageHtml(p.page)}</td>
|
|
3167
|
+
<td class="numeric">${formatNumber(p.sessions)}</td>
|
|
3168
|
+
<td class="numeric">${formatNumber(p.users)}</td>
|
|
3169
|
+
</tr>`).join("");
|
|
3170
|
+
const trendChart = renderLineChart(
|
|
3171
|
+
ai.trend.map((t) => ({ x: t.date, y: t.sessions, label: t.date.slice(5) })),
|
|
3172
|
+
COLORS.series[2],
|
|
3173
|
+
"AI referral sessions over time"
|
|
3174
|
+
);
|
|
3175
|
+
return section(
|
|
3176
|
+
{ id: "ai-referrals", eyebrow: "Section 8", title: "AI Referral Traffic", intro: "Sessions sent from AI answer engines." },
|
|
3177
|
+
`<div class="metric-grid">
|
|
3178
|
+
<div class="metric"><div class="label">Total sessions</div><div class="value">${formatNumber(ai.totalSessions)}</div></div>
|
|
3179
|
+
<div class="metric"><div class="label">Total users</div><div class="value">${formatNumber(ai.totalUsers)}</div></div>
|
|
3180
|
+
</div>
|
|
3181
|
+
${trendChart}
|
|
3182
|
+
<div class="chart-card"><h3>Sessions by source</h3>
|
|
3183
|
+
<table class="report-table">
|
|
3184
|
+
<thead><tr><th>Source</th><th class="numeric">Sessions</th><th class="numeric">Users</th><th class="numeric">Share</th></tr></thead>
|
|
3185
|
+
<tbody>${sourceRows}</tbody>
|
|
3186
|
+
</table>
|
|
3187
|
+
</div>
|
|
3188
|
+
<div class="chart-card"><h3>Top AI landing pages</h3>
|
|
3189
|
+
<table class="report-table">
|
|
3190
|
+
<thead><tr><th>Page</th><th class="numeric">Sessions</th><th class="numeric">Users</th></tr></thead>
|
|
3191
|
+
<tbody>${pageRows}</tbody>
|
|
3192
|
+
</table>
|
|
3193
|
+
</div>`
|
|
3194
|
+
);
|
|
3195
|
+
}
|
|
3196
|
+
function renderIndexingHealth(report) {
|
|
3197
|
+
const ih = report.indexingHealth;
|
|
3198
|
+
if (!ih) {
|
|
3199
|
+
return section(
|
|
3200
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health" },
|
|
3201
|
+
renderEmpty("Connect Google Search Console or Bing Webmaster Tools and run a sitemap inspection.")
|
|
3202
|
+
);
|
|
3203
|
+
}
|
|
3204
|
+
const segments = [
|
|
3205
|
+
{ label: "Indexed", count: ih.indexed, color: COLORS.positive },
|
|
3206
|
+
{ label: "Not indexed", count: ih.notIndexed, color: COLORS.caution },
|
|
3207
|
+
{ label: "Deindexed", count: ih.deindexed, color: COLORS.negative },
|
|
3208
|
+
{ label: "Unknown", count: ih.unknown, color: COLORS.neutral }
|
|
3209
|
+
].filter((s) => s.count > 0);
|
|
3210
|
+
const total = segments.reduce((s, x) => s + x.count, 0) || 1;
|
|
3211
|
+
const width = 600;
|
|
3212
|
+
const height = 28;
|
|
3213
|
+
let acc = 0;
|
|
3214
|
+
const bars = segments.map((s) => {
|
|
3215
|
+
const w = s.count / total * width;
|
|
3216
|
+
const x = acc;
|
|
3217
|
+
acc += w;
|
|
3218
|
+
return `<rect x="${x}" y="0" width="${w}" height="${height}" fill="${s.color}" />`;
|
|
3219
|
+
}).join("");
|
|
3220
|
+
const legend = segments.map((s) => `<span><span class="legend-swatch" style="background:${s.color}"></span>${escapeHtml(s.label)}: ${s.count}</span>`).join("");
|
|
3221
|
+
return section(
|
|
3222
|
+
{ id: "indexing-health", eyebrow: "Section 9", title: "Indexing Health", intro: `Source: ${ih.provider === "google" ? "Google Search Console" : "Bing Webmaster Tools"}.` },
|
|
3223
|
+
`<div class="metric-grid">
|
|
3224
|
+
<div class="metric"><div class="label">Indexed</div><div class="value tone-positive">${formatNumber(ih.indexed)}</div></div>
|
|
3225
|
+
<div class="metric"><div class="label">Total inspected</div><div class="value">${formatNumber(ih.total)}</div></div>
|
|
3226
|
+
<div class="metric"><div class="label">Indexed share</div><div class="value">${ih.indexedPct}%</div></div>
|
|
3227
|
+
</div>
|
|
3228
|
+
<div class="chart-card">
|
|
3229
|
+
<h3>Coverage breakdown</h3>
|
|
3230
|
+
<svg viewBox="0 0 ${width} ${height}" width="100%" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Coverage stacked bar">${bars}</svg>
|
|
3231
|
+
<div class="legend">${legend}</div>
|
|
3232
|
+
</div>`
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
function renderCitationsTrend(report) {
|
|
3236
|
+
const trend = report.citationsTrend;
|
|
3237
|
+
if (trend.length === 0) {
|
|
3238
|
+
return section(
|
|
3239
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
|
|
3240
|
+
renderEmpty("Run multiple visibility sweeps to see a trend.")
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
if (isTrendBaseline(trend)) {
|
|
3244
|
+
return section(
|
|
3245
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time" },
|
|
3246
|
+
renderEmpty(`Establishing baseline (${trend.length} of ${MIN_TREND_POINTS} runs collected). Trend will appear once more sweeps are recorded.`)
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
const chart = renderLineChart(
|
|
3250
|
+
trend.map((t) => ({ x: t.date, y: t.citationRate, label: formatDate(t.date) })),
|
|
3251
|
+
COLORS.positive,
|
|
3252
|
+
"Overall citation rate",
|
|
3253
|
+
220
|
|
3254
|
+
);
|
|
3255
|
+
const rows = trend.map((t) => `
|
|
3256
|
+
<tr>
|
|
3257
|
+
<td>${formatDate(t.date)}</td>
|
|
3258
|
+
<td class="numeric">${t.citationRate}%</td>
|
|
3259
|
+
<td>${t.providerRates.map((r) => `${escapeHtml(r.provider)}: ${r.citationRate}%`).join(" \xB7 ")}</td>
|
|
3260
|
+
</tr>`).join("");
|
|
3261
|
+
return section(
|
|
3262
|
+
{ id: "citations-trend", eyebrow: "Section 10", title: "Citations Over Time", intro: "Per-run citation rate across the project history." },
|
|
3263
|
+
`${chart}
|
|
3264
|
+
<div class="chart-card"><h3>Run-by-run breakdown</h3>
|
|
3265
|
+
<table class="report-table">
|
|
3266
|
+
<thead><tr><th>Run</th><th class="numeric">Overall rate</th><th>Per-provider rates</th></tr></thead>
|
|
3267
|
+
<tbody>${rows}</tbody>
|
|
3268
|
+
</table>
|
|
3269
|
+
</div>`
|
|
3270
|
+
);
|
|
3271
|
+
}
|
|
3272
|
+
function renderInsights(report) {
|
|
3273
|
+
const list = report.insights;
|
|
3274
|
+
if (list.length === 0) {
|
|
3275
|
+
return section(
|
|
3276
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts" },
|
|
3277
|
+
renderEmpty("No insights yet \u2014 run a visibility sweep to generate alerts.")
|
|
3278
|
+
);
|
|
3279
|
+
}
|
|
3280
|
+
const haveDeduped = list.every((i) => typeof i.instanceCount === "number");
|
|
3281
|
+
const rows = (haveDeduped ? list.map((i) => ({ rep: i, count: i.instanceCount })) : groupInsights(list).map((g) => ({ rep: g.representative, count: g.count }))).map(({ rep: i, count }) => {
|
|
3282
|
+
const tone = severityTone(i.severity);
|
|
3283
|
+
const countChip = count > 1 ? ` <span class="badge tone-neutral">\xD7 ${count}</span>` : "";
|
|
3284
|
+
return `<tr>
|
|
3285
|
+
<td><span class="badge tone-${tone}">${escapeHtml(i.severity)}</span></td>
|
|
3286
|
+
<td>${escapeHtml(i.title)}${countChip}</td>
|
|
3287
|
+
<td>${escapeHtml(i.keyword)}</td>
|
|
3288
|
+
<td>${escapeHtml(i.provider)}</td>
|
|
3289
|
+
<td>${i.recommendation ? escapeHtml(i.recommendation) : '<span class="cell-pending">\u2014</span>'}</td>
|
|
3290
|
+
</tr>`;
|
|
3291
|
+
}).join("");
|
|
3292
|
+
return section(
|
|
3293
|
+
{ id: "insights", eyebrow: "Section 11", title: "Insights & Alerts", intro: "Priority-ordered findings from the most recent runs." },
|
|
3294
|
+
`<table class="report-table">
|
|
3295
|
+
<thead><tr><th>Severity</th><th>Title</th><th>Keyword</th><th>Provider</th><th>Recommendation</th></tr></thead>
|
|
3296
|
+
<tbody>${rows}</tbody>
|
|
3297
|
+
</table>`
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
function renderOpportunities(report) {
|
|
3301
|
+
const opps = report.contentOpportunities;
|
|
3302
|
+
if (opps.length === 0) return "";
|
|
3303
|
+
const rows = opps.slice(0, 10).map((o) => {
|
|
3304
|
+
const ourPage = o.ourBestPage ? `<a href="${escapeHtml(o.ourBestPage.url)}">${escapeHtml(o.ourBestPage.url)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
3305
|
+
const winning = o.winningCompetitor ? `<a href="${escapeHtml(o.winningCompetitor.url)}">${escapeHtml(o.winningCompetitor.domain)}</a>` : '<span class="cell-not-cited">\u2014</span>';
|
|
3306
|
+
return `<tr>
|
|
3307
|
+
<td>${escapeHtml(o.query)}</td>
|
|
3308
|
+
<td><span class="badge tone-neutral">${escapeHtml(o.action)}</span></td>
|
|
3309
|
+
<td class="numeric">${Math.round(o.score)}</td>
|
|
3310
|
+
<td>${ourPage}</td>
|
|
3311
|
+
<td>${winning}</td>
|
|
3312
|
+
<td><span class="badge tone-neutral">${escapeHtml(o.demandSource)}</span></td>
|
|
3313
|
+
<td><span class="badge tone-neutral">${escapeHtml(o.actionConfidence)}</span></td>
|
|
3314
|
+
</tr>`;
|
|
3315
|
+
}).join("");
|
|
3316
|
+
return section(
|
|
3317
|
+
{
|
|
3318
|
+
id: "content-opportunities",
|
|
3319
|
+
eyebrow: "Section 12",
|
|
3320
|
+
title: "Content Opportunities",
|
|
3321
|
+
intro: "Ranked, action-typed targets from the content recommendation engine. Top 10 shown."
|
|
3322
|
+
},
|
|
3323
|
+
`<table class="report-table">
|
|
3324
|
+
<thead><tr><th>Query</th><th>Action</th><th class="numeric">Score</th><th>Our page</th><th>Winning competitor</th><th>Demand</th><th>Confidence</th></tr></thead>
|
|
3325
|
+
<tbody>${rows}</tbody>
|
|
3326
|
+
</table>`
|
|
3327
|
+
);
|
|
3328
|
+
}
|
|
3329
|
+
function renderRecommendedNextSteps(report) {
|
|
3330
|
+
const steps = report.recommendedNextSteps;
|
|
3331
|
+
if (steps.length === 0) {
|
|
3332
|
+
return section(
|
|
3333
|
+
{ id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps" },
|
|
3334
|
+
renderEmpty("No outstanding actions.")
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
const items = steps.map((s) => `
|
|
3338
|
+
<div class="step">
|
|
3339
|
+
<span class="horizon">${escapeHtml(s.horizon)}</span>
|
|
3340
|
+
<span class="title">${escapeHtml(s.title)}</span>
|
|
3341
|
+
<span class="rationale">${escapeHtml(s.rationale)}</span>
|
|
3342
|
+
</div>`).join("");
|
|
3343
|
+
return section(
|
|
3344
|
+
{ id: "recommended-next-steps", eyebrow: "Section 13", title: "Recommended Next Steps" },
|
|
3345
|
+
`<div class="steps">${items}</div>`
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
3348
|
+
function escapeJsonForScript(json) {
|
|
3349
|
+
return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
3350
|
+
}
|
|
3351
|
+
function renderReportHtml(report, opts = {}) {
|
|
3352
|
+
const title = opts.title ?? `Canonry report \u2014 ${report.meta.project.displayName}`;
|
|
3353
|
+
const sections = [
|
|
3354
|
+
renderExecutiveSummary(report),
|
|
3355
|
+
renderCitationScorecard(report),
|
|
3356
|
+
renderCompetitorLandscape(report),
|
|
3357
|
+
renderAiSourceOrigin(report),
|
|
3358
|
+
renderGsc(report),
|
|
3359
|
+
renderGa(report),
|
|
3360
|
+
renderSocial(report),
|
|
3361
|
+
renderAiReferrals(report),
|
|
3362
|
+
renderIndexingHealth(report),
|
|
3363
|
+
renderCitationsTrend(report),
|
|
3364
|
+
renderInsights(report),
|
|
3365
|
+
renderOpportunities(report),
|
|
3366
|
+
renderRecommendedNextSteps(report)
|
|
3367
|
+
].join("\n");
|
|
3368
|
+
const json = escapeJsonForScript(JSON.stringify(report));
|
|
3369
|
+
return `<!DOCTYPE html>
|
|
3370
|
+
<html lang="en">
|
|
3371
|
+
<head>
|
|
3372
|
+
<meta charset="utf-8" />
|
|
3373
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
3374
|
+
<title>${escapeHtml(title)}</title>
|
|
3375
|
+
<style>${STYLE}</style>
|
|
3376
|
+
</head>
|
|
3377
|
+
<body>
|
|
3378
|
+
<div class="container">
|
|
3379
|
+
<header class="header">
|
|
3380
|
+
<div class="eyebrow">AEO Report</div>
|
|
3381
|
+
<h1>${escapeHtml(report.meta.project.displayName)}</h1>
|
|
3382
|
+
<div class="subtitle">${escapeHtml(report.meta.project.canonicalDomain)} \xB7 ${escapeHtml(report.meta.project.country)} / ${escapeHtml(report.meta.project.language.toUpperCase())} \xB7 Generated ${formatDate(report.meta.generatedAt)}</div>
|
|
3383
|
+
</header>
|
|
3384
|
+
${sections}
|
|
3385
|
+
<footer class="footer">Generated by canonry \xB7 ${escapeHtml(report.meta.generatedAt)}</footer>
|
|
3386
|
+
</div>
|
|
3387
|
+
<script type="application/json" id="canonry-report-data">${json}</script>
|
|
3388
|
+
</body>
|
|
3389
|
+
</html>`;
|
|
3390
|
+
}
|
|
3391
|
+
|
|
2399
3392
|
// ../api-routes/src/content-data.ts
|
|
2400
3393
|
import { and as and3, eq as eq12, desc as desc5, inArray as inArray3 } from "drizzle-orm";
|
|
2401
3394
|
var RECENT_RUNS_WINDOW = 5;
|
|
@@ -3248,134 +4241,149 @@ function buildExecutiveFindings(citationRate, trend, trendsPoints, trendBaseline
|
|
|
3248
4241
|
}
|
|
3249
4242
|
return findings.slice(0, 5);
|
|
3250
4243
|
}
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
if (priorRate
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
4244
|
+
function buildProjectReport(db, projectName) {
|
|
4245
|
+
const project = resolveProject(db, projectName);
|
|
4246
|
+
const keywordLookup = loadKeywordLookup(db, project.id);
|
|
4247
|
+
const allRuns = db.select().from(runs).where(eq13(runs.projectId, project.id)).orderBy(desc6(runs.createdAt)).all();
|
|
4248
|
+
const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
|
|
4249
|
+
const latestRun = visibilityRuns.find(
|
|
4250
|
+
(r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
|
|
4251
|
+
) ?? visibilityRuns[0];
|
|
4252
|
+
const latestSnapshots = latestRun ? loadSnapshotsForRun(db, latestRun.id) : [];
|
|
4253
|
+
const competitorRows = db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
|
|
4254
|
+
const competitorDomains = competitorRows.map((c) => c.domain);
|
|
4255
|
+
const ownedDomains = parseJsonColumn(project.ownedDomains, []);
|
|
4256
|
+
const projectDomains = [project.canonicalDomain, ...ownedDomains];
|
|
4257
|
+
const citationScorecard = buildCitationScorecard(latestSnapshots, keywordLookup);
|
|
4258
|
+
const competitorLandscape = buildCompetitorLandscape(
|
|
4259
|
+
latestSnapshots,
|
|
4260
|
+
competitorDomains,
|
|
4261
|
+
projectDomains,
|
|
4262
|
+
keywordLookup
|
|
4263
|
+
);
|
|
4264
|
+
const aiSourceOrigin = buildAiSourceOrigin(latestSnapshots, projectDomains, competitorDomains);
|
|
4265
|
+
const trackedKeywords = [...keywordLookup.byId.values()];
|
|
4266
|
+
const gscSection = buildGscSection(
|
|
4267
|
+
db,
|
|
4268
|
+
project.id,
|
|
4269
|
+
project.displayName,
|
|
4270
|
+
project.canonicalDomain,
|
|
4271
|
+
trackedKeywords
|
|
4272
|
+
);
|
|
4273
|
+
const gaSection = buildGaSection(db, project.id);
|
|
4274
|
+
const socialSection = buildSocialReferrals(db, project.id);
|
|
4275
|
+
const aiReferralsSection = buildAiReferrals(db, project.id);
|
|
4276
|
+
const indexingHealthSection = buildIndexingHealth(db, project.id);
|
|
4277
|
+
const citationsTrend = buildCitationsTrend(db, project.id, keywordLookup);
|
|
4278
|
+
const insightList = buildInsightList(db, project.id);
|
|
4279
|
+
const orchestratorInput = loadOrchestratorInput(db, project);
|
|
4280
|
+
const contentOpportunities = buildContentTargetRows(orchestratorInput);
|
|
4281
|
+
const contentGaps = buildContentGapRows(orchestratorInput);
|
|
4282
|
+
const groundingSources = buildContentSourceRows(orchestratorInput);
|
|
4283
|
+
const insightDerivedSteps = buildRecommendedNextSteps(insightList);
|
|
4284
|
+
const recommendedNextSteps = mapOpportunitiesToNextSteps(
|
|
4285
|
+
contentOpportunities,
|
|
4286
|
+
insightDerivedSteps
|
|
4287
|
+
);
|
|
4288
|
+
let latestCited = 0;
|
|
4289
|
+
let latestConsidered = 0;
|
|
4290
|
+
for (const snap of latestSnapshots) {
|
|
4291
|
+
if (!keywordLookup.byId.has(snap.keywordId)) continue;
|
|
4292
|
+
latestConsidered++;
|
|
4293
|
+
if (snap.citationState === "cited") latestCited++;
|
|
4294
|
+
}
|
|
4295
|
+
const citationRate = latestConsidered > 0 ? Math.round(latestCited / latestConsidered * 100) : 0;
|
|
4296
|
+
const trendBaseline = isTrendBaseline(citationsTrend);
|
|
4297
|
+
const latestPoint = citationsTrend.at(-1);
|
|
4298
|
+
const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
|
|
4299
|
+
let trend = "unknown";
|
|
4300
|
+
if (!trendBaseline && latestPoint) {
|
|
4301
|
+
const latestRunOnTrend = latestRun?.id === latestPoint.runId;
|
|
4302
|
+
const currentRate = latestRunOnTrend ? latestPoint.citationRate : citationRate;
|
|
4303
|
+
const priorRate = latestRunOnTrend ? previousPoint?.citationRate : latestPoint.citationRate;
|
|
4304
|
+
if (priorRate !== void 0) {
|
|
4305
|
+
if (currentRate > priorRate) trend = "up";
|
|
4306
|
+
else if (currentRate < priorRate) trend = "down";
|
|
4307
|
+
else trend = "flat";
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
const findings = buildExecutiveFindings(
|
|
4311
|
+
citationRate,
|
|
4312
|
+
trend,
|
|
4313
|
+
citationsTrend,
|
|
4314
|
+
trendBaseline,
|
|
4315
|
+
insightList,
|
|
4316
|
+
competitorLandscape.competitors
|
|
4317
|
+
);
|
|
4318
|
+
const periodStart = citationsTrend[0]?.date ?? null;
|
|
4319
|
+
const periodEnd = citationsTrend.at(-1)?.date ?? null;
|
|
4320
|
+
return {
|
|
4321
|
+
meta: {
|
|
4322
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4323
|
+
project: {
|
|
4324
|
+
id: project.id,
|
|
4325
|
+
name: project.name,
|
|
4326
|
+
displayName: project.displayName,
|
|
4327
|
+
canonicalDomain: project.canonicalDomain,
|
|
4328
|
+
country: project.country,
|
|
4329
|
+
language: project.language
|
|
4330
|
+
},
|
|
4331
|
+
periodStart,
|
|
4332
|
+
periodEnd
|
|
4333
|
+
},
|
|
4334
|
+
executiveSummary: {
|
|
3319
4335
|
citationRate,
|
|
3320
4336
|
trend,
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
},
|
|
3362
|
-
citationScorecard,
|
|
3363
|
-
competitorLandscape,
|
|
3364
|
-
aiSourceOrigin,
|
|
3365
|
-
gsc: gscSection,
|
|
3366
|
-
ga: gaSection,
|
|
3367
|
-
socialReferrals: socialSection,
|
|
3368
|
-
aiReferrals: aiReferralsSection,
|
|
3369
|
-
indexingHealth: indexingHealthSection,
|
|
3370
|
-
citationsTrend,
|
|
3371
|
-
insights: insightList,
|
|
3372
|
-
recommendedNextSteps,
|
|
3373
|
-
contentOpportunities,
|
|
3374
|
-
contentGaps,
|
|
3375
|
-
groundingSources
|
|
3376
|
-
};
|
|
4337
|
+
keywordCount: keywordLookup.byId.size,
|
|
4338
|
+
competitorCount: competitorDomains.length,
|
|
4339
|
+
providerCount: citationScorecard.providers.length,
|
|
4340
|
+
gsc: gscSection ? {
|
|
4341
|
+
clicks: gscSection.totalClicks,
|
|
4342
|
+
impressions: gscSection.totalImpressions,
|
|
4343
|
+
ctr: gscSection.ctr,
|
|
4344
|
+
avgPosition: gscSection.avgPosition
|
|
4345
|
+
} : null,
|
|
4346
|
+
ga: gaSection ? {
|
|
4347
|
+
sessions: gaSection.totalSessions,
|
|
4348
|
+
users: gaSection.totalUsers,
|
|
4349
|
+
periodStart: gaSection.periodStart,
|
|
4350
|
+
periodEnd: gaSection.periodEnd
|
|
4351
|
+
} : null,
|
|
4352
|
+
findings
|
|
4353
|
+
},
|
|
4354
|
+
citationScorecard,
|
|
4355
|
+
competitorLandscape,
|
|
4356
|
+
aiSourceOrigin,
|
|
4357
|
+
gsc: gscSection,
|
|
4358
|
+
ga: gaSection,
|
|
4359
|
+
socialReferrals: socialSection,
|
|
4360
|
+
aiReferrals: aiReferralsSection,
|
|
4361
|
+
indexingHealth: indexingHealthSection,
|
|
4362
|
+
citationsTrend,
|
|
4363
|
+
insights: insightList,
|
|
4364
|
+
recommendedNextSteps,
|
|
4365
|
+
contentOpportunities,
|
|
4366
|
+
contentGaps,
|
|
4367
|
+
groundingSources
|
|
4368
|
+
};
|
|
4369
|
+
}
|
|
4370
|
+
function reportFilenameFor(project, generatedAt) {
|
|
4371
|
+
const date = generatedAt.slice(0, 10);
|
|
4372
|
+
return `canonry-report-${project.name}-${date}.html`;
|
|
4373
|
+
}
|
|
4374
|
+
async function reportRoutes(app) {
|
|
4375
|
+
app.get("/projects/:name/report", async (request, reply) => {
|
|
4376
|
+
const dto = buildProjectReport(app.db, request.params.name);
|
|
3377
4377
|
return reply.send(dto);
|
|
3378
4378
|
});
|
|
4379
|
+
app.get("/projects/:name/report.html", async (request, reply) => {
|
|
4380
|
+
const dto = buildProjectReport(app.db, request.params.name);
|
|
4381
|
+
const html = renderReportHtml(dto);
|
|
4382
|
+
const filename = reportFilenameFor(dto.meta.project, dto.meta.generatedAt);
|
|
4383
|
+
reply.header("Content-Type", "text/html; charset=utf-8");
|
|
4384
|
+
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
|
|
4385
|
+
return reply.send(html);
|
|
4386
|
+
});
|
|
3379
4387
|
}
|
|
3380
4388
|
|
|
3381
4389
|
// ../api-routes/src/citations.ts
|
|
@@ -6130,6 +7138,18 @@ var routeCatalog = [
|
|
|
6130
7138
|
404: { description: "Project not found." }
|
|
6131
7139
|
}
|
|
6132
7140
|
},
|
|
7141
|
+
{
|
|
7142
|
+
method: "get",
|
|
7143
|
+
path: "/api/v1/projects/{name}/report.html",
|
|
7144
|
+
summary: "Standalone HTML AEO report",
|
|
7145
|
+
tags: ["report"],
|
|
7146
|
+
description: "Server-rendered self-contained HTML version of the project report. Same data as `/projects/{name}/report` (JSON), rendered through the canonry HTML report renderer. Returns `text/html` with `Content-Disposition: attachment` so browsers download it as `canonry-report-<project>-YYYY-MM-DD.html`. Open in a browser and Print \u2192 Save as PDF for a PDF copy.",
|
|
7147
|
+
parameters: [nameParameter],
|
|
7148
|
+
responses: {
|
|
7149
|
+
200: { description: "HTML report returned." },
|
|
7150
|
+
404: { description: "Project not found." }
|
|
7151
|
+
}
|
|
7152
|
+
},
|
|
6133
7153
|
{
|
|
6134
7154
|
method: "get",
|
|
6135
7155
|
path: "/api/v1/projects/{name}/health/latest",
|
|
@@ -7234,6 +8254,9 @@ function validateSiteUrl(siteUrl) {
|
|
|
7234
8254
|
if (!siteUrl || typeof siteUrl !== "string" || siteUrl.trim().length === 0) {
|
|
7235
8255
|
throw new GoogleApiError("Site URL is required and must be a non-empty string", 400);
|
|
7236
8256
|
}
|
|
8257
|
+
if (/^\d+$/.test(siteUrl)) {
|
|
8258
|
+
return;
|
|
8259
|
+
}
|
|
7237
8260
|
if (siteUrl.startsWith("sc-domain:")) {
|
|
7238
8261
|
const domain = siteUrl.slice("sc-domain:".length);
|
|
7239
8262
|
if (!domain) {
|
|
@@ -7610,7 +8633,7 @@ async function batchRunReports(accessToken, propertyId, requests) {
|
|
|
7610
8633
|
const data = await res.json();
|
|
7611
8634
|
return data.reports;
|
|
7612
8635
|
}
|
|
7613
|
-
function
|
|
8636
|
+
function formatDate2(d) {
|
|
7614
8637
|
return d.toISOString().split("T")[0];
|
|
7615
8638
|
}
|
|
7616
8639
|
var AI_REFERRAL_SOURCE_FILTERS = [
|
|
@@ -7640,7 +8663,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
7640
8663
|
while (pageCount < GA4_MAX_PAGES) {
|
|
7641
8664
|
pageCount++;
|
|
7642
8665
|
const request = {
|
|
7643
|
-
dateRanges: [{ startDate:
|
|
8666
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7644
8667
|
dimensions: [
|
|
7645
8668
|
{ name: "date" },
|
|
7646
8669
|
{ name: "landingPagePlusQueryString" }
|
|
@@ -7675,7 +8698,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
7675
8698
|
while (organicPageCount < GA4_MAX_PAGES) {
|
|
7676
8699
|
organicPageCount++;
|
|
7677
8700
|
const organicRequest = {
|
|
7678
|
-
dateRanges: [{ startDate:
|
|
8701
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7679
8702
|
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
7680
8703
|
metrics: [{ name: "sessions" }],
|
|
7681
8704
|
dimensionFilter: {
|
|
@@ -7703,7 +8726,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
7703
8726
|
while (directPageCount < GA4_MAX_PAGES) {
|
|
7704
8727
|
directPageCount++;
|
|
7705
8728
|
const directRequest = {
|
|
7706
|
-
dateRanges: [{ startDate:
|
|
8729
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7707
8730
|
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
7708
8731
|
metrics: [{ name: "sessions" }],
|
|
7709
8732
|
dimensionFilter: {
|
|
@@ -7752,7 +8775,7 @@ async function verifyConnectionWithToken(accessToken, propertyId) {
|
|
|
7752
8775
|
const startDate = /* @__PURE__ */ new Date();
|
|
7753
8776
|
startDate.setDate(startDate.getDate() - 1);
|
|
7754
8777
|
await runReport(accessToken, propertyId, {
|
|
7755
|
-
dateRanges: [{ startDate:
|
|
8778
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7756
8779
|
dimensions: [{ name: "date" }],
|
|
7757
8780
|
metrics: [{ name: "sessions" }],
|
|
7758
8781
|
limit: 1
|
|
@@ -7767,7 +8790,7 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
|
7767
8790
|
const startDate = /* @__PURE__ */ new Date();
|
|
7768
8791
|
startDate.setDate(startDate.getDate() - syncDays);
|
|
7769
8792
|
ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
|
|
7770
|
-
const dateRange = { startDate:
|
|
8793
|
+
const dateRange = { startDate: formatDate2(startDate), endDate: formatDate2(endDate) };
|
|
7771
8794
|
const batchRes = await batchRunReports(accessToken, propertyId, [
|
|
7772
8795
|
{
|
|
7773
8796
|
dateRanges: [dateRange],
|
|
@@ -7791,8 +8814,8 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
|
7791
8814
|
const totalRow = batchRes[0]?.rows?.[0];
|
|
7792
8815
|
const organicRow = batchRes[1]?.rows?.[0];
|
|
7793
8816
|
const summary = {
|
|
7794
|
-
periodStart:
|
|
7795
|
-
periodEnd:
|
|
8817
|
+
periodStart: formatDate2(startDate),
|
|
8818
|
+
periodEnd: formatDate2(endDate),
|
|
7796
8819
|
totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
|
|
7797
8820
|
totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
|
|
7798
8821
|
totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
|
|
@@ -7821,7 +8844,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
7821
8844
|
while (aiRefPageCount < GA4_MAX_PAGES) {
|
|
7822
8845
|
aiRefPageCount++;
|
|
7823
8846
|
const request = {
|
|
7824
|
-
dateRanges: [{ startDate:
|
|
8847
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7825
8848
|
dimensions: [
|
|
7826
8849
|
{ name: "date" },
|
|
7827
8850
|
{ name: sourceDim },
|
|
@@ -7899,7 +8922,7 @@ async function fetchSocialReferrals(accessToken, propertyId, days) {
|
|
|
7899
8922
|
let offset = 0;
|
|
7900
8923
|
while (true) {
|
|
7901
8924
|
const request = {
|
|
7902
|
-
dateRanges: [{ startDate:
|
|
8925
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7903
8926
|
dimensions: [
|
|
7904
8927
|
{ name: "date" },
|
|
7905
8928
|
{ name: "sessionSource" },
|
|
@@ -8053,10 +9076,10 @@ async function googleRoutes(app, opts) {
|
|
|
8053
9076
|
return reply.status(500).send("Google OAuth not configured");
|
|
8054
9077
|
}
|
|
8055
9078
|
const store = requireConnectionStore();
|
|
8056
|
-
const
|
|
9079
|
+
const escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8057
9080
|
const { code, state, error } = request.query;
|
|
8058
9081
|
if (error) {
|
|
8059
|
-
const safeError =
|
|
9082
|
+
const safeError = escapeHtml2(String(error));
|
|
8060
9083
|
const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
8061
9084
|
<h2 style="color:#ef4444">Redirect URI mismatch</h2>
|
|
8062
9085
|
<p>Google rejected the OAuth callback because the redirect URI is not registered.</p>
|
|
@@ -8067,7 +9090,7 @@ async function googleRoutes(app, opts) {
|
|
|
8067
9090
|
<li>Under "Authorized redirect URIs", add:<br><code style="background:#1e1e1e;color:#e0e0e0;padding:4px 8px;border-radius:4px;display:inline-block;margin-top:4px">${request.query.state ? (() => {
|
|
8068
9091
|
try {
|
|
8069
9092
|
const s = verifySignedState(request.query.state, stateSecret);
|
|
8070
|
-
return
|
|
9093
|
+
return escapeHtml2(String(s?.redirectUri ?? "Could not determine URI"));
|
|
8071
9094
|
} catch {
|
|
8072
9095
|
return "Could not determine URI";
|
|
8073
9096
|
}
|
|
@@ -8096,9 +9119,9 @@ async function googleRoutes(app, opts) {
|
|
|
8096
9119
|
return reply.type("text/html").send(
|
|
8097
9120
|
`<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
8098
9121
|
<h2 style="color:#ef4444">Token exchange failed</h2>
|
|
8099
|
-
<p>${
|
|
9122
|
+
<p>${escapeHtml2(msg)}</p>
|
|
8100
9123
|
<p><strong>Redirect URI used:</strong><br>
|
|
8101
|
-
<code style="background:#1e1e1e;color:#e0e0e0;padding:4px 8px;border-radius:4px">${
|
|
9124
|
+
<code style="background:#1e1e1e;color:#e0e0e0;padding:4px 8px;border-radius:4px">${escapeHtml2(redirectUri)}</code>
|
|
8102
9125
|
</p>
|
|
8103
9126
|
<p>Ensure this URI is listed in your <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a> OAuth client's authorized redirect URIs.</p>
|
|
8104
9127
|
<p style="color:#888">You can close this tab.</p>
|
|
@@ -16429,7 +17452,7 @@ function getCurrentUsageDay() {
|
|
|
16429
17452
|
import crypto20 from "crypto";
|
|
16430
17453
|
import { eq as eq24, and as and13, sql as sql8 } from "drizzle-orm";
|
|
16431
17454
|
var log2 = createLogger("GscSync");
|
|
16432
|
-
function
|
|
17455
|
+
function formatDate3(d) {
|
|
16433
17456
|
return d.toISOString().split("T")[0];
|
|
16434
17457
|
}
|
|
16435
17458
|
function daysAgo(n) {
|
|
@@ -16469,9 +17492,9 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
16469
17492
|
saveConfigPatch(opts.config);
|
|
16470
17493
|
}
|
|
16471
17494
|
const lagOffset = GSC_DATA_LAG_DAYS;
|
|
16472
|
-
const endDate =
|
|
17495
|
+
const endDate = formatDate3(daysAgo(lagOffset));
|
|
16473
17496
|
const days = opts.full ? 480 : opts.days ?? 30;
|
|
16474
|
-
const startDate =
|
|
17497
|
+
const startDate = formatDate3(daysAgo(days + lagOffset));
|
|
16475
17498
|
log2.info("fetch.start", { runId, projectId, propertyId: conn.propertyId, startDate, endDate });
|
|
16476
17499
|
const rows = await fetchSearchAnalytics(accessToken, conn.propertyId, {
|
|
16477
17500
|
startDate,
|
|
@@ -16567,7 +17590,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
16567
17590
|
reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1;
|
|
16568
17591
|
}
|
|
16569
17592
|
}
|
|
16570
|
-
const snapshotDate =
|
|
17593
|
+
const snapshotDate = formatDate3(/* @__PURE__ */ new Date());
|
|
16571
17594
|
db.delete(gscCoverageSnapshots).where(and13(eq24(gscCoverageSnapshots.projectId, projectId), eq24(gscCoverageSnapshots.date, snapshotDate))).run();
|
|
16572
17595
|
db.insert(gscCoverageSnapshots).values({
|
|
16573
17596
|
id: crypto20.randomUUID(),
|
|
@@ -20588,6 +21611,7 @@ export {
|
|
|
20588
21611
|
determineCitationState,
|
|
20589
21612
|
computeCompetitorOverlap,
|
|
20590
21613
|
extractRecommendedCompetitors,
|
|
21614
|
+
renderReportHtml,
|
|
20591
21615
|
setGoogleAuthConfig,
|
|
20592
21616
|
formatAuditFactorScore,
|
|
20593
21617
|
listAgentProviders,
|