From dd2477390c8201a1f9ae1dae8f1400acb275ea84 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 16 Sep 2026 17:47:20 +0000 Subject: [PATCH] Who he is: live Wikipedia bio (auto-refreshed every 15-min crawl tick) + article link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/bio.js: refreshBio() pulls the article intro via the Wikipedia API (exintro, descriptive UA) and upserts the bio cache; getBio() reads it with a short accurate static fallback. Page loads NEVER hit Wikipedia. - crawl route: refreshBio() runs on every 15-min tick (over daily) — best-effort, a wiki hiccup can't fail the news crawl. - /api/bio: read-only route. - page.js: WhoBox renders the live text + 'Full article on Wikipedia' link + SOURCE: WIKIPEDIA · UPDATED line. - tests/bio-test.mjs: fallback + live-refresh + offline degradation. --- app/api/bio/route.js | 17 +++++++++ app/api/crawl/route.js | 14 ++++++- app/globals.css | 14 +++++++ app/page.js | 66 +++++++++++++++++++++++++++------ lib/bio.js | 83 ++++++++++++++++++++++++++++++++++++++++++ lib/db.js | 2 + tests/bio-test.mjs | 50 +++++++++++++++++++++++++ 7 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 app/api/bio/route.js create mode 100644 lib/bio.js create mode 100644 tests/bio-test.mjs diff --git a/app/api/bio/route.js b/app/api/bio/route.js new file mode 100644 index 0000000..46f632f --- /dev/null +++ b/app/api/bio/route.js @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; + +// The live "Who he is" bio. READ-ONLY: it returns the cached copy that the +// crawl tick refreshes from Wikipedia (lib/bio.js). A page load never calls +// Wikipedia — so this is fast and a wiki outage can't break the front page +// (getBio() falls back to a short static blurb). +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const { getBio } = await import("../../../lib/bio"); + const bio = await getBio(); + return NextResponse.json({ ok: true, ...bio }); + } catch (e) { + return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 }); + } +} diff --git a/app/api/crawl/route.js b/app/api/crawl/route.js index dbfdda3..aad00a2 100644 --- a/app/api/crawl/route.js +++ b/app/api/crawl/route.js @@ -14,7 +14,19 @@ export async function POST(req) { try { const { crawlOnce } = await import("../../../lib/crawl"); const crawl = await crawlOnce(); - return NextResponse.json({ ok: true, crawl }); + + // Refresh the live "Who he is" bio from Wikipedia on the same tick + // (well over "daily"). Best-effort: a Wikipedia hiccup must never + // make the crawl report a failure or lose the news it already gathered. + let bio = null; + try { + const { refreshBio } = await import("../../../lib/bio"); + bio = await refreshBio(); + } catch (e) { + bio = { ok: false, error: String((e && e.message) || e).slice(0, 120) }; + } + + return NextResponse.json({ ok: true, crawl, bio }); } catch (e) { return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 }); } diff --git a/app/globals.css b/app/globals.css index c6a84ef..ec5b62b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -705,6 +705,20 @@ footer.site .disc { max-width: 640px; margin: 0 auto 10px; line-height: 1.5; } .navbtn.board:hover { background: var(--gold-2); box-shadow: var(--glow); } .navbtn.board.active { background: var(--gold-2); box-shadow: var(--glow); } +/* "Full article on Wikipedia" link in the Who-he-is box */ +.wholink { + color: var(--gold); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: none; + border-bottom: 1px solid var(--gold-dim); + padding-bottom: 1px; +} +.wholink:hover { color: var(--gold-2); border-bottom-color: var(--gold); box-shadow: var(--glow-soft); } + /* In Pictures */ .photoscroll { display: grid; diff --git a/app/page.js b/app/page.js index 61f87d5..5a4b359 100644 --- a/app/page.js +++ b/app/page.js @@ -39,6 +39,47 @@ function Brand() { ); } +// The live "Who he is" box. Text comes from /api/bio (the crawl tick caches +// it from the Wikipedia article, so it's current; the box links straight to +// the article). Until the first bio lands, a short accurate default shows. +function WhoBox({ bio }) { + const text = bio && bio.text; + const updated = + bio && bio.updatedAt + ? new Date(bio.updatedAt).toLocaleDateString("en-PH", { month: "long", day: "numeric", year: "numeric" }) + : null; + return ( +
+
Who he is
+
+ {text ? ( + <> + {text} +
+ + Full article on Wikipedia ↗ + +
+ + ) : ( + + Paolo Benigno "Bam" Aguirre Aquino IV — senator of + the Philippines, and the first gamer to reach the chamber. + + )} +
+
+ SOURCE: WIKIPEDIA{updated ? ` · UPDATED ${updated.toUpperCase()}` : ""} +
+
+ ); +} + export default function Home() { const [view, setView] = useState("front"); // front | | board const [edition, setEdition] = useState(null); @@ -46,6 +87,7 @@ export default function Home() { const [stats, setStats] = useState(null); const [boardPosts, setBoardPosts] = useState(null); const [boardCount, setBoardCount] = useState(0); + const [bio, setBio] = useState(null); // live "Who he is" (cached from Wikipedia) const [reading, setReading] = useState(null); // story in the drawer const [toast, setToast] = useState(""); const toastTimer = useRef(null); @@ -85,9 +127,20 @@ export default function Home() { } } + async function loadBio() { + try { + const r = await fetch("/api/bio", { cache: "no-store", signal: AbortSignal.timeout(10000) }); + const j = await r.json(); + if (j.ok && j.text) setBio(j); + } catch { + /* bio box shows the static fallback if this fails */ + } + } + useEffect(() => { loadFeed(); loadBoard(); + loadBio(); return () => clearTimeout(toastTimer.current); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -321,18 +374,7 @@ export default function Home() { -
-
Who he is
-
- Francis "Bam" Tan Aquino IV — Senator for - Pampanga. The first gamer to reach the Philippine Senate: a runner-up in the - 2025 PalaPinas esports championship, and - a legislator who has made the fight for college - students, scholarships, and fair electricity his signature. This desk follows - the wire for every story that bears his name — from verified Philippine and - international news outlets only. -
-
+ diff --git a/lib/bio.js b/lib/bio.js new file mode 100644 index 0000000..69ea4ce --- /dev/null +++ b/lib/bio.js @@ -0,0 +1,83 @@ +// BidaAngMamamayan — live "Who he is" bio, sourced from the Wikipedia article. +// +// The front page's "Who he is" box used to be a hard-coded paragraph (which +// drifted: it said "Francis 'Bam' Tan Aquino IV" and hand-picked a couple of +// facts). Daniel (2026-09-16): source it from the LIVE Wikipedia article, +// link to it, and keep it updated daily. +// +// How it stays fresh without slowing page loads: +// * refreshBio() is called by the crawl tick (scripts/cron-crawl every 15 +// min). It fetches the article's intro via the Wikipedia API and upserts +// a single doc in the `bio` collection. That is well over "daily". +// * getBio() (used by /api/bio on page load) only READS that cached doc — +// it never calls Wikipedia, so a page load costs zero external requests +// and a Wikipedia outage can't break the site. +// * If the cache is empty or stale-but-unrefreshable, getBio() falls back to +// a short, accurate static blurb so the box is never blank. +// +// Wikipedia API: action=query, prop=extracts, exintro, explaintext, +// titles="Bam Aquino", redirects=1. Requires a descriptive User-Agent +// (Wikipedia blocks the default Node UA). + +import { getCollections } from "./db.js"; + +const ARTICLE_TITLE = "Bam Aquino"; +const WIKI_API = "https://en.wikipedia.org/w/api.php"; +const WIKI_LINK = "https://en.wikipedia.org/wiki/Bam_Aquino"; +const UA = "BamaDeskBot/1.0 (bidaangmamamayan.com; contact danieldiaz@boyemma.com)"; + +// Fallback blurb — used ONLY when the live cache is unavailable. Kept short +// and accurate; it is a last resort, not the normal path. +const FALLBACK_TEXT = + 'Paolo Benigno "Bam" Aguirre Aquino IV is a Filipino politician and media ' + + "personality who has served as a senator of the Philippines since 2025. He " + + "is the first gamer to reach the Philippine Senate — a runner-up in the " + + "2025 PalaPinas esports championship — and has made the fight for college " + + "students, scholarships, and fair electricity his legislative signature."; + +export async function refreshBio() { + const { bio } = await getCollections(); + const params = new URLSearchParams({ + action: "query", + prop: "extracts", + exintro: "1", + explaintext: "1", + titles: ARTICLE_TITLE, + format: "json", + redirects: "1", + }); + const res = await fetch(`${WIKI_API}?${params}`, { + headers: { "User-Agent": UA, Accept: "application/json" }, + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) throw new Error(`wiki ${res.status}`); + const j = await res.json(); + const page = j && j.query && j.query.pages + ? Object.values(j.query.pages)[0] + : null; + const text = (page && page.extract ? page.extract : "").trim(); + if (!text) throw new Error("empty intro"); + // Keep only the lead section (first few paragraphs) — the box is a blurb, + // not the whole article. The full text is one click away via the link. + const paragraphs = text.split(/\n\n+/).map((p) => p.trim()).filter(Boolean); + const lead = paragraphs.slice(0, 3).join(" "); + await bio.updateOne( + { _id: "primary" }, + { $set: { _id: "primary", text: lead, full: text, url: WIKI_LINK, updatedAt: new Date() } }, + { upsert: true } + ); + return { ok: true, text: lead, updatedAt: new Date() }; +} + +export async function getBio() { + try { + const { bio } = await getCollections(); + const row = await bio.findOne({ _id: "primary" }); + if (row && row.text) { + return { ok: true, text: row.text, url: row.url || WIKI_LINK, updatedAt: row.updatedAt || null, source: "Wikipedia" }; + } + } catch { + /* fall through to the static blurb */ + } + return { ok: true, text: FALLBACK_TEXT, url: WIKI_LINK, updatedAt: null, source: "fallback" }; +} diff --git a/lib/db.js b/lib/db.js index a9312b7..7a320a2 100644 --- a/lib/db.js +++ b/lib/db.js @@ -48,5 +48,7 @@ export async function getCollections() { board: db.collection("board"), // Rate-limit counters (board spam control). rate: db.collection("rate"), + // Live "Who he is" bio cached from Wikipedia (lib/bio.js). + bio: db.collection("bio"), }; } diff --git a/tests/bio-test.mjs b/tests/bio-test.mjs new file mode 100644 index 0000000..5216860 --- /dev/null +++ b/tests/bio-test.mjs @@ -0,0 +1,50 @@ +// lib/bio.js — live Wikipedia "Who he is" cache. +// refreshBio() pulls the article intro from the Wikipedia API and upserts the +// `bio` doc; getBio() reads it (falling back to the static blurb). +// Runs against a scratch Mongo. Needs network (Wikipedia) for refreshBio. +// RUN: MONGODB_URI=mongodb://127.0.0.1:27099/bama node tests/bio-test.mjs +import { MongoClient } from "mongodb"; + +// bio.js -> db.js reads BAMA_DB_NAME at module load, so set it BEFORE the +// dynamic import of ../lib/bio.js. Use a dedicated scratch db for this test. +process.env.BAMA_DB_NAME = process.env.BAMA_DB_NAME || "bama_bio_test"; +const uri = process.env.MONGODB_URI || "mongodb://127.0.0.1:27099/bama"; +const mc = new MongoClient(uri, { serverSelectionTimeoutMS: 8000 }); +await mc.connect(); +const db = mc.db(process.env.BAMA_DB_NAME); +await db.collection("bio").deleteMany({}); + +let fails = 0; +const check = (name, cond, detail = "") => { + console.log(` ${cond ? "ok " : "FAIL"} ${name}${detail ? " — " + detail : ""}`); + if (!cond) fails++; +}; + +// 1. getBio before any refresh -> static fallback, still has a link +const { getBio, refreshBio } = await import("../lib/bio.js"); +let bio = await getBio(); +check("getBio falls back to text when cache empty", bio.ok === true && typeof bio.text === "string" && bio.text.length > 40, JSON.stringify(bio.text).slice(0, 60)); +check("fallback still carries the Wikipedia link", /wikipedia\.org\/wiki\/Bam_Aquino/.test(bio.url || ""), bio.url); +check("fallback source flagged", bio.source === "fallback", bio.source); + +// 2. refreshBio pulls the live article (needs network) +console.log("refreshing from Wikipedia..."); +try { + const r = await refreshBio(); + check("refreshBio ok", r.ok === true); + check("refreshed text mentions the senator", /Aquino/i.test(r.text || ""), JSON.stringify(r.text).slice(0, 70)); + // now getBio should serve the cached live copy + bio = await getBio(); + check("getBio now serves cached live copy", bio.source === "Wikipedia", bio.source); + check("cached copy has updated date", bio.updatedAt instanceof Date || typeof bio.updatedAt === "object", String(bio.updatedAt)); +} catch (e) { + // No network (e.g. offline CI) -> refresh throws; getBio must still fall back. + console.log(" (network refresh failed: " + (e && e.message) + " — verifying graceful fallback)"); + bio = await getBio(); + check("offline: getBio still returns the fallback blurb", bio.ok === true && bio.source === "fallback", bio.source); +} + +await db.collection("bio").deleteMany({}); +await mc.close(); +console.log(fails ? `\n${fails} FAILED` : "\nall passed"); +process.exit(fails ? 1 : 0);