Something went wrong. Try again.
TV-friendly front end for the TimeGuessr daily
Something went wrong. Try again.
temporamancer server.mjs
9.6 kB · 253 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254#!/usr/bin/env node// Temporamancer: a TV-friendly front end for the TimeGuessr daily.//// Zero-dependency Node server. It serves ./public and exposes:// GET /api/daily today's five rounds, pulled from timeguessr.com and cached briefly// GET /api/image?url= fallback image proxy, only for images.timeguessr.comimport http from 'node:http';import { createReadStream } from 'node:fs';import { stat } from 'node:fs/promises';import { networkInterfaces } from 'node:os';import path from 'node:path';import { Readable } from 'node:stream';import { fileURLToPath } from 'node:url';
const ROOT = path.dirname(fileURLToPath(import.meta.url));const PUBLIC_DIR = path.join(ROOT, 'public');const PORT = Number(process.env.PORT) || 8787;const HOST = process.env.HOST || '0.0.0.0';
// SvelteKit exposes each route's load() data as JSON; this is what the real// /play?mode=daily page hydrates from, so it is the most stable source.const UPSTREAM = 'https://timeguessr.com/play/__data.json?mode=daily';const IMAGE_HOST = 'images.timeguessr.com';const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0 Safari/537.36 temporamancer/0.1';const CACHE_TTL_MS = 5 * 60 * 1000;
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2',};
// ---------------------------------------------------------------------------// devalue "unflatten": SvelteKit serialises load() data as a flat array where// objects and arrays reference other entries by index. Negative indices are// sentinels for values JSON cannot represent.// ---------------------------------------------------------------------------const UNDEFINED = -1;const HOLE = -2;const NAN = -3;const POSITIVE_INFINITY = -4;const NEGATIVE_INFINITY = -5;const NEGATIVE_ZERO = -6;
function unflatten(values) { const hydrated = new Array(values.length); function hydrate(index) { switch (index) { case UNDEFINED: return undefined; case NAN: return NaN; case POSITIVE_INFINITY: return Infinity; case NEGATIVE_INFINITY: return -Infinity; case NEGATIVE_ZERO: return -0; } if (index in hydrated) return hydrated[index]; const value = values[index]; if (!value || typeof value !== 'object') { hydrated[index] = value; } else if (Array.isArray(value)) { if (typeof value[0] === 'string') { const type = value[0]; switch (type) { case 'Date': hydrated[index] = new Date(value[1]); break; case 'RegExp': hydrated[index] = new RegExp(value[1], value[2]); break; case 'Object': hydrated[index] = Object(value[1]); break; case 'BigInt': hydrated[index] = BigInt(value[1]); break; case 'Set': { const set = new Set(); hydrated[index] = set; for (let i = 1; i < value.length; i++) set.add(hydrate(value[i])); break; } case 'Map': { const map = new Map(); hydrated[index] = map; for (let i = 1; i < value.length; i += 2) map.set(hydrate(value[i]), hydrate(value[i + 1])); break; } case 'null': { const obj = Object.create(null); hydrated[index] = obj; for (let i = 1; i < value.length; i += 2) obj[value[i]] = hydrate(value[i + 1]); break; } default: throw new Error(`Unknown devalue type: ${type}`); } } else { const array = new Array(value.length); hydrated[index] = array; for (let i = 0; i < value.length; i++) { const n = value[i]; if (n !== HOLE) array[i] = hydrate(n); } } } else { const object = {}; hydrated[index] = object; for (const key of Object.keys(value)) object[key] = hydrate(value[key]); } return hydrated[index]; } return hydrate(0);}
// ---------------------------------------------------------------------------// Daily payload// ---------------------------------------------------------------------------let cache = { at: 0, payload: null, inflight: null };
function normalizeRound(r) { return { imageId: r.ImageId ?? null, url: r.URL, year: Number(r.Year), lat: Number(r.Location?.lat), lng: Number(r.Location?.lng), description: r.Description ?? '', license: r.License ?? '', country: r.Country ?? '', streetView: r.StreetView ?? null, };}
async function fetchDaily() { // Note: sending `accept: application/json` makes the upstream 404, so only the UA is set. const res = await fetch(UPSTREAM, { headers: { 'user-agent': USER_AGENT } }); if (!res.ok) throw new Error(`upstream responded ${res.status}`); const body = await res.json(); const node = (body.nodes || []).find( (n) => n && n.type === 'data' && Array.isArray(n.data) && n.data[0] && typeof n.data[0] === 'object' && 'playArray' in n.data[0], ); if (!node) throw new Error('daily payload not found in upstream response'); const data = unflatten(node.data); // The array ends with a stray 0 on the live site; keep only real rounds. const raw = (data.playArray || []).filter((r) => r && typeof r === 'object' && typeof r.URL === 'string'); if (raw.length === 0) throw new Error('daily payload had no rounds'); const rounds = raw.map(normalizeRound); for (const r of rounds) { if (!Number.isFinite(r.year) || !Number.isFinite(r.lat) || !Number.isFinite(r.lng)) { throw new Error('a round is missing its year or location'); } } return { no: raw[0].No != null ? String(raw[0].No) : null, dailyId: raw[0].DailyId ?? null, fetchedAt: new Date().toISOString(), rounds, };}
async function getDaily() { if (cache.payload && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload; if (!cache.inflight) { cache.inflight = fetchDaily() .then((payload) => { cache = { at: Date.now(), payload, inflight: null }; return payload; }) .catch((err) => { cache.inflight = null; if (cache.payload) { console.warn(`[daily] refresh failed, serving stale copy: ${err.message}`); return cache.payload; } throw err; }); } return cache.inflight;}
// ---------------------------------------------------------------------------// HTTP plumbing// ---------------------------------------------------------------------------function send(res, status, body, headers = {}) { res.writeHead(status, { 'cache-control': 'no-store', ...headers }); res.end(body);}
function sendJson(res, status, obj) { send(res, status, JSON.stringify(obj), { 'content-type': 'application/json; charset=utf-8' });}
async function serveStatic(req, res, pathname) { let rel; try { rel = decodeURIComponent(pathname); } catch { return send(res, 400, 'Bad request'); } if (rel === '/' || rel === '') rel = '/index.html'; const file = path.normalize(path.join(PUBLIC_DIR, rel)); if (!file.startsWith(PUBLIC_DIR + path.sep)) return send(res, 403, 'Forbidden'); let info; try { info = await stat(file); } catch { return send(res, 404, 'Not found'); } if (!info.isFile()) return send(res, 404, 'Not found'); const type = MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'; res.writeHead(200, { 'content-type': type, 'content-length': info.size, 'cache-control': 'no-cache' }); if (req.method === 'HEAD') return res.end(); createReadStream(file).pipe(res);}
async function proxyImage(res, target) { let url; try { url = new URL(target); } catch { return send(res, 400, 'Bad image url'); } if (url.protocol !== 'https:' || url.hostname !== IMAGE_HOST) return send(res, 403, 'Image host not allowed'); const upstream = await fetch(url, { headers: { 'user-agent': USER_AGENT } }); if (!upstream.ok || !upstream.body) return send(res, 502, `Image upstream responded ${upstream.status}`); res.writeHead(200, { 'content-type': upstream.headers.get('content-type') || 'application/octet-stream', 'cache-control': 'public, max-age=3600', }); Readable.fromWeb(upstream.body).pipe(res);}
const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); try { if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed'); if (url.pathname === '/api/daily') return sendJson(res, 200, await getDaily()); if (url.pathname === '/api/image') return await proxyImage(res, url.searchParams.get('url') || ''); return await serveStatic(req, res, url.pathname); } catch (err) { console.error(`[${req.method} ${url.pathname}]`, err); if (url.pathname.startsWith('/api/')) return sendJson(res, 502, { error: err.message || String(err) }); return send(res, 500, 'Internal error'); }});
server.listen(PORT, HOST, () => { console.log(`temporamancer listening on http://localhost:${PORT}`); if (HOST === '0.0.0.0') { for (const list of Object.values(networkInterfaces())) { for (const iface of list || []) { if (iface.family === 'IPv4' && !iface.internal) console.log(` on your network: http://${iface.address}:${PORT}`); } } }});