From c1c8a4d32666e411957e322884f0ce0df8fd56bc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 12:02:16 +0000 Subject: [PATCH 1/2] scorecard: one weekly number for the whole fleet (0.49.0) crawlproof knows the traffic and the ads, myna knows what went out, and both print JSON. This reads them rather than reimplementing either, and adds the one thing neither can do alone: a read across all of it, week over week. The line it exists to draw is internal traffic. On this fleet 94% of attributed source events are our own properties referring to each other, which is real engagement and is not acquisition. A report that adds those into one "referral" line shows a fleet growing on its own exhaust, so they are split out and the rest is totalled as `acquired`. The source column counts events, not visitors, and runs about twelve times the visitor count (108,972 against 8,870 this week). The report says so in place rather than leaving two units in one table. Snapshots go in ~/.local/share/scorecard, written after the report is rendered so a run never reads itself as the previous week. The first run says it has nothing to compare against instead of printing changes against zero. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 33 +++++ bin/scorecard.ts | 149 ++++++++++++++++++++ package.json | 2 +- src/registry.ts | 1 + src/scorecard.ts | 311 +++++++++++++++++++++++++++++++++++++++++ test/scorecard.test.ts | 248 ++++++++++++++++++++++++++++++++ 6 files changed, 743 insertions(+), 1 deletion(-) create mode 100755 bin/scorecard.ts create mode 100644 src/scorecard.ts create mode 100644 test/scorecard.test.ts diff --git a/README.md b/README.md index 892fb39..c11d36e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ TypeScript, installed as executables on `PATH`. | [`openmcp`](#openmcp) | The OpenMCP catalog of MCP relays: list, find a tool, call it, register your own | | [`shorten`](#shorten) | Mint a short link on the pit, and follow it from `/f/` | | [`sysupdate`](#sysupdate) | Update this box: apt lists, apt packages, snaps | +| [`scorecard`](#scorecard) | One weekly number for the whole fleet: traffic, channels, posts and ads, week over week | | [`users-dump`](#users-dump) | Every user account across the fleet, as one CSV | | [`user-export`](#user-export) | Every user across many databases, as one CSV | | [`email-cleaner`](#email-cleaner) | Clean a mailing list: drop bad, role, disposable, duplicate and unlikely addresses | @@ -605,6 +606,38 @@ daily entry: 5 13 * * * $HOME/.local/bin/gh-pulse >>$HOME/.local/share/gh-pulse/cron.log 2>&1 ``` +### `scorecard` + +One weekly read across the fleet, built from the tools that already know the +numbers: `crawlproof dashboard --json` for traffic and ads, `myna dashboard +--json` for what went out. Nothing is recomputed here that one of them can +answer. + +``` +scorecard # the report, to stdout +scorecard --send # the same, emailed, snapshot kept +scorecard --range 30 # a month instead of a week +scorecard --json # the snapshot +scorecard --no-save # do not write this run into the ledger +``` + +The line it exists to draw is internal traffic. Most of the biggest referrers +on this fleet are our own properties sending people to each other, which is +real engagement and is not acquisition, so those are split out and the rest is +totalled as `acquired`. + +The source column counts events, not visitors, and runs about twelve times the +visitor count. The report says so rather than leaving two different units in +one table to be read as one. + +Week over week needs a previous week. Snapshots go in +`~/.local/share/scorecard` and the first run says it has nothing to compare +against instead of printing changes against zero. A weekly entry: + +``` +0 9 * * 5 $HOME/.local/bin/scorecard --send >>$HOME/.local/share/scorecard/cron.log 2>&1 +``` + ### `gh-prs-fix-all` Looks at every open threatcrush-scan pull request and fixes the ones broken diff --git a/bin/scorecard.ts b/bin/scorecard.ts new file mode 100755 index 0000000..c72a263 --- /dev/null +++ b/bin/scorecard.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * scorecard — one weekly number for the whole fleet. + * + * scorecard the report, to stdout + * scorecard --send the same, emailed, and the snapshot kept + * scorecard --range 30 a month instead of a week + * scorecard --json the snapshot as JSON + * scorecard --no-save do not write this run into the ledger + * + * It shells out to `crawlproof dashboard --json` and `myna dashboard --json`, + * which already know the traffic, the ads and what went out. Nothing is + * recomputed here that one of them can answer. + * + * Week over week needs a previous week, so the first run says so rather than + * printing changes against zero. Snapshots live in ~/.local/share/scorecard. + * + * Mail goes through Resend, like gh-pulse: RESEND_API_KEY from the + * environment, else from ~/.config/logicsrc/shell.env, which is the cron case. + */ +import { UsageError, parseArgs } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { run } from '../src/exec.ts'; +import { loadShellEnv, sendResend } from '../src/gh-pulse.ts'; +import { + dataDir, + formatHtml, + formatReport, + previousSnapshot, + saveSnapshot, + snapshotFrom, + type Snapshot, +} from '../src/scorecard.ts'; + +const DEFAULT_FROM = 'Fleet Scorecard '; + +const USAGE = `scorecard — one weekly number for the whole fleet + +USAGE + scorecard [--range 7] [--send] [--json] [--no-save] + +OPTIONS + --range N days to report on (default 7) + --send email the report through Resend + --to a@b.c recipient (default SCORECARD_TO, then anthony@profullstack.com) + --from "N " sender on a verified Resend domain + --json print the snapshot instead of the report + --no-save do not write this run into the ledger +`; + +/** + * Run a command that prints JSON. + * + * A tool that fails returns null rather than throwing: half a report is worth + * having, and which half is missing is said out loud in the report's notes. + */ +export async function readJson(command: string, args: string[], timeoutMs = 240_000): Promise { + const result = await run(command, args, { timeoutMs }); + if (result.code !== 0) return null; + try { + return JSON.parse(result.stdout) as T; + } catch { + return null; + } +} + +/** crawlproof's range vocabulary, from a number of days. */ +export function rangeFor(days: number): string { + if (days >= 28) return '1m'; + if (days >= 7) return '1w'; + return '1d'; +} + +async function collect(rangeDays: number): Promise { + const [crawlproof, myna] = await Promise.all([ + readJson[0]>('crawlproof', [ + 'dashboard', + `--range=${rangeFor(rangeDays)}`, + '--json', + '--no-coinpay', + ]), + readJson[1]>('myna', ['dashboard', '--json'], 90_000), + ]); + return snapshotFrom(crawlproof, myna, { rangeDays }); +} + +async function main(argv: readonly string[]): Promise { + const { flags, values } = parseArgs(argv, { + boolean: ['-h', '--help', '--send', '--json', '--no-save'], + string: ['--range', '--to', '--from'], + }); + + if (flags.has('-h') || flags.has('--help')) { + process.stdout.write(USAGE); + return 0; + } + + const rangeDays = Number(values.get('--range') ?? 7); + if (!Number.isFinite(rangeDays) || rangeDays < 1) { + throw new UsageError('--range wants a number of days, like 7 or 30.'); + } + + const snapshot = await collect(rangeDays); + const previous = previousSnapshot(snapshot.at); + + if (flags.has('--json')) { + process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`); + } else { + process.stdout.write(formatReport(snapshot, previous)); + } + + // Saved after the report is rendered, so this run never reads itself as the + // previous week, and a crash while rendering leaves the ledger untouched. + if (!flags.has('--no-save')) { + const file = saveSnapshot(snapshot, dataDir()); + if (!flags.has('--json')) process.stdout.write(`\n kept ${file}\n`); + } + + if (flags.has('--send')) { + loadShellEnv(); + const key = process.env['RESEND_API_KEY']; + if (!key) throw new Error('RESEND_API_KEY is not set (and not in ~/.config/logicsrc/shell.env)'); + const to = values.get('--to') ?? process.env['SCORECARD_TO'] ?? 'anthony@profullstack.com'; + const id = await sendResend( + { + to, + from: values.get('--from') ?? DEFAULT_FROM, + subject: `Fleet scorecard, ${rangeDays} days to ${snapshot.at.slice(0, 10)}`, + html: formatHtml(snapshot, previous), + text: formatReport(snapshot, previous), + images: [], + }, + key, + fetch, + ); + process.stdout.write(` mailed ${to} (${id})\n`); + } + + return 0; +} + +if (isMain(import.meta.url)) { + main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((error: Error) => { + console.error(error instanceof UsageError ? error.message : `scorecard: ${error.message}`); + process.exit(1); + }); +} diff --git a/package.json b/package.json index 6cf7f29..0f29e0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.48.0", + "version": "0.49.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/registry.ts b/src/registry.ts index 12c1a21..89332eb 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -56,6 +56,7 @@ const SUMMARIES: Record = { 'gh-pulse': 'What moved on GitHub, last hour to all time, ranked, with traffic: by email, TUI or JSON', hqtui: 'Every vital of this box, in the terminal: sockets, HTTP, sessions, services', porkbun: 'Read and change DNS at Porkbun, and un-park a domain', + scorecard: 'One weekly number for the whole fleet: traffic, channels, posts and ads, week over week', shorten: 'Mint a short link on the pit, and follow it from /f/', sysupdate: 'Update this box: apt lists, apt packages, snaps', 'user-export': 'Every user across many databases, as one CSV', diff --git a/src/scorecard.ts b/src/scorecard.ts new file mode 100644 index 0000000..4ec589b --- /dev/null +++ b/src/scorecard.ts @@ -0,0 +1,311 @@ +/** + * One weekly number for the whole fleet, from the tools that already know it. + * + * crawlproof knows the traffic and the ads, myna knows what went out. Both + * print JSON, so this reads them rather than reimplementing either, and adds + * the one thing neither can do alone: a read across all of it, week over week. + * + * The distinction that makes the number honest is internal traffic. Eight of + * the ten biggest referrers on this fleet are our own properties sending + * people to each other, which is real engagement and is not acquisition. A + * scorecard that adds those into one "referral" line reports a fleet growing + * on its own exhaust, so they are split out and named. + * + * Everything that decides anything here is a pure function over a snapshot, so + * the report can be tested without a network, a token or a clock. + */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; + +/* ---------------------------------------------------------------- channels */ + +/** + * Where a visit came from, as a weekly report cares about it. + * + * `internal` and `external` are both referrals; keeping them apart is the + * whole point. `direct` covers a visit with nothing to attribute it to, which + * after this week's myna change should slowly shrink as posted links start + * carrying their own campaign tags. + */ +export type Channel = 'search' | 'ai' | 'social' | 'ads' | 'internal' | 'external' | 'direct' | 'other'; + +export const CHANNELS: Channel[] = ['search', 'ai', 'social', 'ads', 'external', 'internal', 'direct', 'other']; + +/** How each channel is labelled in the report, in the order it is printed. */ +const CHANNEL_LABEL: Record = { + search: 'search', + ai: 'AI', + social: 'social', + ads: 'ads', + external: 'referral (external)', + internal: 'referral (ours)', + direct: 'direct', + other: 'other', +}; + +export interface Source { + label: string; + value: number; +} + +/** The host part of a "Referral · example.com" label, lower-case, or "". */ +export function referrerHost(label: string): string { + const [, rest] = label.split('·'); + if (!rest) return ''; + return rest.trim().toLowerCase().replace(/^www\./, ''); +} + +/** + * Which channel a crawlproof source label belongs to. + * + * A referral from a host we own is `internal`. Subdomains count as ours: a + * visit from blog.example.com to example.com is still us talking to ourselves. + */ +export function channelOf(label: string, ourHosts: ReadonlySet = new Set()): Channel { + const prefix = (label.split('·')[0] ?? '').trim().toLowerCase(); + if (prefix.startsWith('search')) return 'search'; + if (prefix === 'ai') return 'ai'; + if (prefix.startsWith('social')) return 'social'; + if (prefix.startsWith('ad')) return 'ads'; + if (prefix.startsWith('direct') || label.trim().toLowerCase() === 'direct') return 'direct'; + if (prefix.startsWith('referral')) { + const host = referrerHost(label); + if (!host) return 'external'; + for (const ours of ourHosts) { + if (host === ours || host.endsWith(`.${ours}`)) return 'internal'; + } + return 'external'; + } + return 'other'; +} + +/** Total every source into its channel. */ +export function rollChannels(sources: readonly Source[], ourHosts: ReadonlySet = new Set()): Record { + const totals = Object.fromEntries(CHANNELS.map((channel) => [channel, 0])) as Record; + for (const source of sources) { + const value = Number(source.value) || 0; + totals[channelOf(String(source.label ?? ''), ourHosts)] += value; + } + return totals; +} + +/** Visits we did not send ourselves: everything but our own cross-links. */ +export function acquired(channels: Record): number { + return CHANNELS.filter((channel) => channel !== 'internal').reduce((sum, channel) => sum + channels[channel], 0); +} + +/* --------------------------------------------------------------- snapshots */ + +export interface Snapshot { + at: string; + rangeDays: number; + traffic: { visitors: number; pageviews: number; sites: number; reporting: number }; + channels: Record; + topSites: Array<{ site: string; visitors: number }>; + ads: { impressions: number; clicks: number; spentUsd: number; earnedUsd: number }; + posts: { sent: number; failed: number; queued: number; networks: number } | null; + outreach: { campaigns: number; sent: number; replies: number } | null; + notes: string[]; +} + +interface CrawlproofJson { + sites?: Array<{ site?: string; visitors?: number }>; + fleet?: { sources?: Source[] }; + ads?: { totals?: Record }; + roi?: { attention?: { visitors?: number; pageviews?: number; sites?: number; sitesReporting?: number } }; +} + +interface MynaJson { + perDay?: Array<{ day?: string; sent?: number; failed?: number }>; + queuedCount?: number; + totals?: { networks?: number }; +} + +/** Sends and failures over the last `days` buckets of myna's daily series. */ +export function sendsOverDays(perDay: MynaJson['perDay'], days: number): { sent: number; failed: number } { + const recent = (perDay ?? []).slice(-days); + return { + sent: recent.reduce((sum, day) => sum + (Number(day.sent) || 0), 0), + failed: recent.reduce((sum, day) => sum + (Number(day.failed) || 0), 0), + }; +} + +/** Build a snapshot from what the two tools printed. Neither is required. */ +export function snapshotFrom( + crawlproof: CrawlproofJson | null, + myna: MynaJson | null, + { rangeDays = 7, now = new Date() }: { rangeDays?: number; now?: Date } = {}, +): Snapshot { + const notes: string[] = []; + const sites = crawlproof?.sites ?? []; + // Every property in the account is a host we own, so the internal split + // needs no list to be kept up to date by hand. + const ourHosts = new Set(sites.map((site) => String(site.site ?? '').toLowerCase()).filter(Boolean)); + const attention = crawlproof?.roi?.attention ?? {}; + const adTotals = crawlproof?.ads?.totals ?? {}; + + if (!crawlproof) notes.push('crawlproof returned nothing, so traffic and ads are missing from this report.'); + if (!myna) notes.push('myna returned nothing, so posting is missing from this report.'); + + return { + at: now.toISOString(), + rangeDays, + traffic: { + visitors: Number(attention.visitors) || 0, + pageviews: Number(attention.pageviews) || 0, + sites: Number(attention.sites) || sites.length, + reporting: Number(attention.sitesReporting) || 0, + }, + channels: rollChannels(crawlproof?.fleet?.sources ?? [], ourHosts), + topSites: [...sites] + .map((site) => ({ site: String(site.site ?? ''), visitors: Number(site.visitors) || 0 })) + .sort((a, b) => b.visitors - a.visitors) + .slice(0, 8), + ads: { + impressions: Number(adTotals.advImpressions) || 0, + clicks: Number(adTotals.advClicks) || 0, + spentUsd: (Number(adTotals.spentCents) || 0) / 100, + earnedUsd: (Number(adTotals.earnedCents) || 0) / 100, + }, + posts: myna + ? { + ...sendsOverDays(myna.perDay, rangeDays), + queued: Number(myna.queuedCount) || 0, + networks: Number(myna.totals?.networks) || 0, + } + : null, + outreach: null, + notes, + }; +} + +/* ----------------------------------------------------------------- ledger */ + +export function dataDir(env: NodeJS.ProcessEnv = process.env): string { + return env['SCORECARD_DATA'] ?? join(homedir(), '.local', 'share', 'scorecard'); +} + +/** Write this week's snapshot and return where it went. */ +export function saveSnapshot(snapshot: Snapshot, dir = dataDir()): string { + mkdirSync(dir, { recursive: true }); + const file = join(dir, `${snapshot.at.slice(0, 10)}.json`); + writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`); + return file; +} + +/** + * The most recent snapshot before `at`, or null on the first ever run. + * + * Read by filename rather than by a "latest" pointer, so a run that crashed + * halfway through leaves nothing to mistake for the previous week. + */ +export function previousSnapshot(at: string, dir = dataDir()): Snapshot | null { + if (!existsSync(dir)) return null; + const earlier = readdirSync(dir) + .filter((name) => /^\d{4}-\d{2}-\d{2}\.json$/.test(name)) + .filter((name) => name.slice(0, 10) < at.slice(0, 10)) + .sort(); + const last = earlier.at(-1); + if (!last) return null; + try { + return JSON.parse(readFileSync(join(dir, last), 'utf8')) as Snapshot; + } catch { + return null; + } +} + +/* ----------------------------------------------------------------- report */ + +const number = (value: number): string => value.toLocaleString('en-US'); +const usd = (value: number): string => `$${value.toFixed(2)}`; + +/** " (+12%)" against the previous week, or "" when there is nothing to compare. */ +export function delta(current: number, previous: number | undefined): string { + if (previous === undefined || previous === null) return ''; + if (previous === 0) return current === 0 ? '' : ' (new)'; + const change = Math.round(((current - previous) / previous) * 100); + if (change === 0) return ' (flat)'; + return ` (${change > 0 ? '+' : ''}${change}%)`; +} + +/** The weekly report, as plain text. */ +export function formatReport(snapshot: Snapshot, previous: Snapshot | null = null): string { + const lines: string[] = []; + const since = `${snapshot.rangeDays} days to ${snapshot.at.slice(0, 10)}`; + lines.push(`Fleet scorecard, ${since}`); + lines.push(''); + + const acquiredNow = acquired(snapshot.channels); + const acquiredBefore = previous ? acquired(previous.channels) : undefined; + lines.push( + ` visitors ${number(snapshot.traffic.visitors)}${delta(snapshot.traffic.visitors, previous?.traffic.visitors)}` + + ` across ${snapshot.traffic.reporting || snapshot.traffic.sites} sites`, + ); + lines.push(` pageviews ${number(snapshot.traffic.pageviews)}${delta(snapshot.traffic.pageviews, previous?.traffic.pageviews)}`); + if (snapshot.posts) { + lines.push( + ` posts ${number(snapshot.posts.sent)} sent${snapshot.posts.failed ? `, ${snapshot.posts.failed} failed` : ''}` + + `${delta(snapshot.posts.sent, previous?.posts?.sent)} ${number(snapshot.posts.queued)} queued`, + ); + } + lines.push(` ads ${number(snapshot.ads.clicks)} clicks of ${number(snapshot.ads.impressions)} ${usd(snapshot.ads.earnedUsd)} earned, ${usd(snapshot.ads.spentUsd)} spent`); + if (snapshot.outreach) { + lines.push(` outreach ${number(snapshot.outreach.sent)} sent, ${number(snapshot.outreach.replies)} replies across ${snapshot.outreach.campaigns} campaigns`); + } + + lines.push(''); + lines.push('Where the traffic came from'); + // The tracker counts a source per event, not per visitor: this column runs + // an order of magnitude above the visitor count above it and is not the same + // unit. Saying so is the difference between a share that means something and + // a number somebody will one day read as visitors. + const attributed = acquiredNow + snapshot.channels.internal; + lines.push(` shares of ${number(attributed)} attributed source events, which the tracker counts per`); + lines.push(' event rather than per visitor: comparable with each other and week to week,'); + lines.push(' not with the visitor count above.'); + lines.push(''); + const widest = Math.max(...CHANNELS.map((channel) => CHANNEL_LABEL[channel].length), 'acquired'.length); + const share = (value: number): string => `${String(Math.round((value / Math.max(1, attributed)) * 100)).padStart(3)}%`; + for (const channel of [...CHANNELS].sort((a, b) => snapshot.channels[b] - snapshot.channels[a])) { + const value = snapshot.channels[channel]; + if (!value) continue; + lines.push( + ` ${CHANNEL_LABEL[channel].padEnd(widest)} ${number(value).padStart(9)} ${share(value)}` + + `${delta(value, previous?.channels?.[channel])}`, + ); + } + lines.push( + ` ${'acquired'.padEnd(widest)} ${number(acquiredNow).padStart(9)} ${share(acquiredNow)}` + + `${delta(acquiredNow, acquiredBefore)} everything but our own cross-links`, + ); + + lines.push(''); + lines.push('Busiest properties'); + for (const site of snapshot.topSites) { + if (!site.visitors) continue; + lines.push(` ${site.site.padEnd(28)} ${number(site.visitors).padStart(8)}`); + } + + if (snapshot.notes.length) { + lines.push(''); + lines.push('Notes'); + for (const note of snapshot.notes) lines.push(` ${note}`); + } + + if (!previous) { + lines.push(''); + lines.push(' First run: nothing to compare against yet. Next week carries the change.'); + } + + return `${lines.join('\n')}\n`; +} + +/** The same report as HTML, for the email. */ +export function formatHtml(snapshot: Snapshot, previous: Snapshot | null = null): string { + const escaped = formatReport(snapshot, previous) + .replace(/&/g, '&') + .replace(//g, '>'); + return `
${escaped}
`; +} diff --git a/test/scorecard.test.ts b/test/scorecard.test.ts new file mode 100644 index 0000000..bab703c --- /dev/null +++ b/test/scorecard.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + acquired, + channelOf, + delta, + formatReport, + previousSnapshot, + referrerHost, + rollChannels, + saveSnapshot, + sendsOverDays, + snapshotFrom, + type Snapshot, +} from '../src/scorecard.ts'; + +const OURS = new Set(['genrewatch.com', 'p0dcasters.com', 'example.com']); + +describe('classifying a source', () => { + it('reads the channel off the label prefix', () => { + expect(channelOf('Search · google', OURS)).toBe('search'); + expect(channelOf('AI · chatgpt', OURS)).toBe('ai'); + expect(channelOf('Social · reddit', OURS)).toBe('social'); + expect(channelOf('Ad · crawlproof-ad-318', OURS)).toBe('ads'); + expect(channelOf('Direct', OURS)).toBe('direct'); + }); + + it('splits a referral by whether the host is ours', () => { + expect(channelOf('Referral · genrewatch.com', OURS)).toBe('internal'); + expect(channelOf('Referral · news.ycombinator.com', OURS)).toBe('external'); + }); + + it('counts a subdomain of ours as ours', () => { + expect(channelOf('Referral · blog.example.com', OURS)).toBe('internal'); + }); + + it('does not mistake a lookalike domain for ours', () => { + // The tell of a naive endsWith: a domain that merely ends the same way. + expect(channelOf('Referral · notexample.com', OURS)).toBe('external'); + expect(channelOf('Referral · example.com.evil.net', OURS)).toBe('external'); + }); + + it('ignores www and case when matching', () => { + expect(channelOf('Referral · WWW.Example.com', OURS)).toBe('internal'); + }); + + it('files anything it does not recognise as other, not as a referral', () => { + expect(channelOf('Something · else', OURS)).toBe('other'); + }); + + it('reads the host out of a label', () => { + expect(referrerHost('Referral · com.reddit.frontpage')).toBe('com.reddit.frontpage'); + expect(referrerHost('Direct')).toBe(''); + }); +}); + +describe('rolling sources into channels', () => { + const sources = [ + { label: 'Referral · genrewatch.com', value: 45492 }, + { label: 'Referral · news.ycombinator.com', value: 300 }, + { label: 'Search · google', value: 2284 }, + { label: 'AI · duckduckgo_ai', value: 2220 }, + { label: 'Social · reddit', value: 1980 }, + { label: 'Ad · crawlproof-ad-318', value: 3 }, + ]; + + it('totals each channel', () => { + const rolled = rollChannels(sources, OURS); + expect(rolled.internal).toBe(45492); + expect(rolled.external).toBe(300); + expect(rolled.search).toBe(2284); + expect(rolled.ai).toBe(2220); + expect(rolled.social).toBe(1980); + expect(rolled.ads).toBe(3); + }); + + it('leaves our own cross-links out of what was acquired', () => { + const rolled = rollChannels(sources, OURS); + // The whole point: 45,492 of our own referrals must not read as growth. + expect(acquired(rolled)).toBe(300 + 2284 + 2220 + 1980 + 3); + }); + + it('treats an unknown host as external rather than dropping it', () => { + const rolled = rollChannels([{ label: 'Referral · ', value: 5 }], OURS); + expect(rolled.external).toBe(5); + }); + + it('survives a malformed source', () => { + const rolled = rollChannels([{ label: '', value: Number.NaN }] as never, OURS); + expect(rolled.other).toBe(0); + }); +}); + +describe('counting sends', () => { + const perDay = [ + { day: '2026-09-15', sent: 9, failed: 1 }, + { day: '2026-09-16', sent: 4, failed: 0 }, + { day: '2026-09-17', sent: 7, failed: 2 }, + ]; + + it('adds up the last N days', () => { + expect(sendsOverDays(perDay, 2)).toEqual({ sent: 11, failed: 2 }); + }); + + it('asking for more days than exist takes what there is', () => { + expect(sendsOverDays(perDay, 30)).toEqual({ sent: 20, failed: 3 }); + }); + + it('no series is zero, not a crash', () => { + expect(sendsOverDays(undefined, 7)).toEqual({ sent: 0, failed: 0 }); + }); +}); + +describe('building a snapshot', () => { + const crawlproof = { + sites: [ + { site: 'genrewatch.com', visitors: 4000 }, + { site: 'p0dcasters.com', visitors: 1418 }, + ], + fleet: { sources: [{ label: 'Referral · genrewatch.com', value: 900 }, { label: 'Search · google', value: 100 }] }, + ads: { totals: { advImpressions: 50905, advClicks: 11013, spentCents: 1550, earnedCents: 530 } }, + roi: { attention: { visitors: 8859, pageviews: 15597, sites: 56, sitesReporting: 56 } }, + }; + const myna = { perDay: [{ day: '2026-09-23', sent: 5, failed: 0 }], queuedCount: 306, totals: { networks: 9 } }; + + it('takes the headline numbers from the tools that own them', () => { + const snapshot = snapshotFrom(crawlproof, myna, { rangeDays: 7, now: new Date('2026-09-24T00:00:00Z') }); + expect(snapshot.traffic.visitors).toBe(8859); + expect(snapshot.ads.clicks).toBe(11013); + expect(snapshot.ads.spentUsd).toBe(15.5); + expect(snapshot.posts?.queued).toBe(306); + }); + + it('derives what we own from the account, needing no list', () => { + const snapshot = snapshotFrom(crawlproof, myna, { now: new Date('2026-09-24T00:00:00Z') }); + expect(snapshot.channels.internal).toBe(900); + expect(snapshot.channels.search).toBe(100); + }); + + it('a missing tool is a note, not a failure', () => { + const snapshot = snapshotFrom(null, myna, { now: new Date('2026-09-24T00:00:00Z') }); + expect(snapshot.traffic.visitors).toBe(0); + expect(snapshot.notes.join(' ')).toMatch(/crawlproof/); + expect(snapshot.posts).not.toBeNull(); + }); + + it('ranks the busiest properties', () => { + const snapshot = snapshotFrom(crawlproof, myna, { now: new Date('2026-09-24T00:00:00Z') }); + expect(snapshot.topSites[0]?.site).toBe('genrewatch.com'); + }); +}); + +describe('week over week', () => { + it('reports a change against the previous week', () => { + expect(delta(110, 100)).toBe(' (+10%)'); + expect(delta(90, 100)).toBe(' (-10%)'); + expect(delta(100, 100)).toBe(' (flat)'); + }); + + it('says nothing when there is nothing to compare', () => { + expect(delta(100, undefined)).toBe(''); + expect(delta(0, 0)).toBe(''); + }); + + it('calls a number that grew from zero new rather than dividing by it', () => { + expect(delta(5, 0)).toBe(' (new)'); + }); +}); + +describe('the ledger', () => { + const snapshot = (at: string, visitors: number): Snapshot => ({ + at, + rangeDays: 7, + traffic: { visitors, pageviews: 0, sites: 1, reporting: 1 }, + channels: { search: 0, ai: 0, social: 0, ads: 0, internal: 0, external: 0, direct: 0, other: 0 }, + topSites: [], + ads: { impressions: 0, clicks: 0, spentUsd: 0, earnedUsd: 0 }, + posts: null, + outreach: null, + notes: [], + }); + + it('finds the most recent earlier snapshot', () => { + const dir = mkdtempSync(join(tmpdir(), 'scorecard-')); + saveSnapshot(snapshot('2026-09-10T00:00:00.000Z', 100), dir); + saveSnapshot(snapshot('2026-09-17T00:00:00.000Z', 200), dir); + expect(previousSnapshot('2026-09-24T00:00:00.000Z', dir)?.traffic.visitors).toBe(200); + }); + + it('never reads the current run as the previous one', () => { + const dir = mkdtempSync(join(tmpdir(), 'scorecard-')); + const at = '2026-09-24T00:00:00.000Z'; + saveSnapshot(snapshot(at, 300), dir); + expect(previousSnapshot(at, dir)).toBeNull(); + }); + + it('the first ever run has no previous week', () => { + expect(previousSnapshot('2026-09-24T00:00:00.000Z', mkdtempSync(join(tmpdir(), 'scorecard-')))).toBeNull(); + }); + + it('a corrupt snapshot is skipped rather than crashing the run', () => { + const dir = mkdtempSync(join(tmpdir(), 'scorecard-')); + writeFileSync(join(dir, '2026-09-17.json'), '{ not json'); + expect(previousSnapshot('2026-09-24T00:00:00.000Z', dir)).toBeNull(); + }); +}); + +describe('the report', () => { + const built = snapshotFrom( + { + sites: [{ site: 'genrewatch.com', visitors: 4000 }], + fleet: { sources: [{ label: 'Referral · genrewatch.com', value: 900 }, { label: 'Search · google', value: 100 }] }, + ads: { totals: { advImpressions: 10, advClicks: 2, spentCents: 100, earnedCents: 50 } }, + roi: { attention: { visitors: 1000, pageviews: 2000, sites: 1, sitesReporting: 1 } }, + }, + { perDay: [{ day: '2026-09-23', sent: 5, failed: 1 }], queuedCount: 306, totals: { networks: 9 } }, + { now: new Date('2026-09-24T00:00:00Z') }, + ); + + it('names the acquired figure apart from our own cross-links', () => { + const text = formatReport(built, null); + expect(text).toMatch(/acquired\s+100/); + expect(text).toMatch(/referral \(ours\)/); + }); + + it('says so on the first run instead of comparing against zero', () => { + expect(formatReport(built, null)).toMatch(/First run/); + }); + + it('never lets the source column be read as visitors', () => { + // Source labels are counted per event: on the real fleet they run about + // twelve times the visitor count, so a report that puts them in one column + // without saying so invites exactly the wrong conclusion. + const text = formatReport(built, null); + expect(text).toMatch(/source events/); + expect(text).toMatch(/not with the visitor count above/); + }); + + it('carries the change once there is a previous week', () => { + const before = { ...built, traffic: { ...built.traffic, visitors: 500 } }; + const text = formatReport(built, before); + expect(text).toMatch(/\(\+100%\)/); + expect(text).not.toMatch(/First run/); + }); +}); From 4714a66b1cbe12f867ebdf0bb2a28e30a05b8aa2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 24 Sep 2026 12:03:51 +0000 Subject: [PATCH 2/2] docs: scorecard cron line matches the house pattern (PATH, Friday morning) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c11d36e..e77edbb 100644 --- a/README.md +++ b/README.md @@ -635,7 +635,7 @@ Week over week needs a previous week. Snapshots go in against instead of printing changes against zero. A weekly entry: ``` -0 9 * * 5 $HOME/.local/bin/scorecard --send >>$HOME/.local/share/scorecard/cron.log 2>&1 +30 13 * * 5 export PATH=$HOME/.local/bin:$HOME/.local/share/mise/shims:/usr/local/bin:/usr/bin:/bin; $HOME/.local/bin/scorecard --send >>$HOME/.local/share/scorecard/cron.log 2>&1 ``` ### `gh-prs-fix-all`