siteread 0.0.0-stage → 0.2.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.
@@ -0,0 +1,363 @@
1
+ // An access log (nginx / Apache "combined", plain or .gz) → people, sources and crawlers.
2
+ //
3
+ // Who counts as a person is the rule synacl.com settled on after its first counts were wrong
4
+ // (2026-09-28, stricter than the read a week earlier, so compare only reads made the same way):
5
+ // a person = an IP that sent no bot User-Agent, never asked for a scanner path or drew a 444,
6
+ // and loaded at least one asset (CSS/JS/font/image). Link checkers, previewers and most bots
7
+ // fetch the HTML and stop; a browser always pulls the stylesheet.
8
+ // That rule alone let one fleet through: AWS us-west-2 IPs with a single Linux Chrome UA that
9
+ // FAKED a Google Referer and loaded assets. They were the whole of one day's "Google bump" that
10
+ // Search Console showed as zero clicks. So a person whose first visit claims a search-engine
11
+ // Referer from a cloud provider's address space is set aside and counted on its own line.
12
+ //
13
+ // Crawlers are counted on page fetches (and on sitemap URLs when a sitemap is given), each IP
14
+ // checked against its vendor's list. The raw UA count lies: one week synacl.com's log showed
15
+ // "Googlebot 6.9k", and it was scanners wearing Googlebot's UA against the bare IP. Googlebot
16
+ // also crawls from 192.178.x, not only 66.249.x: a first-octet guess undercounted it by a third.
17
+
18
+ import { createReadStream } from 'node:fs';
19
+ import { createGunzip } from 'node:zlib';
20
+ import { createInterface } from 'node:readline';
21
+ import { detectAgent, detectCrawler } from '../agents.js';
22
+ import { isScannerPath } from '../scanner.js';
23
+ import { classifySource, refererHost } from './referrer.js';
24
+
25
+ // ip - user [time] "METHOD target PROTO" status bytes "referer" "ua" (anything after is ignored).
26
+ // nginx escapes a quote inside a field as \x22, Apache as \"; both are handled.
27
+ const LINE_RE = /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+)(?: [^"]*)?" (\d{3}) \S+(?: "((?:[^"\\]|\\.)*)" "((?:[^"\\]|\\.)*)")?/;
28
+ const MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
29
+
30
+ export const ASSET_RE = /\.(css|js|mjs|woff2?|ttf|otf|png|jpe?g|gif|webp|avif|svg|ico)(\?|$)/i;
31
+ // A page: no extension, a trailing slash, or .html. Feeds, robots.txt, sitemaps and JSON are not.
32
+ const PAGE_RE = /^[^?]*(\/|\/[^./?]+|\.html?)(\?|$)/i;
33
+ const NOT_PAGE_RE = /^\/(robots\.txt|sitemap[^/]*\.xml|favicon|\.well-known\/|feed\/?$|rss\/?$|api\/)/i;
34
+ // UAs that are not a browser and not in the vendor tables: libraries, monitors, previewers.
35
+ const GENERIC_BOT_RE = /bot\b|bot\/|crawl|spider|slurp|fetch|scan|curl\/|wget\/|python|go-http|java\/|okhttp|axios|node-fetch|undici|httpclient|libwww|headless|phantom|lighthouse|pingdom|uptime|monitor|preview|validator|feed|^-?$/i;
36
+ // First octets of AWS (3, 13, 15, 16, 18, 44, 52, 54) and GCP (34, 35) address space. Crude on
37
+ // purpose: it only ever applies to a search-referred "person", where a residential reader on
38
+ // these octets is far rarer than a fake. Exported so a site can see what it filters.
39
+ export const CLOUD_FIRST_OCTETS = Object.freeze([3, 13, 15, 16, 18, 34, 35, 44, 52, 54]);
40
+ const CLOUD = new Set(CLOUD_FIRST_OCTETS);
41
+
42
+ /** One combined-format line → request, or null. */
43
+ export function parseLine(line) {
44
+ const m = LINE_RE.exec(line);
45
+ if (!m) return null;
46
+ const [, ip, time, method, target, status, referer = '-', ua = '-'] = m;
47
+ const t = /^(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(time);
48
+ if (!t || !(t[2] in MONTHS)) return null;
49
+ const off = (t[7] === '-' ? -1 : 1) * (Number(t[8]) * 60 + Number(t[9]));
50
+ const ts = Date.UTC(+t[3], MONTHS[t[2]], +t[1], +t[4], +t[5], +t[6]) - off * 60_000;
51
+ const unesc = s => s.replace(/\\x22/g, '"').replace(/\\"/g, '"');
52
+ return { ip, ts, method, target, status: Number(status), referer: unesc(referer), ua: unesc(ua) };
53
+ }
54
+
55
+ /** Day (YYYY-MM-DD) of a timestamp in an IANA zone. */
56
+ export function dayIn(tz) {
57
+ const f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' });
58
+ return ts => f.format(ts);
59
+ }
60
+
61
+ const pathOnly = t => String(t).split('?')[0];
62
+
63
+ function countBy(map, key, n = 1) { map.set(key, (map.get(key) ?? 0) + n); }
64
+ function top(map, limit) {
65
+ return [...map].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).slice(0, limit);
66
+ }
67
+
68
+ /**
69
+ * A streaming digest. add(line) for every log line, then finish(verifier) once.
70
+ * @param {object} o
71
+ * @param {string} [o.site] the site's host; guessed from Referers when absent
72
+ * @param {string} [o.tz] zone for days (default America/Los_Angeles, like GSC)
73
+ * @param {string} [o.from] [o.to] keep only days in [from, to]
74
+ * @param {Set<string>} [o.sitemap] sitemap paths, to count crawler coverage on
75
+ * @param {Array<{label: string, re: RegExp}>} [o.goals] matched against "METHOD path status"
76
+ * @param {RegExp} [o.teamPath] an IP that got a 2xx on such a path is the site's own team
77
+ * @param {Set<string>} [o.excludeIps]
78
+ * @param {RegExp} [o.allowPath] a site's own paths that look like scanner probes
79
+ */
80
+ export function createLogDigest({
81
+ site = null, tz = 'America/Los_Angeles', from = null, to = null, sitemap = null,
82
+ goals = [], teamPath = null, excludeIps = new Set(), allowPath = null, limit = 15,
83
+ } = {}) {
84
+ const day = dayIn(tz);
85
+ const ips = new Map(); // ip → per-IP facts
86
+ const crawl = new Map(); // `${agent}\t${ip}` → { agent, ip, ua, requests, pages: Map(day → [path]) }
87
+ const generic = { requests: 0, ips: new Set(), uas: new Map() };
88
+ const scannerPaths = new Map();
89
+ const refHosts = new Map();
90
+ let lines = 0, unparsed = 0, outOfRange = 0, firstDay = null, lastDay = null;
91
+
92
+ function ipRec(ip) {
93
+ let r = ips.get(ip);
94
+ if (!r) {
95
+ r = { bot: false, scanner: false, asset: false, browser: false, team: false, views: [], goals: new Set() };
96
+ ips.set(ip, r);
97
+ }
98
+ return r;
99
+ }
100
+
101
+ function add(line) {
102
+ lines++;
103
+ const q = parseLine(line);
104
+ if (!q) { unparsed++; return; }
105
+ const d = day(q.ts);
106
+ if ((from && d < from) || (to && d > to)) { outOfRange++; return; }
107
+ if (!firstDay || d < firstDay) firstDay = d;
108
+ if (!lastDay || d > lastDay) lastDay = d;
109
+ const r = ipRec(q.ip);
110
+ const path = pathOnly(q.target);
111
+ const ok = q.status === 200 || q.status === 304;
112
+
113
+ // The 444 rule: a scanner path or a 444 marks the IP for the whole log, whatever its UA.
114
+ if (q.status === 444 || isScannerPath(q.target, { allow: allowPath })) {
115
+ r.scanner = true;
116
+ countBy(scannerPaths, path);
117
+ }
118
+ if (teamPath && q.status >= 200 && q.status < 300 && teamPath.test(path)) r.team = true;
119
+
120
+ const named = detectAgent(q.ua) || detectCrawler(q.ua);
121
+ const isPage = q.method === 'GET' && ok && PAGE_RE.test(q.target) && !NOT_PAGE_RE.test(path) && !isScannerPath(q.target, { allow: allowPath });
122
+ if (named) {
123
+ r.bot = true;
124
+ const k = `${named.key}\t${q.ip}`;
125
+ let c = crawl.get(k);
126
+ if (!c) { c = { agent: named.key, ip: q.ip, ua: q.ua, requests: 0, pages: new Map() }; crawl.set(k, c); }
127
+ c.requests++;
128
+ if (isPage) {
129
+ if (!c.pages.has(d)) c.pages.set(d, []);
130
+ c.pages.get(d).push(path);
131
+ }
132
+ return;
133
+ }
134
+ if (GENERIC_BOT_RE.test(q.ua) || !/^Mozilla\//.test(q.ua)) {
135
+ r.bot = true;
136
+ generic.requests++;
137
+ generic.ips.add(q.ip);
138
+ countBy(generic.uas, q.ua.slice(0, 80));
139
+ return;
140
+ }
141
+ r.browser = true;
142
+ if (q.method === 'GET' && ok && ASSET_RE.test(q.target)) {
143
+ r.asset = true;
144
+ // A stylesheet's Referer is the page that loaded it: the site's own host, almost always.
145
+ const h = refererHost(q.referer);
146
+ if (h) countBy(refHosts, h.replace(/^www\./, ''));
147
+ }
148
+ if (isPage) r.views.push({ ts: q.ts, day: d, path, target: q.target, referer: q.referer });
149
+ for (const g of goals) if (g.re.test(`${q.method} ${path} ${q.status}`)) r.goals.add(g.label);
150
+ }
151
+
152
+ function finish(verifier) {
153
+ // The site's own host: given, or the Referer host browsers' asset loads carry most.
154
+ const siteHost = site ? site.replace(/^www\./, '') : (top(refHosts, 1)[0]?.[0] ?? null);
155
+
156
+ // People.
157
+ const people = [];
158
+ const set = { bots: 0, scanners: 0, noAssets: 0, team: 0, excluded: 0, cloudFakes: 0 };
159
+ const cloudFakeEngines = new Map();
160
+ for (const [ip, r] of ips) {
161
+ if (!r.views.length && !r.browser) continue;
162
+ if (r.scanner) { if (r.browser) set.scanners++; continue; }
163
+ if (r.bot && !r.browser) continue;
164
+ if (!r.views.length) continue;
165
+ if (excludeIps.has(ip)) { set.excluded++; continue; }
166
+ if (r.team) { set.team++; continue; }
167
+ if (!r.asset) { set.noAssets++; continue; }
168
+ if (r.bot) { set.bots++; continue; }
169
+ r.views.sort((a, b) => a.ts - b.ts);
170
+ const land = r.views[0];
171
+ const src = classifySource(land.referer, land.target, { site: siteHost });
172
+ if (src.kind === 'search' && CLOUD.has(Number(ip.split('.')[0]))) { set.cloudFakes++; countBy(cloudFakeEngines, src.source); continue; }
173
+ people.push({ ip, land, src, views: r.views, goals: r.goals });
174
+ }
175
+
176
+ const perDay = new Map();
177
+ const bySource = new Map();
178
+ const pagePeople = new Map();
179
+ for (const p of people) {
180
+ const days = new Set(p.views.map(v => v.day));
181
+ for (const d of days) countBy(perDay, d);
182
+ const key = p.src.source;
183
+ let s = bySource.get(key);
184
+ if (!s) { s = { source: key, kind: p.src.kind, people: 0, landings: new Map(), days: new Map(), goals: new Map() }; bySource.set(key, s); }
185
+ s.people++;
186
+ countBy(s.days, p.land.day);
187
+ countBy(s.landings, p.land.path);
188
+ for (const g of p.goals) countBy(s.goals, g);
189
+ for (const path of new Set(p.views.map(v => v.path))) countBy(pagePeople, path);
190
+ }
191
+ // Every day of the log, zeros included: an average over only the days someone came is not one.
192
+ const dayList = [];
193
+ if (firstDay) {
194
+ for (let t = Date.parse(`${firstDay}T00:00:00Z`); t <= Date.parse(`${lastDay}T00:00:00Z`); t += 86_400_000) {
195
+ const date = new Date(t).toISOString().slice(0, 10);
196
+ dayList.push({ date, people: perDay.get(date) ?? 0 });
197
+ }
198
+ }
199
+
200
+ // People per 7-day bucket ending on the last day: unique IPs within each bucket.
201
+ const weeks = [];
202
+ if (lastDay) {
203
+ const end = Date.parse(`${lastDay}T00:00:00Z`);
204
+ const buckets = new Map();
205
+ const goalBuckets = new Map();
206
+ const weekOf = d => Math.floor((end - Date.parse(`${d}T00:00:00Z`)) / (7 * 86_400_000));
207
+ for (const p of people) {
208
+ for (const v of p.views) {
209
+ const w = weekOf(v.day);
210
+ if (!buckets.has(w)) buckets.set(w, new Set());
211
+ buckets.get(w).add(p.ip);
212
+ }
213
+ // A goal belongs to the week of the person's first page view in the log.
214
+ const w = weekOf(p.land.day);
215
+ if (!goalBuckets.has(w)) goalBuckets.set(w, new Map());
216
+ for (const g of p.goals) countBy(goalBuckets.get(w), g);
217
+ }
218
+ for (const w of [...buckets.keys()].sort((a, b) => b - a)) {
219
+ const iso = ms => new Date(ms).toISOString().slice(0, 10);
220
+ const lo = iso(end - (7 * w + 6) * 86_400_000);
221
+ const gb = goalBuckets.get(w);
222
+ weeks.push({
223
+ from: lo < firstDay ? firstDay : lo, to: iso(end - 7 * w * 86_400_000), people: buckets.get(w).size,
224
+ goals: Object.fromEntries(goals.map(g => [g.label, gb?.get(g.label) ?? 0])),
225
+ });
226
+ }
227
+ }
228
+
229
+ const sources = [...bySource.values()]
230
+ .sort((a, b) => b.people - a.people || a.source.localeCompare(b.source))
231
+ .map(s => ({
232
+ source: s.source, kind: s.kind, people: s.people,
233
+ landings: top(s.landings, 5).map(([path, n]) => ({ path, people: n })),
234
+ // People by the day of their first page view (the click a search console counts).
235
+ landingDays: Object.fromEntries([...s.days].sort((a, b) => a[0].localeCompare(b[0]))),
236
+ goals: Object.fromEntries(top(s.goals, 50)),
237
+ }));
238
+ const goalTotals = Object.fromEntries(goals.map(g => [g.label, people.filter(p => p.goals.has(g.label)).length]));
239
+
240
+ // Crawlers: one verdict per (agent, IP). isKnownScannerIp is the 444 rule from this log.
241
+ const isKnownScannerIp = ip => ips.get(ip)?.scanner === true;
242
+ const agents = new Map();
243
+ for (const c of crawl.values()) {
244
+ const v = verifier
245
+ ? verifier.check(c.ip, c.ua)
246
+ : { verdict: 'unknown', reason: 'not-checked', kind: detectAgent(c.ua) ? 'ai' : 'crawler' };
247
+ const verdict = v.verdict === 'unknown' && isKnownScannerIp(c.ip) ? 'spoofed' : v.verdict;
248
+ c.verdict = verdict;
249
+ let a = agents.get(c.agent);
250
+ if (!a) {
251
+ const e = detectAgent(c.ua) || detectCrawler(c.ua);
252
+ a = {
253
+ agent: c.agent, kind: detectAgent(c.ua) ? 'ai' : 'crawler', purpose: e?.purpose ?? null,
254
+ requests: 0, ips: 0,
255
+ verified: { ips: 0, requests: 0, pageFetches: 0, sitemapFetches: 0, pages: new Set(), perDay: new Map() },
256
+ spoofed: { ips: 0, requests: 0 },
257
+ unknown: { ips: 0, requests: 0, pageFetches: 0 },
258
+ };
259
+ agents.set(c.agent, a);
260
+ }
261
+ a.requests += c.requests;
262
+ a.ips++;
263
+ const fetches = [...c.pages.values()].flat();
264
+ if (verdict === 'verified') {
265
+ const b = a.verified;
266
+ b.ips++; b.requests += c.requests; b.pageFetches += fetches.length;
267
+ for (const [d, ps] of c.pages) {
268
+ const counted = sitemap ? ps.filter(p => sitemap.has(p)) : ps;
269
+ countBy(b.perDay, d, counted.length);
270
+ b.sitemapFetches += sitemap ? counted.length : 0;
271
+ for (const p of ps) b.pages.add(p);
272
+ }
273
+ } else if (verdict === 'spoofed') {
274
+ a.spoofed.ips++; a.spoofed.requests += c.requests;
275
+ } else {
276
+ a.unknown.ips++; a.unknown.requests += c.requests; a.unknown.pageFetches += fetches.length;
277
+ }
278
+ }
279
+ const crawlers = [...agents.values()]
280
+ .sort((x, y) => y.verified.pageFetches - x.verified.pageFetches || y.requests - x.requests || x.agent.localeCompare(y.agent))
281
+ .map(a => {
282
+ const v = a.verified;
283
+ const covered = sitemap ? [...v.pages].filter(p => sitemap.has(p)).length : null;
284
+ const offSitemap = sitemap ? [...v.pages].filter(p => !sitemap.has(p)).sort() : null;
285
+ return {
286
+ agent: a.agent, kind: a.kind, purpose: a.purpose, requests: a.requests, ips: a.ips,
287
+ verified: {
288
+ ips: v.ips, requests: v.requests, pageFetches: v.pageFetches,
289
+ sitemapFetches: sitemap ? v.sitemapFetches : null,
290
+ uniquePages: v.pages.size, sitemapCovered: covered,
291
+ offSitemapPages: offSitemap ? offSitemap.slice(0, 10) : null,
292
+ perWeek: weekly(v.perDay, lastDay, firstDay),
293
+ },
294
+ spoofed: a.spoofed,
295
+ unknown: a.unknown,
296
+ };
297
+ });
298
+
299
+ let scannerIps = 0;
300
+ for (const r of ips.values()) if (r.scanner) scannerIps++;
301
+
302
+ return {
303
+ range: { from: firstDay, to: lastDay, tz },
304
+ site: { host: siteHost, guessed: !site },
305
+ lines: { total: lines, unparsed, outOfRange },
306
+ people: {
307
+ total: people.length,
308
+ perDay: dayList,
309
+ avgPerDay: dayList.length ? Math.round((10 * dayList.reduce((s, d) => s + d.people, 0)) / dayList.length) / 10 : 0,
310
+ weeks,
311
+ setAside: { ...set, cloudFakeReferers: Object.fromEntries(top(cloudFakeEngines, 10)) },
312
+ },
313
+ sources,
314
+ goals: goalTotals,
315
+ pages: top(pagePeople, limit).map(([path, n]) => ({ path, people: n })),
316
+ crawlers,
317
+ sitemap: sitemap ? sitemapCoverage(sitemap, crawlers, crawl, limit) : null,
318
+ otherBots: { requests: generic.requests, ips: generic.ips.size, topUserAgents: top(generic.uas, 5).map(([ua, n]) => ({ ua, requests: n })) },
319
+ scanners: { ips: scannerIps, topPaths: top(scannerPaths, 5).map(([path, n]) => ({ path, requests: n })) },
320
+ };
321
+ }
322
+
323
+ return { add, finish };
324
+ }
325
+
326
+ // Sitemap URLs no verified search crawler fetched in this log. On synacl.com one clean guide was
327
+ // never fetched by Googlebot in 21 days and had 0 impressions; it was linked from too few pages.
328
+ function sitemapCoverage(sitemap, crawlers, crawl, limit) {
329
+ const fetched = new Set();
330
+ for (const c of crawl.values()) {
331
+ if (c.verdict !== 'verified' || crawlers.find(x => x.agent === c.agent)?.purpose !== 'search') continue;
332
+ for (const ps of c.pages.values()) for (const p of ps) fetched.add(p);
333
+ }
334
+ const never = [...sitemap].filter(p => !fetched.has(p));
335
+ return { urls: sitemap.size, fetchedBySearch: sitemap.size - never.length, neverFetched: never.slice(0, limit), neverFetchedCount: never.length };
336
+ }
337
+
338
+ // Verified fetches per 7-day bucket ending on the last day: sitemap URLs only when a sitemap was given.
339
+ function weekly(perDay, lastDay, firstDay) {
340
+ if (!lastDay) return [];
341
+ const end = Date.parse(`${lastDay}T00:00:00Z`);
342
+ const b = new Map();
343
+ for (const [d, n] of perDay) {
344
+ const w = Math.floor((end - Date.parse(`${d}T00:00:00Z`)) / (7 * 86_400_000));
345
+ countBy(b, w, n);
346
+ }
347
+ const out = [];
348
+ const nWeeks = Math.floor((end - Date.parse(`${firstDay}T00:00:00Z`)) / (7 * 86_400_000));
349
+ for (let w = nWeeks; w >= 0; w--) {
350
+ const iso = ms => new Date(ms).toISOString().slice(0, 10);
351
+ const lo = iso(end - (7 * w + 6) * 86_400_000);
352
+ out.push({ from: lo < firstDay ? firstDay : lo, to: iso(end - 7 * w * 86_400_000), fetches: b.get(w) ?? 0 });
353
+ }
354
+ return out;
355
+ }
356
+
357
+ /** Feed every line of a log file (plain or .gz) to digest.add. */
358
+ export async function readLog(file, digest) {
359
+ let stream = createReadStream(file);
360
+ if (/\.gz$/i.test(file)) stream = stream.pipe(createGunzip());
361
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
362
+ for await (const line of rl) if (line) digest.add(line);
363
+ }
@@ -0,0 +1,77 @@
1
+ // Where a visit came from: the Referer of its first page view, or the landing URL's utm_source.
2
+ //
3
+ // utm_source wins because the AI assistants mostly send no Referer: ChatGPT tags its links
4
+ // `?utm_source=chatgpt.com`, and on synacl.com that tag was the only trace of what became the
5
+ // channel with the most tool use per visitor (2026-09). ChatGPT traffic never appears in GSC.
6
+
7
+ // [source, kind, host test]. First match wins, so gemini.google.com sits before google.
8
+ const SOURCES = [
9
+ ['chatgpt', 'ai', /(^|\.)(chatgpt\.com|chat\.openai\.com)$/],
10
+ ['perplexity', 'ai', /(^|\.)perplexity\.ai$/],
11
+ ['claude', 'ai', /(^|\.)claude\.ai$/],
12
+ ['gemini', 'ai', /^gemini\.google\.com$/],
13
+ ['copilot', 'ai', /^copilot\.microsoft\.com$/],
14
+ ['deepseek', 'ai', /(^|\.)deepseek\.com$/],
15
+ ['google', 'search', /(^|\.)google\.[a-z.]+$/],
16
+ ['bing', 'search', /(^|\.)bing\.com$/],
17
+ ['duckduckgo', 'search', /(^|\.)duckduckgo\.com$/],
18
+ ['yandex', 'search', /(^|\.)yandex\.[a-z.]+$|(^|\.)ya\.ru$/],
19
+ ['baidu', 'search', /(^|\.)baidu\.com$/],
20
+ ['yahoo', 'search', /(^|\.)yahoo\.[a-z.]+$/],
21
+ ['ecosia', 'search', /(^|\.)ecosia\.org$/],
22
+ ['brave', 'search', /^search\.brave\.com$/],
23
+ ['naver', 'search', /(^|\.)naver\.com$/],
24
+ ['reddit', 'social', /(^|\.)reddit\.com$|^redd\.it$/],
25
+ ['hackernews', 'social', /^news\.ycombinator\.com$/],
26
+ ['x', 'social', /^(t\.co|x\.com|(www\.|mobile\.)?twitter\.com)$/],
27
+ ['linkedin', 'social', /(^|\.)linkedin\.com$|^lnkd\.in$/],
28
+ ['facebook', 'social', /(^|\.)facebook\.com$|^fb\.me$/],
29
+ ['youtube', 'social', /(^|\.)youtube\.com$|^youtu\.be$/],
30
+ ['dev.to', 'social', /^dev\.to$/],
31
+ ['github', 'social', /(^|\.)github\.com$/],
32
+ ['medium', 'social', /(^|\.)medium\.com$/],
33
+ ['lobsters', 'social', /^lobste\.rs$/],
34
+ ['bluesky', 'social', /^bsky\.app$/],
35
+ ];
36
+
37
+ const UTM = new Map([
38
+ ['chatgpt.com', 'chatgpt'], ['chatgpt', 'chatgpt'], ['openai', 'chatgpt'],
39
+ ['perplexity', 'perplexity'], ['perplexity.ai', 'perplexity'], ['claude.ai', 'claude'], ['claude', 'claude'],
40
+ ['copilot.com', 'copilot'], ['gemini', 'gemini'], ['reddit', 'reddit'], ['hn', 'hackernews'],
41
+ ['news.ycombinator.com', 'hackernews'], ['twitter', 'x'], ['x', 'x'], ['linkedin', 'linkedin'],
42
+ ['devto', 'dev.to'], ['dev.to', 'dev.to'], ['newsletter', 'newsletter'], ['email', 'email'],
43
+ ]);
44
+ const KIND = new Map(SOURCES.map(([s, k]) => [s, k]));
45
+
46
+ function hostOf(url) {
47
+ try { return new URL(url).hostname.toLowerCase(); } catch { return null; }
48
+ }
49
+
50
+ const bare = h => h.replace(/^www\./, '');
51
+
52
+ /**
53
+ * { source, kind } for a landing. kind: ai · search · social · referral · internal · direct.
54
+ * `site` is the site's own host (www. ignored); a Referer from it is `internal`.
55
+ */
56
+ export function classifySource(referer, target, { site = null } = {}) {
57
+ const q = String(target ?? '').split('?')[1];
58
+ if (q) {
59
+ const utm = new URLSearchParams(q).get('utm_source');
60
+ if (utm) {
61
+ const u = utm.toLowerCase();
62
+ const s = UTM.get(u) ?? u;
63
+ return { source: s, kind: KIND.get(s) ?? 'campaign' };
64
+ }
65
+ }
66
+ if (!referer || referer === '-') return { source: 'direct', kind: 'direct' };
67
+ const h = hostOf(referer);
68
+ if (!h) return { source: 'direct', kind: 'direct' };
69
+ if (site && bare(h) === bare(site)) return { source: 'internal', kind: 'internal' };
70
+ for (const [source, kind, re] of SOURCES) if (re.test(h)) return { source, kind };
71
+ return { source: bare(h), kind: 'referral' };
72
+ }
73
+
74
+ /** The referer's host, for guessing the site's own host when `site` is not given. */
75
+ export function refererHost(referer) {
76
+ return referer && referer !== '-' ? hostOf(referer) : null;
77
+ }
@@ -0,0 +1,41 @@
1
+ // The smallest zip reader that opens a Search Console export: walks the central directory and
2
+ // inflates stored (0) or deflated (8) entries with node:zlib. No zip64, no encryption, which
3
+ // no GSC export uses.
4
+
5
+ import { inflateRawSync } from 'node:zlib';
6
+
7
+ const EOCD_SIG = 0x06054b50;
8
+ const CEN_SIG = 0x02014b50;
9
+ const LOC_SIG = 0x04034b50;
10
+
11
+ /** Map of entry name → Buffer for every file in a zip. Throws on anything it can't read. */
12
+ export function readZip(buf) {
13
+ // The end-of-central-directory record sits in the last 22 + up to 65535 (comment) bytes.
14
+ let eocd = -1;
15
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
16
+ if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
17
+ }
18
+ if (eocd < 0) throw new Error('not a zip file');
19
+ const count = buf.readUInt16LE(eocd + 10);
20
+ let p = buf.readUInt32LE(eocd + 16);
21
+ const out = new Map();
22
+ for (let n = 0; n < count; n++) {
23
+ if (buf.readUInt32LE(p) !== CEN_SIG) throw new Error('broken zip central directory');
24
+ const method = buf.readUInt16LE(p + 10);
25
+ const size = buf.readUInt32LE(p + 20);
26
+ const nameLen = buf.readUInt16LE(p + 28);
27
+ const extraLen = buf.readUInt16LE(p + 30);
28
+ const commentLen = buf.readUInt16LE(p + 32);
29
+ const local = buf.readUInt32LE(p + 42);
30
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
31
+ p += 46 + nameLen + extraLen + commentLen;
32
+ if (name.endsWith('/')) continue;
33
+ if (buf.readUInt32LE(local) !== LOC_SIG) throw new Error(`broken zip entry ${name}`);
34
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
35
+ const data = buf.subarray(start, start + size);
36
+ if (method === 0) out.set(name, Buffer.from(data));
37
+ else if (method === 8) out.set(name, inflateRawSync(data));
38
+ else throw new Error(`zip entry ${name} uses compression method ${method}`);
39
+ }
40
+ return out;
41
+ }
@@ -0,0 +1,49 @@
1
+ // A store that keeps the lists in one JSON file, for the CLI and for anyone who wants the
2
+ // lists to survive a restart without a database.
3
+ //
4
+ // The default CLI path is under os.tmpdir(), which on Linux is a directory every user shares. A
5
+ // planted lists file would make a spoofed IP "verified", so: the directory must be ours and not
6
+ // group/world-writable, the file must be ours, and writes go through an O_EXCL temp file
7
+ // (never through a symlink someone left at that name) and an atomic rename.
8
+
9
+ import { mkdir, readFile, writeFile, rename, lstat, unlink } from 'node:fs/promises';
10
+ import { dirname } from 'node:path';
11
+
12
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null;
13
+
14
+ function trusted(st) {
15
+ if (uid === null) return true; // Windows: no POSIX owner to check
16
+ return st.uid === uid && (st.mode & 0o022) === 0;
17
+ }
18
+
19
+ /** { get, set } over one JSON file. A missing, foreign or unparseable file reads as null. */
20
+ export function fileStore(file) {
21
+ const dir = dirname(file);
22
+ return {
23
+ async get() {
24
+ let text;
25
+ try {
26
+ const [d, f] = await Promise.all([lstat(dir), lstat(file)]);
27
+ if (!d.isDirectory() || !trusted(d) || !f.isFile() || !trusted(f)) return null;
28
+ text = await readFile(file, 'utf8');
29
+ } catch (err) {
30
+ if (err.code === 'ENOENT') return null;
31
+ throw err;
32
+ }
33
+ try { return JSON.parse(text); } catch { return null; }
34
+ },
35
+ async set(doc) {
36
+ await mkdir(dir, { recursive: true, mode: 0o700 });
37
+ const d = await lstat(dir);
38
+ if (!d.isDirectory() || !trusted(d)) throw new Error(`refusing to write into ${dir}: not ours, or writable by others`);
39
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
40
+ await writeFile(tmp, JSON.stringify(doc), { mode: 0o600, flag: 'wx' });
41
+ try {
42
+ await rename(tmp, file);
43
+ } catch (err) {
44
+ await unlink(tmp).catch(() => {});
45
+ throw err;
46
+ }
47
+ },
48
+ };
49
+ }
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // siteread: is that crawler who it says it is? Vendor IP lists + a User-Agent table +
2
+ // scanner-path signatures, with zero dependencies. See README.md.
3
+
4
+ export { AGENTS, CRAWLERS, detectAgent, detectCrawler, isSearchCrawler } from './agents.js';
5
+ export { SCANNER_PATH_RE, isScannerPath } from './scanner.js';
6
+ export {
7
+ SOURCES, parseCidr, parseList, normIp,
8
+ REFRESH_AFTER_MS, DROP_AFTER_MS, MIN_PREFIX, MAX_PREFIXES, MAX_BODY_CHARS,
9
+ } from './ip-ranges.js';
10
+ export { createVerifier, memoryStore } from './verifier.js';
11
+ export { fileStore } from './file-store.js';
12
+ export { VERSION } from './version.js';
13
+ export { digest, parseSitemap, parseGoal } from './digest/index.js';
14
+ export { parseCsv } from './digest/csv.js';
15
+ export { classifySource } from './digest/referrer.js';
16
+ export { parseLine } from './digest/log.js';