1import { createHash } from 'node:crypto';
2
3export const ENGINE_VERSION = '1.0.0';
4export interface SerpRow {
5 rank: number;
6 url: string;
7 title: string;
8}
9export interface Movement {
10 recordType: 'rank-change';
11 comparisonId: string;
12 changeType: 'GAIN' | 'LOSS' | 'UNCHANGED' | 'ENTRY' | 'EXIT' | 'URL_SWAP';
13 url: string;
14 previousUrl: string | null;
15 title: string;
16 baselineRank: number | null;
17 currentRank: number | null;
18 delta: number | null;
19 engineVersion: string;
20}
21export interface Duplicate {
22 recordType: 'duplicate-result';
23 comparisonId: string;
24 snapshot: 'baseline' | 'current';
25 canonicalUrl: string;
26 ranks: number[];
27 engineVersion: string;
28}
29export interface RunResult {
30 inputHash: string;
31 movements: Movement[];
32 duplicates: Duplicate[];
33 summary: {
34 recordType: 'serp-summary';
35 status: 'SUCCEEDED';
36 comparisonId: string;
37 query: string;
38 engine: string;
39 locale: string;
40 device: string;
41 gains: number;
42 losses: number;
43 unchanged: number;
44 entries: number;
45 exits: number;
46 urlSwaps: number;
47 duplicateGroups: number;
48 inputHash: string;
49 engineVersion: string;
50 };
51}
52export class DomainError extends Error {
53 public constructor(public readonly code: string, message: string) {
54 super(message);
55 }
56}
57function canonical(value: unknown): string {
58 if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
59 if (value && typeof value === 'object') {
60 return `{${Object.entries(value as Record<string, unknown>)
61 .sort(([a], [b]) => a.localeCompare(b))
62 .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
63 .join(',')}}`;
64 }
65 return JSON.stringify(value);
66}
67function hash(value: unknown): string {
68 return createHash('sha256').update(canonical(value)).digest('hex');
69}
70function object(value: unknown, label: string): Record<string, unknown> {
71 if (!value || typeof value !== 'object' || Array.isArray(value))
72 throw new DomainError('INVALID_INPUT', `${label} must be an object`);
73 return value as Record<string, unknown>;
74}
75function requiredText(value: unknown, label: string, maximum = 500): string {
76 if (typeof value !== 'string' || !value.trim() || value.length > maximum)
77 throw new DomainError('INVALID_INPUT', `${label} is required and must not exceed ${maximum} characters`);
78 return value.trim();
79}
80function normalizeTitle(value: string): string {
81 return value.normalize('NFKC').toLocaleLowerCase('en-US').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().replace(/\s+/g, ' ');
82}
83export function canonicalUrl(value: string): string {
84 let url: URL;
85 try {
86 url = new URL(value);
87 } catch {
88 throw new DomainError('INVALID_INPUT', 'result URL must be a valid absolute HTTP or HTTPS URL');
89 }
90 if (!['http:', 'https:'].includes(url.protocol))
91 throw new DomainError('INVALID_INPUT', 'result URL must use HTTP or HTTPS');
92 url.hash = '';
93 url.hostname = url.hostname.toLowerCase().replace(/^www\./, '');
94 for (const key of [...url.searchParams.keys()]) {
95 if (/^(utm_|gclid$|fbclid$|ref$)/i.test(key)) url.searchParams.delete(key);
96 }
97 url.searchParams.sort();
98 url.pathname = url.pathname.replace(/\/+$/, '') || '/';
99 return url.toString();
100}
101function parseRows(value: unknown, label: string, maximum: number): SerpRow[] {
102 if (!Array.isArray(value) || value.length > maximum)
103 throw new DomainError('RESOURCE_LIMIT_EXCEEDED', `${label} must contain at most ${maximum} rows`);
104 return value.map((candidate, index) => {
105 const row = object(candidate, `${label}[${index}]`);
106 if (!Number.isInteger(row.rank) || (row.rank as number) < 1 || (row.rank as number) > 100_000)
107 throw new DomainError('INVALID_INPUT', `${label}[${index}].rank must be an integer from 1 to 100000`);
108 const url = requiredText(row.url, `${label}[${index}].url`, 2_000);
109 canonicalUrl(url);
110 return {
111 rank: row.rank as number,
112 url,
113 title: requiredText(row.title, `${label}[${index}].title`, 1_000),
114 };
115 });
116}
117function indexRows(rows: SerpRow[], snapshot: 'baseline' | 'current'): {
118 canonical: Map<string, SerpRow>;
119 duplicates: Omit<Duplicate, 'comparisonId'>[];
120} {
121 const grouped = new Map<string, SerpRow[]>();
122 for (const row of rows) {
123 const key = canonicalUrl(row.url);
124 grouped.set(key, [...(grouped.get(key) ?? []), row]);
125 }
126 return {
127 canonical: new Map([...grouped].map(([key, group]) => [
128 key,
129 [...group].sort((a, b) => a.rank - b.rank || a.title.localeCompare(b.title))[0]!,
130 ])),
131 duplicates: [...grouped]
132 .filter(([, group]) => group.length > 1)
133 .map(([url, group]) => ({
134 recordType: 'duplicate-result' as const,
135 snapshot,
136 canonicalUrl: url,
137 ranks: group.map((row) => row.rank).sort((a, b) => a - b),
138 engineVersion: ENGINE_VERSION,
139 }))
140 .sort((a, b) => a.canonicalUrl.localeCompare(b.canonicalUrl)),
141 };
142}
143export function processInput(value: unknown): RunResult {
144 const input = object(value, 'input');
145 const comparisonId = requiredText(input.comparisonId, 'comparisonId', 120);
146 const query = requiredText(input.query, 'query');
147 const engine = requiredText(input.engine, 'engine', 80).toLocaleLowerCase('en-US');
148 const locale = requiredText(input.locale, 'locale', 80);
149 const device = requiredText(input.device, 'device', 80).toLocaleLowerCase('en-US');
150 const maximum = input.maxResultsPerSnapshot ?? 1_000;
151 if (!Number.isInteger(maximum) || (maximum as number) < 1 || (maximum as number) > 1_000)
152 throw new DomainError('INVALID_INPUT', 'maxResultsPerSnapshot must be an integer from 1 to 1000');
153 const baseline = parseRows(input.baseline, 'baseline', maximum as number);
154 const current = parseRows(input.current, 'current', maximum as number);
155 if (baseline.length + current.length === 0)
156 throw new DomainError('INVALID_INPUT', 'at least one snapshot must contain a result');
157 if (Buffer.byteLength(canonical({ baseline, current }), 'utf8') > 10_000_000)
158 throw new DomainError('RESOURCE_LIMIT_EXCEEDED', 'snapshot input exceeds 10 MB');
159 const oldIndex = indexRows(baseline, 'baseline');
160 const newIndex = indexRows(current, 'current');
161 const movements: Movement[] = [];
162 const exited = new Map<string, SerpRow>();
163 const entered = new Map<string, SerpRow>();
164 for (const [url, row] of oldIndex.canonical) if (!newIndex.canonical.has(url)) exited.set(url, row);
165 for (const [url, row] of newIndex.canonical) if (!oldIndex.canonical.has(url)) entered.set(url, row);
166 const exitByTitle = new Map<string, [string, SerpRow][]>();
167 for (const item of exited) {
168 const title = normalizeTitle(item[1].title);
169 exitByTitle.set(title, [...(exitByTitle.get(title) ?? []), item]);
170 }
171 for (const [url, row] of [...entered]) {
172 const candidates = exitByTitle.get(normalizeTitle(row.title));
173 if (candidates?.length === 1) {
174 const [oldUrl, oldRow] = candidates[0]!;
175 movements.push({
176 recordType: 'rank-change',
177 comparisonId,
178 changeType: 'URL_SWAP',
179 url,
180 previousUrl: oldUrl,
181 title: row.title,
182 baselineRank: oldRow.rank,
183 currentRank: row.rank,
184 delta: oldRow.rank - row.rank,
185 engineVersion: ENGINE_VERSION,
186 });
187 exited.delete(oldUrl);
188 entered.delete(url);
189 exitByTitle.delete(normalizeTitle(row.title));
190 }
191 }
192 for (const url of [...new Set([...oldIndex.canonical.keys(), ...newIndex.canonical.keys()])].sort()) {
193 const before = oldIndex.canonical.get(url);
194 const after = newIndex.canonical.get(url);
195 if (!before || !after) continue;
196 const delta = before.rank - after.rank;
197 movements.push({
198 recordType: 'rank-change',
199 comparisonId,
200 changeType: delta > 0 ? 'GAIN' : delta < 0 ? 'LOSS' : 'UNCHANGED',
201 url,
202 previousUrl: null,
203 title: after.title,
204 baselineRank: before.rank,
205 currentRank: after.rank,
206 delta,
207 engineVersion: ENGINE_VERSION,
208 });
209 }
210 for (const [url, row] of [...entered].sort(([a], [b]) => a.localeCompare(b))) {
211 movements.push({
212 recordType: 'rank-change',
213 comparisonId,
214 changeType: 'ENTRY',
215 url,
216 previousUrl: null,
217 title: row.title,
218 baselineRank: null,
219 currentRank: row.rank,
220 delta: null,
221 engineVersion: ENGINE_VERSION,
222 });
223 }
224 for (const [url, row] of [...exited].sort(([a], [b]) => a.localeCompare(b))) {
225 movements.push({
226 recordType: 'rank-change',
227 comparisonId,
228 changeType: 'EXIT',
229 url,
230 previousUrl: null,
231 title: row.title,
232 baselineRank: row.rank,
233 currentRank: null,
234 delta: null,
235 engineVersion: ENGINE_VERSION,
236 });
237 }
238 const order = { GAIN: 0, LOSS: 1, UNCHANGED: 2, URL_SWAP: 3, ENTRY: 4, EXIT: 5 };
239 movements.sort((a, b) => order[a.changeType] - order[b.changeType] || a.url.localeCompare(b.url));
240 const duplicates = [...oldIndex.duplicates, ...newIndex.duplicates].map((item) => ({ ...item, comparisonId }));
241 const inputHash = hash({ comparisonId, query, engine, locale, device, baseline, current });
242 const count = (type: Movement['changeType']): number => movements.filter((row) => row.changeType === type).length;
243 return {
244 inputHash,
245 movements,
246 duplicates,
247 summary: {
248 recordType: 'serp-summary',
249 status: 'SUCCEEDED',
250 comparisonId,
251 query,
252 engine,
253 locale,
254 device,
255 gains: count('GAIN'),
256 losses: count('LOSS'),
257 unchanged: count('UNCHANGED'),
258 entries: count('ENTRY'),
259 exits: count('EXIT'),
260 urlSwaps: count('URL_SWAP'),
261 duplicateGroups: duplicates.length,
262 inputHash,
263 engineVersion: ENGINE_VERSION,
264 },
265 };
266}