|
| 1 | +/** |
| 2 | + * L2 — server-side aggregation for heavy statistics reports (SEARCH_QUERY, …). |
| 3 | + * |
| 4 | + * Pure functions, no network. Instead of returning thousands of raw TSV rows (which |
| 5 | + * the consumer then truncates to a sample), we compute the answer over 100% of rows |
| 6 | + * and return a compact, bounded summary: |
| 7 | + * - totals over ALL rows (the true period total — correctness for "сколько всего"); |
| 8 | + * - a top-N detail list by a chosen metric (ranking — adaptive 95% cutoff for Cost); |
| 9 | + * - a tail rollup of everything not shown; |
| 10 | + * - zero-click / zero-conversion counts and a conversions>clicks anomaly slice. |
| 11 | + * |
| 12 | + * Field-aware: conversion-dependent numbers appear only when "Conversions" was among |
| 13 | + * the requested fieldNames. No server-side n-gram/bigram extraction (naive whitespace |
| 14 | + * tokenization breaks on Russian morphology — the model groups queries better). |
| 15 | + */ |
| 16 | + |
| 17 | +const METRIC_FIELDS = new Set([ |
| 18 | + "Impressions", |
| 19 | + "Clicks", |
| 20 | + "Cost", |
| 21 | + "Ctr", |
| 22 | + "AvgCpc", |
| 23 | + "Conversions", |
| 24 | + "ConversionRate", |
| 25 | + "CostPerConversion", |
| 26 | + "BounceRate", |
| 27 | + "AvgPageviews", |
| 28 | +]); |
| 29 | + |
| 30 | +/** Metrics that are summable across rows (ratios like Ctr/AvgCpc are NOT summed). */ |
| 31 | +const SUMMABLE = ["Impressions", "Clicks", "Cost", "Conversions"]; |
| 32 | +/** Metrics shown in each detail row when present in the report. */ |
| 33 | +const DISPLAY_METRICS = [ |
| 34 | + "Impressions", |
| 35 | + "Clicks", |
| 36 | + "Cost", |
| 37 | + "Ctr", |
| 38 | + "AvgCpc", |
| 39 | + "Conversions", |
| 40 | + "ConversionRate", |
| 41 | + "CostPerConversion", |
| 42 | + "BounceRate", |
| 43 | +]; |
| 44 | + |
| 45 | +export const MAX_TOP_N = 100; |
| 46 | +const DEFAULT_TOP_N = 50; |
| 47 | +const COST_COVERAGE = 0.95; // adaptive cutoff: enough rows to cover 95% of detail Cost |
| 48 | + |
| 49 | +export interface AggregateOptions { |
| 50 | + sortBy?: string; |
| 51 | + order?: "asc" | "desc"; |
| 52 | + topN?: number; |
| 53 | + minCost?: number; |
| 54 | + queryContains?: string; |
| 55 | + zeroClicksOnly?: boolean; |
| 56 | + zeroConversionsOnly?: boolean; |
| 57 | +} |
| 58 | + |
| 59 | +export interface ReportAggregate { |
| 60 | + reportType: string; |
| 61 | + aggregated: true; |
| 62 | + rowsTotal: number; |
| 63 | + rowsReturned: number; |
| 64 | + sortBy: string; |
| 65 | + order: "asc" | "desc"; |
| 66 | + hasConversions: boolean; |
| 67 | + totals: Record<string, number>; |
| 68 | + counts: Record<string, number>; |
| 69 | + filtered?: Record<string, number>; |
| 70 | + top: Array<Record<string, number | string>>; |
| 71 | + tail: Record<string, number>; |
| 72 | + anomalies?: Array<Record<string, number | string>>; |
| 73 | + note: string; |
| 74 | +} |
| 75 | + |
| 76 | +/** Parse a Yandex Reports cell into a number ("--" / "" → 0; tolerant of comma decimals). */ |
| 77 | +function num(v: string | undefined): number { |
| 78 | + if (v == null) return 0; |
| 79 | + const s = String(v).trim(); |
| 80 | + if (s === "" || s === "--") return 0; |
| 81 | + const n = parseFloat(s.replace(",", ".")); |
| 82 | + return Number.isFinite(n) ? n : 0; |
| 83 | +} |
| 84 | + |
| 85 | +interface ParsedRow { |
| 86 | + dims: Record<string, string>; |
| 87 | + m: Record<string, number>; |
| 88 | +} |
| 89 | + |
| 90 | +/** Splits a (headerless) TSV body into rows keyed positionally by fieldNames. */ |
| 91 | +export function parseRows(tsv: string, fieldNames: string[]): ParsedRow[] { |
| 92 | + const rows: ParsedRow[] = []; |
| 93 | + for (const line of tsv.split("\n")) { |
| 94 | + if (!line.trim()) continue; |
| 95 | + const cells = line.split("\t"); |
| 96 | + const dims: Record<string, string> = {}; |
| 97 | + const m: Record<string, number> = {}; |
| 98 | + fieldNames.forEach((f, i) => { |
| 99 | + const cell = cells[i] ?? ""; |
| 100 | + if (METRIC_FIELDS.has(f)) m[f] = num(cell); |
| 101 | + else dims[f] = cell; |
| 102 | + }); |
| 103 | + rows.push({ dims, m }); |
| 104 | + } |
| 105 | + return rows; |
| 106 | +} |
| 107 | + |
| 108 | +function primaryDimension(fieldNames: string[]): string { |
| 109 | + for (const f of ["Query", "Criterion", "AdGroupName", "CampaignName"]) { |
| 110 | + if (fieldNames.includes(f)) return f; |
| 111 | + } |
| 112 | + return fieldNames.find((f) => !METRIC_FIELDS.has(f)) ?? ""; |
| 113 | +} |
| 114 | + |
| 115 | +function sumMetrics(rows: ParsedRow[], keys: string[]): Record<string, number> { |
| 116 | + const t: Record<string, number> = {}; |
| 117 | + for (const k of keys) t[k] = 0; |
| 118 | + for (const r of rows) for (const k of keys) t[k] += r.m[k] ?? 0; |
| 119 | + // Round Cost to 2 decimals to avoid float dust. |
| 120 | + if ("Cost" in t) t.Cost = Math.round(t.Cost * 100) / 100; |
| 121 | + return t; |
| 122 | +} |
| 123 | + |
| 124 | +export function aggregateReport( |
| 125 | + tsv: string, |
| 126 | + fieldNames: string[], |
| 127 | + reportType: string, |
| 128 | + opts: AggregateOptions = {}, |
| 129 | +): ReportAggregate { |
| 130 | + const rows = parseRows(tsv, fieldNames); |
| 131 | + const hasConversions = fieldNames.includes("Conversions"); |
| 132 | + const dimKey = primaryDimension(fieldNames); |
| 133 | + const sumKeys = SUMMABLE.filter((k) => fieldNames.includes(k)); |
| 134 | + |
| 135 | + // Totals over 100% of rows — the true period total (this is the correctness point). |
| 136 | + const totals = sumMetrics(rows, sumKeys); |
| 137 | + const counts: Record<string, number> = { |
| 138 | + zeroClick: rows.filter((r) => (r.m.Clicks ?? 0) === 0).length, |
| 139 | + }; |
| 140 | + if (hasConversions) { |
| 141 | + counts.zeroConversion = rows.filter( |
| 142 | + (r) => (r.m.Clicks ?? 0) > 0 && (r.m.Conversions ?? 0) === 0, |
| 143 | + ).length; |
| 144 | + } |
| 145 | + |
| 146 | + // Filtered set drives the detail list (totals above stay over 100%). |
| 147 | + let detail = rows; |
| 148 | + if (opts.minCost != null) detail = detail.filter((r) => (r.m.Cost ?? 0) >= opts.minCost!); |
| 149 | + if (opts.queryContains) { |
| 150 | + const q = opts.queryContains.toLowerCase(); |
| 151 | + detail = detail.filter((r) => (r.dims[dimKey] ?? "").toLowerCase().includes(q)); |
| 152 | + } |
| 153 | + if (opts.zeroClicksOnly) detail = detail.filter((r) => (r.m.Clicks ?? 0) === 0); |
| 154 | + if (opts.zeroConversionsOnly && hasConversions) { |
| 155 | + detail = detail.filter((r) => (r.m.Clicks ?? 0) > 0 && (r.m.Conversions ?? 0) === 0); |
| 156 | + } |
| 157 | + |
| 158 | + const sortBy = METRIC_FIELDS.has(opts.sortBy ?? "") ? (opts.sortBy as string) : "Cost"; |
| 159 | + const order = opts.order === "asc" ? "asc" : "desc"; |
| 160 | + const sorted = [...detail].sort((a, b) => { |
| 161 | + const d = (a.m[sortBy] ?? 0) - (b.m[sortBy] ?? 0); |
| 162 | + return order === "asc" ? d : -d; |
| 163 | + }); |
| 164 | + |
| 165 | + const cap = Math.min(Math.max(opts.topN ?? DEFAULT_TOP_N, 1), MAX_TOP_N); |
| 166 | + let take = Math.min(cap, sorted.length); |
| 167 | + // Adaptive: for Cost desc, stop once cumulative cost covers 95% of the detail set — |
| 168 | + // no point listing thousands of ~0-cost rows. |
| 169 | + if (sortBy === "Cost" && order === "desc") { |
| 170 | + const detailCost = sorted.reduce((s, r) => s + (r.m.Cost ?? 0), 0); |
| 171 | + if (detailCost > 0) { |
| 172 | + let cum = 0; |
| 173 | + let n = 0; |
| 174 | + for (const r of sorted) { |
| 175 | + cum += r.m.Cost ?? 0; |
| 176 | + n++; |
| 177 | + if (cum >= COST_COVERAGE * detailCost) break; |
| 178 | + } |
| 179 | + take = Math.min(cap, n); |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + const topRows = sorted.slice(0, take); |
| 184 | + const top = topRows.map((r) => { |
| 185 | + const o: Record<string, number | string> = { ...r.dims }; |
| 186 | + for (const k of DISPLAY_METRICS) if (k in r.m) o[k] = r.m[k]; |
| 187 | + return o; |
| 188 | + }); |
| 189 | + |
| 190 | + // tail rolls up the detail rows not shown, so top + tail reconstruct the detail set. |
| 191 | + const tailRows = sorted.slice(take); |
| 192 | + const tail = { rows: tailRows.length, ...sumMetrics(tailRows, sumKeys) }; |
| 193 | + |
| 194 | + const anomalies = hasConversions |
| 195 | + ? rows |
| 196 | + .filter((r) => (r.m.Conversions ?? 0) > (r.m.Clicks ?? 0)) |
| 197 | + .slice(0, 10) |
| 198 | + .map((r) => ({ |
| 199 | + [dimKey]: r.dims[dimKey] ?? "", |
| 200 | + clicks: r.m.Clicks ?? 0, |
| 201 | + conversions: r.m.Conversions ?? 0, |
| 202 | + reason: "conversions>clicks", |
| 203 | + })) |
| 204 | + : []; |
| 205 | + |
| 206 | + const filtered = |
| 207 | + detail.length !== rows.length |
| 208 | + ? { rows: detail.length, ...sumMetrics(detail, sumKeys) } |
| 209 | + : undefined; |
| 210 | + |
| 211 | + return { |
| 212 | + reportType, |
| 213 | + aggregated: true, |
| 214 | + rowsTotal: rows.length, |
| 215 | + rowsReturned: top.length, |
| 216 | + sortBy, |
| 217 | + order, |
| 218 | + hasConversions, |
| 219 | + totals, // over 100% of rows — the full-period total |
| 220 | + counts, |
| 221 | + ...(filtered ? { filtered } : {}), |
| 222 | + top, |
| 223 | + tail, |
| 224 | + ...(anomalies.length ? { anomalies } : {}), |
| 225 | + note: |
| 226 | + `totals are over all ${rows.length} row(s) (the full period, not a sample); ` + |
| 227 | + `top lists ${top.length} row(s) by ${sortBy} ${order}` + |
| 228 | + (filtered ? ` from ${detail.length} filtered row(s)` : "") + |
| 229 | + "; tail rolls up the rest.", |
| 230 | + }; |
| 231 | +} |
0 commit comments