@ainyc/canonry 3.4.7 → 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-3WMODJE5.js → chunk-5G7S6SEP.js} +1164 -143
- package/dist/cli.js +2 -999
- package/dist/index.js +1 -1
- package/package.json +8 -8
- 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",
|
|
@@ -7613,7 +8633,7 @@ async function batchRunReports(accessToken, propertyId, requests) {
|
|
|
7613
8633
|
const data = await res.json();
|
|
7614
8634
|
return data.reports;
|
|
7615
8635
|
}
|
|
7616
|
-
function
|
|
8636
|
+
function formatDate2(d) {
|
|
7617
8637
|
return d.toISOString().split("T")[0];
|
|
7618
8638
|
}
|
|
7619
8639
|
var AI_REFERRAL_SOURCE_FILTERS = [
|
|
@@ -7643,7 +8663,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
7643
8663
|
while (pageCount < GA4_MAX_PAGES) {
|
|
7644
8664
|
pageCount++;
|
|
7645
8665
|
const request = {
|
|
7646
|
-
dateRanges: [{ startDate:
|
|
8666
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7647
8667
|
dimensions: [
|
|
7648
8668
|
{ name: "date" },
|
|
7649
8669
|
{ name: "landingPagePlusQueryString" }
|
|
@@ -7678,7 +8698,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
7678
8698
|
while (organicPageCount < GA4_MAX_PAGES) {
|
|
7679
8699
|
organicPageCount++;
|
|
7680
8700
|
const organicRequest = {
|
|
7681
|
-
dateRanges: [{ startDate:
|
|
8701
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7682
8702
|
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
7683
8703
|
metrics: [{ name: "sessions" }],
|
|
7684
8704
|
dimensionFilter: {
|
|
@@ -7706,7 +8726,7 @@ async function fetchTrafficByLandingPage(accessToken, propertyId, days) {
|
|
|
7706
8726
|
while (directPageCount < GA4_MAX_PAGES) {
|
|
7707
8727
|
directPageCount++;
|
|
7708
8728
|
const directRequest = {
|
|
7709
|
-
dateRanges: [{ startDate:
|
|
8729
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7710
8730
|
dimensions: [{ name: "date" }, { name: "landingPagePlusQueryString" }],
|
|
7711
8731
|
metrics: [{ name: "sessions" }],
|
|
7712
8732
|
dimensionFilter: {
|
|
@@ -7755,7 +8775,7 @@ async function verifyConnectionWithToken(accessToken, propertyId) {
|
|
|
7755
8775
|
const startDate = /* @__PURE__ */ new Date();
|
|
7756
8776
|
startDate.setDate(startDate.getDate() - 1);
|
|
7757
8777
|
await runReport(accessToken, propertyId, {
|
|
7758
|
-
dateRanges: [{ startDate:
|
|
8778
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7759
8779
|
dimensions: [{ name: "date" }],
|
|
7760
8780
|
metrics: [{ name: "sessions" }],
|
|
7761
8781
|
limit: 1
|
|
@@ -7770,7 +8790,7 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
|
7770
8790
|
const startDate = /* @__PURE__ */ new Date();
|
|
7771
8791
|
startDate.setDate(startDate.getDate() - syncDays);
|
|
7772
8792
|
ga4Log("info", "fetch-aggregate.start", { propertyId, days: syncDays });
|
|
7773
|
-
const dateRange = { startDate:
|
|
8793
|
+
const dateRange = { startDate: formatDate2(startDate), endDate: formatDate2(endDate) };
|
|
7774
8794
|
const batchRes = await batchRunReports(accessToken, propertyId, [
|
|
7775
8795
|
{
|
|
7776
8796
|
dateRanges: [dateRange],
|
|
@@ -7794,8 +8814,8 @@ async function fetchAggregateSummary(accessToken, propertyId, days) {
|
|
|
7794
8814
|
const totalRow = batchRes[0]?.rows?.[0];
|
|
7795
8815
|
const organicRow = batchRes[1]?.rows?.[0];
|
|
7796
8816
|
const summary = {
|
|
7797
|
-
periodStart:
|
|
7798
|
-
periodEnd:
|
|
8817
|
+
periodStart: formatDate2(startDate),
|
|
8818
|
+
periodEnd: formatDate2(endDate),
|
|
7799
8819
|
totalSessions: parseInt(totalRow?.metricValues[0]?.value ?? "0", 10) || 0,
|
|
7800
8820
|
totalUsers: parseInt(totalRow?.metricValues[1]?.value ?? "0", 10) || 0,
|
|
7801
8821
|
totalOrganicSessions: parseInt(organicRow?.metricValues[0]?.value ?? "0", 10) || 0
|
|
@@ -7824,7 +8844,7 @@ async function fetchAiReferrals(accessToken, propertyId, days) {
|
|
|
7824
8844
|
while (aiRefPageCount < GA4_MAX_PAGES) {
|
|
7825
8845
|
aiRefPageCount++;
|
|
7826
8846
|
const request = {
|
|
7827
|
-
dateRanges: [{ startDate:
|
|
8847
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7828
8848
|
dimensions: [
|
|
7829
8849
|
{ name: "date" },
|
|
7830
8850
|
{ name: sourceDim },
|
|
@@ -7902,7 +8922,7 @@ async function fetchSocialReferrals(accessToken, propertyId, days) {
|
|
|
7902
8922
|
let offset = 0;
|
|
7903
8923
|
while (true) {
|
|
7904
8924
|
const request = {
|
|
7905
|
-
dateRanges: [{ startDate:
|
|
8925
|
+
dateRanges: [{ startDate: formatDate2(startDate), endDate: formatDate2(endDate) }],
|
|
7906
8926
|
dimensions: [
|
|
7907
8927
|
{ name: "date" },
|
|
7908
8928
|
{ name: "sessionSource" },
|
|
@@ -8056,10 +9076,10 @@ async function googleRoutes(app, opts) {
|
|
|
8056
9076
|
return reply.status(500).send("Google OAuth not configured");
|
|
8057
9077
|
}
|
|
8058
9078
|
const store = requireConnectionStore();
|
|
8059
|
-
const
|
|
9079
|
+
const escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8060
9080
|
const { code, state, error } = request.query;
|
|
8061
9081
|
if (error) {
|
|
8062
|
-
const safeError =
|
|
9082
|
+
const safeError = escapeHtml2(String(error));
|
|
8063
9083
|
const errorHtml = error === "redirect_uri_mismatch" ? `<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
8064
9084
|
<h2 style="color:#ef4444">Redirect URI mismatch</h2>
|
|
8065
9085
|
<p>Google rejected the OAuth callback because the redirect URI is not registered.</p>
|
|
@@ -8070,7 +9090,7 @@ async function googleRoutes(app, opts) {
|
|
|
8070
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 ? (() => {
|
|
8071
9091
|
try {
|
|
8072
9092
|
const s = verifySignedState(request.query.state, stateSecret);
|
|
8073
|
-
return
|
|
9093
|
+
return escapeHtml2(String(s?.redirectUri ?? "Could not determine URI"));
|
|
8074
9094
|
} catch {
|
|
8075
9095
|
return "Could not determine URI";
|
|
8076
9096
|
}
|
|
@@ -8099,9 +9119,9 @@ async function googleRoutes(app, opts) {
|
|
|
8099
9119
|
return reply.type("text/html").send(
|
|
8100
9120
|
`<html><body style="font-family:system-ui;padding:40px;max-width:600px;margin:0 auto">
|
|
8101
9121
|
<h2 style="color:#ef4444">Token exchange failed</h2>
|
|
8102
|
-
<p>${
|
|
9122
|
+
<p>${escapeHtml2(msg)}</p>
|
|
8103
9123
|
<p><strong>Redirect URI used:</strong><br>
|
|
8104
|
-
<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>
|
|
8105
9125
|
</p>
|
|
8106
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>
|
|
8107
9127
|
<p style="color:#888">You can close this tab.</p>
|
|
@@ -16432,7 +17452,7 @@ function getCurrentUsageDay() {
|
|
|
16432
17452
|
import crypto20 from "crypto";
|
|
16433
17453
|
import { eq as eq24, and as and13, sql as sql8 } from "drizzle-orm";
|
|
16434
17454
|
var log2 = createLogger("GscSync");
|
|
16435
|
-
function
|
|
17455
|
+
function formatDate3(d) {
|
|
16436
17456
|
return d.toISOString().split("T")[0];
|
|
16437
17457
|
}
|
|
16438
17458
|
function daysAgo(n) {
|
|
@@ -16472,9 +17492,9 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
16472
17492
|
saveConfigPatch(opts.config);
|
|
16473
17493
|
}
|
|
16474
17494
|
const lagOffset = GSC_DATA_LAG_DAYS;
|
|
16475
|
-
const endDate =
|
|
17495
|
+
const endDate = formatDate3(daysAgo(lagOffset));
|
|
16476
17496
|
const days = opts.full ? 480 : opts.days ?? 30;
|
|
16477
|
-
const startDate =
|
|
17497
|
+
const startDate = formatDate3(daysAgo(days + lagOffset));
|
|
16478
17498
|
log2.info("fetch.start", { runId, projectId, propertyId: conn.propertyId, startDate, endDate });
|
|
16479
17499
|
const rows = await fetchSearchAnalytics(accessToken, conn.propertyId, {
|
|
16480
17500
|
startDate,
|
|
@@ -16570,7 +17590,7 @@ async function executeGscSync(db, runId, projectId, opts) {
|
|
|
16570
17590
|
reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1;
|
|
16571
17591
|
}
|
|
16572
17592
|
}
|
|
16573
|
-
const snapshotDate =
|
|
17593
|
+
const snapshotDate = formatDate3(/* @__PURE__ */ new Date());
|
|
16574
17594
|
db.delete(gscCoverageSnapshots).where(and13(eq24(gscCoverageSnapshots.projectId, projectId), eq24(gscCoverageSnapshots.date, snapshotDate))).run();
|
|
16575
17595
|
db.insert(gscCoverageSnapshots).values({
|
|
16576
17596
|
id: crypto20.randomUUID(),
|
|
@@ -20591,6 +21611,7 @@ export {
|
|
|
20591
21611
|
determineCitationState,
|
|
20592
21612
|
computeCompetitorOverlap,
|
|
20593
21613
|
extractRecommendedCompetitors,
|
|
21614
|
+
renderReportHtml,
|
|
20594
21615
|
setGoogleAuthConfig,
|
|
20595
21616
|
formatAuditFactorScore,
|
|
20596
21617
|
listAgentProviders,
|