1#!/usr/bin/env node 2// How often listeners agree with Jev's art critic: the "do you agree with 3// Jev?" vote counts from the site (GET /jev/votes/summary, 4// worker/src/votes.ts), against each song's current `critic.art` in its 5// SPEC.md. 6// 7// nix run .#agreement # the public site 8// nix run .#agreement -- --url http://localhost:4324 # another deploy 9// 10// A vote agrees when its pick is the song the critic scores higher. A pair 11// the critic scores equally has no preference and is left out, as on the 12// page (agree.mjs), and so is a vote on a song no longer here or unscored. 13// Per song, a vote counts for both songs in its pair. 14import { readdirSync, readFileSync, existsSync } from 'node:fs'; 15import { fileURLToPath } from 'node:url'; 16import { agreementReport, SUMMARY } from '../../website/src/jev/agree.mjs'; 17import { siteUrl } from '../../website/src/jev/site.mjs'; 18 19export { SUMMARY }; 20 21// `critic.art` from a SPEC.md's frontmatter, or null when it has none. 22// Read the way tools/critic writes it: a top-level `critic:` block, `art:` 23// two spaces in. 24export function criticArt(spec) { 25 const fm = spec.split(/^---\s*$/m)[1] ?? ''; 26 const block = fm.match(/^critic:[ \t]*\n((?:[ \t]+.*\n?)*)/m)?.[1] ?? ''; 27 const art = Number(block.match(/^ {2}art:\s*([^\s#]+)/m)?.[1]); 28 return Number.isFinite(art) ? art : null; 29} 30 31// Every song under `root` (songs/<theme>/<song>/SPEC.md), shaped like the 32// page's songs for agree.mjs: { id, art: { p } | null }. 33export function readSongs(root) { 34 const songs = []; 35 for (const theme of readdirSync(root, { withFileTypes: true })) { 36 if (!theme.isDirectory()) continue; 37 for (const song of readdirSync(new URL(`${theme.name}/`, root), { withFileTypes: true })) { 38 const spec = new URL(`${theme.name}/${song.name}/SPEC.md`, root); 39 if (!song.isDirectory() || !existsSync(spec)) continue; 40 const art = criticArt(readFileSync(spec, 'utf8')); 41 songs.push({ id: `${theme.name}/${song.name}`, art: art === null ? null : { p: art } }); 42 } 43 } 44 return songs; 45} 46 47// The report is the page's own (website/src/jev/agree.mjs), which its 48// results view shows too: one definition, so the two never disagree. 49export const report = agreementReport; 50 51const pct = (p) => (p === null ? '—' : `${Math.round(p * 100)}%`); 52 53export function format({ overall, songs }) { 54 const lines = [ 55 `listeners agree with the critic ${pct(overall.share)} of the time (${overall.agreed} of ${overall.counted} votes)`, 56 ` of ${overall.votes} votes: ${overall.ties} on pairs the critic scores equally, ${overall.unknown} on songs no longer here or unscored, left out`, 57 ]; 58 if (songs.length) { 59 const width = Math.max(...songs.map((s) => s.id.length)); 60 lines.push('', `${'song'.padEnd(width)} art agree votes`); 61 for (const s of songs) { 62 lines.push( 63 `${s.id.padEnd(width)} ${pct(s.art).padStart(4)} ${pct(s.share).padStart(5)} ${String(s.counted).padStart(5)}`, 64 ); 65 } 66 } 67 return lines.join('\n'); 68} 69 70async function main(args) { 71 const at = args.indexOf('--url'); 72 const base = at >= 0 ? args[at + 1] : siteUrl; 73 if (!base) throw new Error('usage: agreement [--url https://site]'); 74 const url = new URL(SUMMARY, base); 75 const res = await fetch(url); 76 if (!res.ok) throw new Error(`${url} answered ${res.status}: ${(await res.text()).slice(0, 200)}`); 77 const { votes } = await res.json(); 78 const songs = readSongs(new URL('../../songs/', import.meta.url)); 79 console.log(`${url}\n`); 80 console.log(format(report(songs, votes))); 81} 82 83if (process.argv[1] === fileURLToPath(import.meta.url)) { 84 main(process.argv.slice(2)).catch((e) => { 85 console.error(`agreement: ${e.message}`); 86 process.exit(1); 87 }); 88}