Who he is: live Wikipedia bio (auto-refreshed every 15-min crawl tick) + article link

- 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 <date> line.
- tests/bio-test.mjs: fallback + live-refresh + offline degradation.
This commit is contained in:
Ryan 2026-09-16 17:47:20 +00:00
parent 97c5d7c438
commit dd2477390c
7 changed files with 233 additions and 13 deletions

17
app/api/bio/route.js Normal file
View File

@ -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 });
}
}

View File

@ -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 });
}

View File

@ -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;

View File

@ -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 (
<div className="panel railcard">
<div className="ph">Who he is</div>
<div style={{ padding: 14, color: "var(--muted)", fontSize: 15, fontWeight: 500, lineHeight: 1.55 }}>
{text ? (
<>
{text}
<div style={{ marginTop: 12 }}>
<a
className="wholink"
href={bio.url}
target="_blank"
rel="noopener noreferrer"
>
Full article on Wikipedia ↗
</a>
</div>
</>
) : (
<span>
<b style={{ color: "var(--text)" }}>Paolo Benigno &quot;Bam&quot; Aguirre Aquino IV</b> — senator of
the Philippines, and the first gamer to reach the chamber.
</span>
)}
</div>
<div className="whosrc" style={{ padding: "0 14px 12px", fontFamily: "var(--font-mono)", fontSize: 10.5, color: "var(--faint)", letterSpacing: "0.04em" }}>
SOURCE: WIKIPEDIA{updated ? ` · UPDATED ${updated.toUpperCase()}` : ""}
</div>
</div>
);
}
export default function Home() {
const [view, setView] = useState("front"); // front | <catKey> | 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() {
</div>
</div>
<div className="panel railcard">
<div className="ph">Who he is</div>
<div style={{ padding: 14, color: "var(--muted)", fontSize: 15, fontWeight: 500, lineHeight: 1.5 }}>
<b style={{ color: "var(--text)" }}>Francis "Bam" Tan Aquino IV</b> — Senator for
Pampanga. The first gamer to reach the Philippine Senate: a runner-up in the
2025 <b style={{ color: "var(--gold)" }}>PalaPinas</b> esports championship, and
a legislator who has made the fight for <b style={{ color: "var(--gold)" }}>college
students, scholarships, and fair electricity</b> his signature. This desk follows
the wire for every story that bears his name — from verified Philippine and
international news outlets only.
</div>
</div>
<WhoBox bio={bio} />
</div>
</div>

83
lib/bio.js Normal file
View File

@ -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" };
}

View File

@ -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"),
};
}

50
tests/bio-test.mjs Normal file
View File

@ -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);