@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.
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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
- async function reportRoutes(app) {
3252
- app.get("/projects/:name/report", async (request, reply) => {
3253
- const project = resolveProject(app.db, request.params.name);
3254
- const keywordLookup = loadKeywordLookup(app.db, project.id);
3255
- const allRuns = app.db.select().from(runs).where(eq13(runs.projectId, project.id)).orderBy(desc6(runs.createdAt)).all();
3256
- const visibilityRuns = allRuns.filter((r) => r.kind === RunKinds["answer-visibility"]);
3257
- const latestRun = visibilityRuns.find(
3258
- (r) => r.status === RunStatuses.completed || r.status === RunStatuses.partial
3259
- ) ?? visibilityRuns[0];
3260
- const latestSnapshots = latestRun ? loadSnapshotsForRun(app.db, latestRun.id) : [];
3261
- const competitorRows = app.db.select().from(competitors).where(eq13(competitors.projectId, project.id)).all();
3262
- const competitorDomains = competitorRows.map((c) => c.domain);
3263
- const ownedDomains = parseJsonColumn(project.ownedDomains, []);
3264
- const projectDomains = [project.canonicalDomain, ...ownedDomains];
3265
- const citationScorecard = buildCitationScorecard(latestSnapshots, keywordLookup);
3266
- const competitorLandscape = buildCompetitorLandscape(
3267
- latestSnapshots,
3268
- competitorDomains,
3269
- projectDomains,
3270
- keywordLookup
3271
- );
3272
- const aiSourceOrigin = buildAiSourceOrigin(latestSnapshots, projectDomains, competitorDomains);
3273
- const trackedKeywords = [...keywordLookup.byId.values()];
3274
- const gscSection = buildGscSection(
3275
- app.db,
3276
- project.id,
3277
- project.displayName,
3278
- project.canonicalDomain,
3279
- trackedKeywords
3280
- );
3281
- const gaSection = buildGaSection(app.db, project.id);
3282
- const socialSection = buildSocialReferrals(app.db, project.id);
3283
- const aiReferralsSection = buildAiReferrals(app.db, project.id);
3284
- const indexingHealthSection = buildIndexingHealth(app.db, project.id);
3285
- const citationsTrend = buildCitationsTrend(app.db, project.id, keywordLookup);
3286
- const insightList = buildInsightList(app.db, project.id);
3287
- const orchestratorInput = loadOrchestratorInput(app.db, project);
3288
- const contentOpportunities = buildContentTargetRows(orchestratorInput);
3289
- const contentGaps = buildContentGapRows(orchestratorInput);
3290
- const groundingSources = buildContentSourceRows(orchestratorInput);
3291
- const insightDerivedSteps = buildRecommendedNextSteps(insightList);
3292
- const recommendedNextSteps = mapOpportunitiesToNextSteps(
3293
- contentOpportunities,
3294
- insightDerivedSteps
3295
- );
3296
- let latestCited = 0;
3297
- let latestConsidered = 0;
3298
- for (const snap of latestSnapshots) {
3299
- if (!keywordLookup.byId.has(snap.keywordId)) continue;
3300
- latestConsidered++;
3301
- if (snap.citationState === "cited") latestCited++;
3302
- }
3303
- const citationRate = latestConsidered > 0 ? Math.round(latestCited / latestConsidered * 100) : 0;
3304
- const trendBaseline = isTrendBaseline(citationsTrend);
3305
- const latestPoint = citationsTrend.at(-1);
3306
- const previousPoint = citationsTrend.length >= 2 ? citationsTrend.at(-2) : null;
3307
- let trend = "unknown";
3308
- if (!trendBaseline && latestPoint) {
3309
- const latestRunOnTrend = latestRun?.id === latestPoint.runId;
3310
- const currentRate = latestRunOnTrend ? latestPoint.citationRate : citationRate;
3311
- const priorRate = latestRunOnTrend ? previousPoint?.citationRate : latestPoint.citationRate;
3312
- if (priorRate !== void 0) {
3313
- if (currentRate > priorRate) trend = "up";
3314
- else if (currentRate < priorRate) trend = "down";
3315
- else trend = "flat";
3316
- }
3317
- }
3318
- const findings = buildExecutiveFindings(
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
- citationsTrend,
3322
- trendBaseline,
3323
- insightList,
3324
- competitorLandscape.competitors
3325
- );
3326
- const periodStart = citationsTrend[0]?.date ?? null;
3327
- const periodEnd = citationsTrend.at(-1)?.date ?? null;
3328
- const dto = {
3329
- meta: {
3330
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3331
- project: {
3332
- id: project.id,
3333
- name: project.name,
3334
- displayName: project.displayName,
3335
- canonicalDomain: project.canonicalDomain,
3336
- country: project.country,
3337
- language: project.language
3338
- },
3339
- periodStart,
3340
- periodEnd
3341
- },
3342
- executiveSummary: {
3343
- citationRate,
3344
- trend,
3345
- keywordCount: keywordLookup.byId.size,
3346
- competitorCount: competitorDomains.length,
3347
- providerCount: citationScorecard.providers.length,
3348
- gsc: gscSection ? {
3349
- clicks: gscSection.totalClicks,
3350
- impressions: gscSection.totalImpressions,
3351
- ctr: gscSection.ctr,
3352
- avgPosition: gscSection.avgPosition
3353
- } : null,
3354
- ga: gaSection ? {
3355
- sessions: gaSection.totalSessions,
3356
- users: gaSection.totalUsers,
3357
- periodStart: gaSection.periodStart,
3358
- periodEnd: gaSection.periodEnd
3359
- } : null,
3360
- findings
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 formatDate(d) {
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: formatDate(startDate), endDate: formatDate(endDate) }],
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: formatDate(startDate), endDate: formatDate(endDate) }],
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: formatDate(startDate), endDate: formatDate(endDate) }],
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: formatDate(startDate), endDate: formatDate(endDate) }],
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: formatDate(startDate), endDate: formatDate(endDate) };
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: formatDate(startDate),
7795
- periodEnd: formatDate(endDate),
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: formatDate(startDate), endDate: formatDate(endDate) }],
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: formatDate(startDate), endDate: formatDate(endDate) }],
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 escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9079
+ const escapeHtml2 = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8057
9080
  const { code, state, error } = request.query;
8058
9081
  if (error) {
8059
- const safeError = escapeHtml(String(error));
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 escapeHtml(String(s?.redirectUri ?? "Could not determine URI"));
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>${escapeHtml(msg)}</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">${escapeHtml(redirectUri)}</code>
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 formatDate2(d) {
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 = formatDate2(daysAgo(lagOffset));
17495
+ const endDate = formatDate3(daysAgo(lagOffset));
16473
17496
  const days = opts.full ? 480 : opts.days ?? 30;
16474
- const startDate = formatDate2(daysAgo(days + lagOffset));
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 = formatDate2(/* @__PURE__ */ new Date());
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,