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:
parent
97c5d7c438
commit
dd2477390c
17
app/api/bio/route.js
Normal file
17
app/api/bio/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,7 +14,19 @@ export async function POST(req) {
|
|||||||
try {
|
try {
|
||||||
const { crawlOnce } = await import("../../../lib/crawl");
|
const { crawlOnce } = await import("../../../lib/crawl");
|
||||||
const crawl = await crawlOnce();
|
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) {
|
} catch (e) {
|
||||||
return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 });
|
return NextResponse.json({ ok: false, error: String((e && e.message) || e) }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:hover { background: var(--gold-2); box-shadow: var(--glow); }
|
||||||
.navbtn.board.active { 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 */
|
/* In Pictures */
|
||||||
.photoscroll {
|
.photoscroll {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
66
app/page.js
66
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 (
|
||||||
|
<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 "Bam" 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() {
|
export default function Home() {
|
||||||
const [view, setView] = useState("front"); // front | <catKey> | board
|
const [view, setView] = useState("front"); // front | <catKey> | board
|
||||||
const [edition, setEdition] = useState(null);
|
const [edition, setEdition] = useState(null);
|
||||||
@ -46,6 +87,7 @@ export default function Home() {
|
|||||||
const [stats, setStats] = useState(null);
|
const [stats, setStats] = useState(null);
|
||||||
const [boardPosts, setBoardPosts] = useState(null);
|
const [boardPosts, setBoardPosts] = useState(null);
|
||||||
const [boardCount, setBoardCount] = useState(0);
|
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 [reading, setReading] = useState(null); // story in the drawer
|
||||||
const [toast, setToast] = useState("");
|
const [toast, setToast] = useState("");
|
||||||
const toastTimer = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
loadFeed();
|
loadFeed();
|
||||||
loadBoard();
|
loadBoard();
|
||||||
|
loadBio();
|
||||||
return () => clearTimeout(toastTimer.current);
|
return () => clearTimeout(toastTimer.current);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@ -321,18 +374,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel railcard">
|
<WhoBox bio={bio} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
83
lib/bio.js
Normal file
83
lib/bio.js
Normal 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" };
|
||||||
|
}
|
||||||
@ -48,5 +48,7 @@ export async function getCollections() {
|
|||||||
board: db.collection("board"),
|
board: db.collection("board"),
|
||||||
// Rate-limit counters (board spam control).
|
// Rate-limit counters (board spam control).
|
||||||
rate: db.collection("rate"),
|
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
50
tests/bio-test.mjs
Normal 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);
|
||||||
Loading…
x
Reference in New Issue
Block a user