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.
- package/LICENSE +21 -0
- package/README.md +232 -2
- package/bin/siteread.js +277 -0
- package/package.json +35 -4
- package/src/agents.js +85 -0
- package/src/digest/csv.js +37 -0
- package/src/digest/gsc.js +233 -0
- package/src/digest/index.js +76 -0
- package/src/digest/log.js +363 -0
- package/src/digest/referrer.js +77 -0
- package/src/digest/zip.js +41 -0
- package/src/file-store.js +49 -0
- package/src/index.js +16 -0
- package/src/ip-ranges.js +219 -0
- package/src/scanner.js +56 -0
- package/src/verifier.js +221 -0
- package/src/version.js +2 -0
package/src/agents.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Who a request CLAIMS to be, from its User-Agent. A claim only: anyone can send any UA, and
|
|
2
|
+
// on the site this was extracted from most AI user-agents outside the vendors' IP lists were a
|
|
3
|
+
// scanner cycling through them (ip-ranges.js has the numbers). verifier.js turns a claim into a
|
|
4
|
+
// verdict.
|
|
5
|
+
//
|
|
6
|
+
// Ported from the AI-traffic classifier Synacl Labs runs on synacl.com (2026-10).
|
|
7
|
+
//
|
|
8
|
+
// purpose: `answer` fetched live while answering someone (ChatGPT-User, Perplexity-User)
|
|
9
|
+
// `search` building the assistant's search index (OAI-SearchBot, PerplexityBot)
|
|
10
|
+
// `training` a model-training crawl (GPTBot, ClaudeBot, CCBot)
|
|
11
|
+
|
|
12
|
+
// Ordered: the FIRST match on the User-Agent wins. Not AI (left out on purpose, see CRAWLERS):
|
|
13
|
+
// PetalBot, GoogleOther, Googlebot, bingbot. Copilot's fetches cannot be told apart from Bing's.
|
|
14
|
+
export const AGENTS = Object.freeze([
|
|
15
|
+
{ key: 'chatgpt-user', re: /ChatGPT-User/i, assistant: 'chatgpt', purpose: 'answer' },
|
|
16
|
+
{ key: 'oai-searchbot', re: /OAI-SearchBot/i, assistant: 'chatgpt', purpose: 'search' },
|
|
17
|
+
{ key: 'gptbot', re: /GPTBot/i, assistant: 'chatgpt', purpose: 'training' },
|
|
18
|
+
// Before claude-user: Claude Code's WebFetch sends `Claude-User (claude-code/2.1.292; …)` from
|
|
19
|
+
// the user's OWN machine, so it can never be in Anthropic's IP list and stays on the 444 rule.
|
|
20
|
+
{ key: 'claude-code', re: /^claude-code\/|^Claude-User \(claude-code\//i, assistant: 'claude', purpose: 'answer',
|
|
21
|
+
note: "Claude Code's WebFetch runs from the user's own machine, so no vendor list can cover it" },
|
|
22
|
+
{ key: 'claude-user', re: /Claude-User/i, assistant: 'claude', purpose: 'answer' },
|
|
23
|
+
{ key: 'claude-searchbot', re: /Claude-SearchBot/i, assistant: 'claude', purpose: 'search' },
|
|
24
|
+
{ key: 'claudebot', re: /ClaudeBot/i, assistant: 'claude', purpose: 'training' },
|
|
25
|
+
{ key: 'perplexity-user', re: /Perplexity-User/i, assistant: 'perplexity', purpose: 'answer' },
|
|
26
|
+
{ key: 'perplexitybot', re: /PerplexityBot/i, assistant: 'perplexity', purpose: 'search' },
|
|
27
|
+
{ key: 'applebot', re: /Applebot/i, assistant: 'apple', purpose: 'search' },
|
|
28
|
+
{ key: 'amazonbot', re: /Amazonbot/i, assistant: 'amazon', purpose: 'search' },
|
|
29
|
+
{ key: 'meta-externalfetcher', re: /meta-externalfetcher/i, assistant: 'meta', purpose: 'answer' },
|
|
30
|
+
{ key: 'meta-externalagent', re: /meta-externalagent/i, assistant: 'meta', purpose: 'training' },
|
|
31
|
+
{ key: 'duckassistbot', re: /DuckAssistBot/i, assistant: 'duckduckgo', purpose: 'answer' },
|
|
32
|
+
{ key: 'mistralai-user', re: /MistralAI-User/i, assistant: 'mistral', purpose: 'answer' },
|
|
33
|
+
{ key: 'youbot', re: /YouBot/i, assistant: 'you', purpose: 'search' },
|
|
34
|
+
// Not a real User-Agent (Google-Extended is a robots.txt token): always spoofed in practice.
|
|
35
|
+
// Google's crawlers obey it, but fetch as Googlebot; nothing real ever sends it as a UA.
|
|
36
|
+
{ key: 'google-extended', re: /Google-Extended/i, assistant: 'gemini', purpose: 'training',
|
|
37
|
+
robotsTokenOnly: true, note: 'Google-Extended is a robots.txt token, never a real user-agent' },
|
|
38
|
+
{ key: 'ccbot', re: /CCBot/i, assistant: 'commoncrawl', purpose: 'training' },
|
|
39
|
+
{ key: 'bytespider', re: /Bytespider/i, assistant: 'bytedance', purpose: 'training' },
|
|
40
|
+
{ key: 'cohere', re: /cohere-ai/i, assistant: 'cohere', purpose: 'training' },
|
|
41
|
+
{ key: 'deepseek', re: /DeepSeekBot/i, assistant: 'deepseek', purpose: 'training' },
|
|
42
|
+
{ key: 'xai', re: /xAI-Grok|GrokBot/i, assistant: 'grok', purpose: 'training' },
|
|
43
|
+
{ key: 'ai2bot', re: /AI2Bot/i, assistant: 'ai2', purpose: 'training' },
|
|
44
|
+
].map(Object.freeze));
|
|
45
|
+
|
|
46
|
+
// Classic search-engine and SEO-tool crawlers, the non-AI half of the original CRAWLER_RE. The
|
|
47
|
+
// AI names it also matched (GPTBot, OAI-SearchBot, ChatGPT, ClaudeBot, Applebot, Amazonbot,
|
|
48
|
+
// Bytespider) live in AGENTS, which is checked first. purpose: `search` (an engine's index),
|
|
49
|
+
// `seo` (a backlink/SEO tool's crawl) or `preview` (a link-preview fetch).
|
|
50
|
+
export const CRAWLERS = Object.freeze([
|
|
51
|
+
{ key: 'googlebot', re: /Googlebot/i, operator: 'google', purpose: 'search' },
|
|
52
|
+
{ key: 'bingbot', re: /bingbot/i, operator: 'bing', purpose: 'search' },
|
|
53
|
+
{ key: 'yandexbot', re: /YandexBot/i, operator: 'yandex', purpose: 'search' },
|
|
54
|
+
{ key: 'duckduckbot', re: /DuckDuckBot/i, operator: 'duckduckgo', purpose: 'search' },
|
|
55
|
+
{ key: 'slurp', re: /Slurp/i, operator: 'yahoo', purpose: 'search' },
|
|
56
|
+
{ key: 'petalbot', re: /PetalBot/i, operator: 'huawei', purpose: 'search' },
|
|
57
|
+
{ key: 'ahrefsbot', re: /AhrefsBot/i, operator: 'ahrefs', purpose: 'seo' },
|
|
58
|
+
{ key: 'semrushbot', re: /SemrushBot/i, operator: 'semrush', purpose: 'seo' },
|
|
59
|
+
{ key: 'mj12bot', re: /MJ12bot/i, operator: 'majestic', purpose: 'seo' },
|
|
60
|
+
{ key: 'facebookexternalhit', re: /facebookexternalhit/i, operator: 'meta', purpose: 'preview' },
|
|
61
|
+
].map(Object.freeze));
|
|
62
|
+
|
|
63
|
+
// One cheap test before the ordered walk: most lines are not an AI agent at all.
|
|
64
|
+
const ANY_AGENT_RE = new RegExp(AGENTS.map(a => a.re.source).join('|'), 'i');
|
|
65
|
+
const ANY_CRAWLER_RE = new RegExp(CRAWLERS.map(c => c.re.source).join('|'), 'i');
|
|
66
|
+
|
|
67
|
+
/** The AGENTS entry a User-Agent claims to be, or null. */
|
|
68
|
+
export function detectAgent(ua) {
|
|
69
|
+
if (typeof ua !== 'string' || !ua || !ANY_AGENT_RE.test(ua)) return null;
|
|
70
|
+
for (const a of AGENTS) if (a.re.test(ua)) return a;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The CRAWLERS entry a User-Agent claims to be, or null. Not an AI agent: those match AGENTS. */
|
|
75
|
+
export function detectCrawler(ua) {
|
|
76
|
+
if (typeof ua !== 'string' || !ua || !ANY_CRAWLER_RE.test(ua)) return null;
|
|
77
|
+
if (detectAgent(ua)) return null;
|
|
78
|
+
for (const c of CRAWLERS) if (c.re.test(ua)) return c;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the UA claims to be a classic search-engine or SEO crawler (never an AI agent). */
|
|
83
|
+
export function isSearchCrawler(ua) {
|
|
84
|
+
return detectCrawler(ua) !== null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// RFC 4180 CSV, enough for a Search Console export: quoted fields, "" escapes, CRLF or LF,
|
|
2
|
+
// a UTF-8 BOM. No dependencies.
|
|
3
|
+
|
|
4
|
+
/** Rows of a CSV text as arrays of strings. Blank lines are dropped. */
|
|
5
|
+
export function parseCsv(text) {
|
|
6
|
+
const s = String(text ?? '').replace(/^/, '');
|
|
7
|
+
const rows = [];
|
|
8
|
+
let row = [];
|
|
9
|
+
let field = '';
|
|
10
|
+
let quoted = false;
|
|
11
|
+
for (let i = 0; i < s.length; i++) {
|
|
12
|
+
const c = s[i];
|
|
13
|
+
if (quoted) {
|
|
14
|
+
if (c === '"') {
|
|
15
|
+
if (s[i + 1] === '"') { field += '"'; i++; } else quoted = false;
|
|
16
|
+
} else field += c;
|
|
17
|
+
} else if (c === '"') quoted = true;
|
|
18
|
+
else if (c === ',') { row.push(field); field = ''; }
|
|
19
|
+
else if (c === '\n' || c === '\r') {
|
|
20
|
+
if (c === '\r' && s[i + 1] === '\n') i++;
|
|
21
|
+
row.push(field); field = '';
|
|
22
|
+
if (row.some(f => f !== '')) rows.push(row);
|
|
23
|
+
row = [];
|
|
24
|
+
} else field += c;
|
|
25
|
+
}
|
|
26
|
+
row.push(field);
|
|
27
|
+
if (row.some(f => f !== '')) rows.push(row);
|
|
28
|
+
return rows;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The CSV as objects keyed by its header row. */
|
|
32
|
+
export function parseCsvObjects(text) {
|
|
33
|
+
const [head, ...rows] = parseCsv(text);
|
|
34
|
+
if (!head) return { header: [], rows: [] };
|
|
35
|
+
const header = head.map(h => h.trim());
|
|
36
|
+
return { header, rows: rows.map(r => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? '').trim()]))) };
|
|
37
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// A Google Search Console Performance export (the "Export → Download CSV" zip, or the folder it
|
|
2
|
+
// unzips to) → the numbers a weekly read needs.
|
|
3
|
+
//
|
|
4
|
+
// The method is the one that held up over six weekly reads of synacl.com (2026-09 → 10):
|
|
5
|
+
// - Totals come from the daily series (Dates.csv; older exports call it Chart.csv), never by
|
|
6
|
+
// summing Pages.csv: a page table double-counts an impression that showed two URLs.
|
|
7
|
+
// - Position is impression-weighted (Σ position·impressions / Σ impressions). A plain mean
|
|
8
|
+
// lets a page shown once at 90 outweigh one shown 50 times at 5.
|
|
9
|
+
// - GSC lags ~2-3 days and exports the last days as zeros. Trailing all-zero days are cut and
|
|
10
|
+
// the cut is reported, or every "last week" reads as a drop.
|
|
11
|
+
// - Pages are keyed by path: one export listed https://www. and the apex separately.
|
|
12
|
+
// - Queries.csv only names part of the traffic (anonymised queries are left out). The named
|
|
13
|
+
// share is reported so nobody reads the query table as the whole story.
|
|
14
|
+
// GSC days are US Pacific days; the log half (log.js) buckets in the same zone by default.
|
|
15
|
+
|
|
16
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { parseCsvObjects } from './csv.js';
|
|
19
|
+
import { readZip } from './zip.js';
|
|
20
|
+
|
|
21
|
+
// English export file names → table. A non-English UI exports translated names and headers.
|
|
22
|
+
const FILES = {
|
|
23
|
+
dates: ['Dates.csv', 'Chart.csv'],
|
|
24
|
+
queries: ['Queries.csv'],
|
|
25
|
+
pages: ['Pages.csv'],
|
|
26
|
+
countries: ['Countries.csv'],
|
|
27
|
+
devices: ['Devices.csv'],
|
|
28
|
+
appearance: ['Search appearance.csv'],
|
|
29
|
+
filters: ['Filters.csv'],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** { name → text } of the CSVs in an export folder or zip. */
|
|
33
|
+
export function readExport(p) {
|
|
34
|
+
const st = statSync(p);
|
|
35
|
+
const files = new Map();
|
|
36
|
+
if (st.isDirectory()) {
|
|
37
|
+
for (const f of readdirSync(p)) if (f.toLowerCase().endsWith('.csv')) files.set(f, readFileSync(path.join(p, f), 'utf8'));
|
|
38
|
+
} else {
|
|
39
|
+
for (const [name, buf] of readZip(readFileSync(p))) {
|
|
40
|
+
if (name.toLowerCase().endsWith('.csv')) files.set(path.basename(name), buf.toString('utf8'));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pick(files, names) {
|
|
47
|
+
for (const n of names) for (const [f, text] of files) if (f.toLowerCase() === n.toLowerCase()) return text;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// "Clicks", or in an export that split search types, the web column ("Web Clicks" or similar).
|
|
52
|
+
function col(header, metric) {
|
|
53
|
+
if (header.includes(metric)) return metric;
|
|
54
|
+
const hits = header.filter(h => h.toLowerCase().endsWith(metric.toLowerCase()));
|
|
55
|
+
return hits.find(h => /^web\b/i.test(h)) ?? hits[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function num(v) {
|
|
59
|
+
const n = Number(String(v ?? '').replace(/[%,\s]/g, ''));
|
|
60
|
+
return Number.isFinite(n) ? n : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function table(text, keyCol) {
|
|
64
|
+
if (text == null) return null;
|
|
65
|
+
const { header, rows } = parseCsvObjects(text);
|
|
66
|
+
const key = header.includes(keyCol) ? keyCol : header[0];
|
|
67
|
+
const c = { clicks: col(header, 'Clicks'), impressions: col(header, 'Impressions'), position: col(header, 'Position') };
|
|
68
|
+
if (!c.clicks || !c.impressions) throw new Error(`no Clicks/Impressions columns in the ${keyCol} table (header: ${header.join(', ')}); is the export in English?`);
|
|
69
|
+
return rows.map(r => ({
|
|
70
|
+
key: r[key],
|
|
71
|
+
clicks: num(r[c.clicks]),
|
|
72
|
+
impressions: num(r[c.impressions]),
|
|
73
|
+
position: c.position ? num(r[c.position]) : null,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const round = (n, d = 1) => (n == null || !Number.isFinite(n) ? null : Math.round(n * 10 ** d) / 10 ** d);
|
|
78
|
+
|
|
79
|
+
/** Totals of rows { clicks, impressions, position }: CTR in %, position impression-weighted. */
|
|
80
|
+
export function totals(rows) {
|
|
81
|
+
let clicks = 0, impressions = 0, wpos = 0, wimp = 0;
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
clicks += r.clicks;
|
|
84
|
+
impressions += r.impressions;
|
|
85
|
+
if (r.position) { wpos += r.position * r.impressions; wimp += r.impressions; }
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
impressions,
|
|
89
|
+
clicks,
|
|
90
|
+
ctr: impressions ? round((100 * clicks) / impressions, 1) : null,
|
|
91
|
+
position: wimp ? round(wpos / wimp, 1) : null,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DAY = 86_400_000;
|
|
96
|
+
const dayMs = d => Date.parse(`${d}T00:00:00Z`);
|
|
97
|
+
const isoDay = ms => new Date(ms).toISOString().slice(0, 10);
|
|
98
|
+
|
|
99
|
+
/** 7-day buckets ending on `last`, newest last; the oldest may be partial. */
|
|
100
|
+
export function weekBuckets(days, last) {
|
|
101
|
+
const end = dayMs(last);
|
|
102
|
+
const byWeek = new Map();
|
|
103
|
+
for (const d of days) {
|
|
104
|
+
const w = Math.floor((end - dayMs(d.date)) / (7 * DAY));
|
|
105
|
+
if (w < 0) continue;
|
|
106
|
+
if (!byWeek.has(w)) byWeek.set(w, []);
|
|
107
|
+
byWeek.get(w).push(d);
|
|
108
|
+
}
|
|
109
|
+
return [...byWeek.keys()].sort((a, b) => b - a).map(w => {
|
|
110
|
+
const rows = byWeek.get(w);
|
|
111
|
+
const t = totals(rows);
|
|
112
|
+
return {
|
|
113
|
+
from: isoDay(end - (7 * w + 6) * DAY),
|
|
114
|
+
to: isoDay(end - 7 * w * DAY),
|
|
115
|
+
days: rows.length,
|
|
116
|
+
...t,
|
|
117
|
+
impressionsPerDay: round(t.impressions / rows.length, 1),
|
|
118
|
+
clicksPerDay: round(t.clicks / rows.length, 2),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function pathOf(url) {
|
|
124
|
+
try { const u = new URL(url); return `${u.pathname}${u.search}`; } catch { return url; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function topRows(rows, limit, { merge } = {}) {
|
|
128
|
+
let list = rows;
|
|
129
|
+
if (merge) {
|
|
130
|
+
// Same path from two hosts (www + apex): sum, weighting position by impressions.
|
|
131
|
+
const by = new Map();
|
|
132
|
+
for (const r of rows) {
|
|
133
|
+
const k = merge(r.key);
|
|
134
|
+
if (!by.has(k)) by.set(k, []);
|
|
135
|
+
by.get(k).push(r);
|
|
136
|
+
}
|
|
137
|
+
list = [...by].map(([key, rs]) => ({ key, ...totals(rs) }));
|
|
138
|
+
} else {
|
|
139
|
+
list = rows.map(r => ({ ...r, ctr: r.impressions ? round((100 * r.clicks) / r.impressions, 1) : null, position: round(r.position, 1) }));
|
|
140
|
+
}
|
|
141
|
+
list.sort((a, b) => b.impressions - a.impressions || b.clicks - a.clicks || String(a.key).localeCompare(String(b.key)));
|
|
142
|
+
return { count: list.length, rows: limit ? list.slice(0, limit) : list };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function withPrev(cur, prev) {
|
|
146
|
+
if (!prev) return cur;
|
|
147
|
+
const old = new Map(prev.rows.map(r => [r.key, r]));
|
|
148
|
+
return {
|
|
149
|
+
...cur,
|
|
150
|
+
rows: cur.rows.map(r => {
|
|
151
|
+
const p = old.get(r.key);
|
|
152
|
+
return { ...r, prev: p ? { impressions: p.impressions, clicks: p.clicks, position: p.position } : null };
|
|
153
|
+
}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Digest one export. `split` (YYYY-MM-DD) adds a before/after comparison around a change, per
|
|
159
|
+
* day so unequal windows compare. `prev` is an earlier digest's `gsc` object: rows gain `prev`.
|
|
160
|
+
*/
|
|
161
|
+
export function digestGsc(files, { split = null, limit = 25, prev = null } = {}) {
|
|
162
|
+
const warnings = [];
|
|
163
|
+
const filters = (() => {
|
|
164
|
+
const t = pick(files, FILES.filters);
|
|
165
|
+
if (t == null) return null;
|
|
166
|
+
return Object.fromEntries(parseCsvObjects(t).rows.map(r => Object.values(r)).filter(v => v.length >= 2).map(([k, v]) => [k, v]));
|
|
167
|
+
})();
|
|
168
|
+
|
|
169
|
+
const datesText = pick(files, FILES.dates);
|
|
170
|
+
if (datesText == null) throw new Error('no Dates.csv (or Chart.csv) in the export: export from Search Console → Performance → Export → Download CSV');
|
|
171
|
+
let days = table(datesText, 'Date')
|
|
172
|
+
.filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d.key))
|
|
173
|
+
.map(d => ({ date: d.key, clicks: d.clicks, impressions: d.impressions, position: d.position }))
|
|
174
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
175
|
+
const trimmed = [];
|
|
176
|
+
while (days.length && days.at(-1).impressions === 0 && days.at(-1).clicks === 0) trimmed.unshift(days.pop().date);
|
|
177
|
+
if (trimmed.length) warnings.push(`cut ${trimmed.length} trailing day(s) with no data (GSC lag): ${trimmed.join(', ')}`);
|
|
178
|
+
if (!days.length) throw new Error('the export has no days with impressions');
|
|
179
|
+
|
|
180
|
+
const first = days[0].date;
|
|
181
|
+
const last = days.at(-1).date;
|
|
182
|
+
const all = totals(days);
|
|
183
|
+
const weeks = weekBuckets(days, last);
|
|
184
|
+
|
|
185
|
+
let windows = null;
|
|
186
|
+
if (split) {
|
|
187
|
+
const before = days.filter(d => d.date < split);
|
|
188
|
+
const after = days.filter(d => d.date >= split);
|
|
189
|
+
const side = (rows) => {
|
|
190
|
+
const t = totals(rows);
|
|
191
|
+
return { from: rows[0]?.date ?? null, to: rows.at(-1)?.date ?? null, days: rows.length, ...t,
|
|
192
|
+
impressionsPerDay: rows.length ? round(t.impressions / rows.length, 1) : null,
|
|
193
|
+
clicksPerDay: rows.length ? round(t.clicks / rows.length, 2) : null };
|
|
194
|
+
};
|
|
195
|
+
windows = { split, before: side(before), after: side(after) };
|
|
196
|
+
if (!before.length || !after.length) warnings.push(`--split ${split} leaves one side empty (data runs ${first} → ${last})`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const q = table(pick(files, FILES.queries), 'Top queries');
|
|
200
|
+
const pg = table(pick(files, FILES.pages), 'Top pages');
|
|
201
|
+
const co = table(pick(files, FILES.countries), 'Country');
|
|
202
|
+
const de = table(pick(files, FILES.devices), 'Device');
|
|
203
|
+
for (const [name, t] of [['Queries', q], ['Pages', pg], ['Countries', co], ['Devices', de]]) {
|
|
204
|
+
if (!t) warnings.push(`no ${name}.csv in the export`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let namedQueries = null;
|
|
208
|
+
if (q) {
|
|
209
|
+
const named = q.reduce((s, r) => s + r.impressions, 0);
|
|
210
|
+
namedQueries = {
|
|
211
|
+
impressions: named,
|
|
212
|
+
shareOfTotal: all.impressions ? round((100 * named) / all.impressions, 0) : null,
|
|
213
|
+
clicks: q.reduce((s, r) => s + r.clicks, 0),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
range: { from: first, to: last, days: days.length, trimmedTrailingDays: trimmed },
|
|
219
|
+
filters,
|
|
220
|
+
totals: all,
|
|
221
|
+
perDay: { impressions: round(all.impressions / days.length, 1), clicks: round(all.clicks / days.length, 2) },
|
|
222
|
+
weeks,
|
|
223
|
+
windows,
|
|
224
|
+
daily: days,
|
|
225
|
+
namedQueries,
|
|
226
|
+
queries: q ? withPrev(topRows(q, limit), prev?.queries) : null,
|
|
227
|
+
pages: pg ? withPrev(topRows(pg, limit, { merge: pathOf }), prev?.pages) : null,
|
|
228
|
+
countries: co ? withPrev(topRows(co, limit), prev?.countries) : null,
|
|
229
|
+
devices: de ? topRows(de, 0) : null,
|
|
230
|
+
prevTotals: prev?.totals ? { range: prev.range, ...prev.totals } : null,
|
|
231
|
+
warnings,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `siteread digest`: a Search Console export and an access log → one JSON document of the
|
|
2
|
+
// numbers a weekly read is written from. Deterministic: the same inputs give the same numbers,
|
|
3
|
+
// so this week's read compares with last week's. Nothing is sent anywhere; the only network
|
|
4
|
+
// traffic is the verifier's GETs of the vendors' public IP lists (none with `offline`).
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { digestGsc, readExport } from './gsc.js';
|
|
8
|
+
import { createLogDigest, readLog } from './log.js';
|
|
9
|
+
import { VERSION } from '../version.js';
|
|
10
|
+
|
|
11
|
+
// GSC clicks vs people the log saw arrive from Google, over the days both cover. They are
|
|
12
|
+
// different counts (a click without a Referer reads as direct; one person can click twice), so
|
|
13
|
+
// this is a sanity check, never a sum. On synacl.com they tracked within 10 % day by day.
|
|
14
|
+
function crossCheck(gsc, log) {
|
|
15
|
+
const from = gsc.range.from > log.range.from ? gsc.range.from : log.range.from;
|
|
16
|
+
const to = gsc.range.to < log.range.to ? gsc.range.to : log.range.to;
|
|
17
|
+
if (from > to) return null;
|
|
18
|
+
const gscClicks = gsc.daily.filter(d => d.date >= from && d.date <= to).reduce((s, d) => s + d.clicks, 0);
|
|
19
|
+
const days = log.sources.find(s => s.source === 'google')?.landingDays ?? {};
|
|
20
|
+
const googlePeople = Object.entries(days).filter(([d]) => d >= from && d <= to).reduce((s, [, n]) => s + n, 0);
|
|
21
|
+
return { from, to, gscClicks, googlePeople };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Paths of every <loc> in a sitemap (or sitemap index) file. */
|
|
25
|
+
export function parseSitemap(xml) {
|
|
26
|
+
const out = new Set();
|
|
27
|
+
for (const m of String(xml).matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)) {
|
|
28
|
+
const loc = m[1].replace(/&/g, '&');
|
|
29
|
+
try { out.add(new URL(loc).pathname); } catch { out.add(loc); }
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** "label=regex" → { label, re }. */
|
|
35
|
+
export function parseGoal(s) {
|
|
36
|
+
const i = String(s).indexOf('=');
|
|
37
|
+
if (i < 1) throw new Error(`--goal wants label=regex, got "${s}"`);
|
|
38
|
+
return { label: s.slice(0, i), re: new RegExp(s.slice(i + 1), 'i') };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} o
|
|
43
|
+
* @param {string} [o.gsc] export zip or folder
|
|
44
|
+
* @param {string[]} [o.logs] access log files
|
|
45
|
+
* @param {object} [o.verifier] from createVerifier(), already refreshed or loaded
|
|
46
|
+
* @param {object} [o.prev] an earlier digest (its JSON), for per-row deltas
|
|
47
|
+
* Plus the createLogDigest options and `split`, `limit`.
|
|
48
|
+
*/
|
|
49
|
+
export async function digest({ gsc = null, logs = [], verifier = null, prev = null, split = null, limit = 25, sitemapFile = null, now = new Date(), ...logOpts } = {}) {
|
|
50
|
+
if (!gsc && !logs.length) throw new Error('nothing to read: give a Search Console export, a log, or both');
|
|
51
|
+
const out = { siteread: VERSION, generatedAt: now.toISOString(), gsc: null, log: null, warnings: [] };
|
|
52
|
+
if (gsc) {
|
|
53
|
+
out.gsc = digestGsc(readExport(gsc), { split, limit, prev: prev?.gsc ?? null });
|
|
54
|
+
out.warnings.push(...out.gsc.warnings.map(w => `gsc: ${w}`));
|
|
55
|
+
delete out.gsc.warnings;
|
|
56
|
+
}
|
|
57
|
+
if (logs.length) {
|
|
58
|
+
const sitemap = sitemapFile ? parseSitemap(readFileSync(sitemapFile, 'utf8')) : null;
|
|
59
|
+
if (sitemap && !sitemap.size) out.warnings.push(`log: no <loc> URLs in ${sitemapFile}`);
|
|
60
|
+
const d = createLogDigest({ ...logOpts, sitemap, limit: Math.min(limit, 25) });
|
|
61
|
+
for (const f of logs) await readLog(f, d);
|
|
62
|
+
out.log = d.finish(verifier);
|
|
63
|
+
if (out.log.lines.unparsed > out.log.lines.total / 2) {
|
|
64
|
+
out.warnings.push(`log: ${out.log.lines.unparsed} of ${out.log.lines.total} lines are not in combined format; counts are from the rest`);
|
|
65
|
+
}
|
|
66
|
+
if (out.log.site.guessed) out.warnings.push(`log: site host guessed as ${out.log.site.host ?? 'unknown'} from Referers; pass --site to set it`);
|
|
67
|
+
if (out.gsc && out.log.range.from) {
|
|
68
|
+
const a = out.gsc.range, b = out.log.range;
|
|
69
|
+
if (b.from < a.from || b.to > a.to) {
|
|
70
|
+
out.warnings.push(`the log runs ${b.from} → ${b.to}, past the export's ${a.from} → ${a.to}; pass --from ${a.from > b.from ? a.from : b.from} --to ${a.to < b.to ? a.to : b.to} to compare the same days`);
|
|
71
|
+
}
|
|
72
|
+
out.crossCheck = crossCheck(out.gsc, out.log);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|