diff --git a/app/globals.css b/app/globals.css
index a7b8eb6..c6a84ef 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -667,4 +667,118 @@ footer.site .disc { max-width: 640px; margin: 0 auto 10px; line-height: 1.5; }
animation: shimmer 1.4s infinite;
border-radius: 6px;
}
+
+/* ── Full-width content bands (In Pictures, The Board) ──────────────────
+ These are deliberately NOT news desks: they sit above / below the wire
+ grid in their own gold-edged bands so a visitor immediately sees them. */
+.band {
+ margin: 22px 0;
+ background:
+ radial-gradient(120% 200% at 0% 0%, rgba(255, 212, 38, 0.05), transparent 45%),
+ var(--panel);
+ border: 1px solid var(--line);
+ border-top: 2px solid var(--gold-dim);
+ border-radius: 10px;
+ padding: 18px 20px 22px;
+ box-shadow: var(--glow-soft);
+}
+.bandhead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; margin-bottom: 14px; }
+.bandtitle {
+ font-family: var(--font-display);
+ font-weight: 800;
+ color: var(--gold);
+ font-size: 20px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+.bandsub { color: var(--muted); font-size: 14px; font-weight: 500; letter-spacing: 0.02em; }
+
+/* The board button is NOT a news desk — give it its own gold treatment so it
+ reads as "another area of the site", not a category. */
+.navbtn.board {
+ background: var(--gold);
+ color: #14110b;
+ border-color: var(--gold);
+ font-weight: 700;
+}
+.navbtn.board .dot { background: #14110b; }
+.navbtn.board:hover { background: var(--gold-2); box-shadow: var(--glow); }
+.navbtn.board.active { background: var(--gold-2); box-shadow: var(--glow); }
+
+/* In Pictures */
+.photoscroll {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 12px;
+}
+.photo {
+ margin: 0;
+ background: var(--bg-2);
+ border: 1px solid var(--line-soft);
+ border-radius: 8px;
+ overflow: hidden;
+ cursor: pointer;
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
+}
+.photo:hover { border-color: var(--gold-dim); box-shadow: var(--glow-soft); transform: translateY(-2px); }
+.photo .phimg {
+ aspect-ratio: 4 / 3;
+ background: var(--bg-2) center/cover no-repeat;
+}
+.photo figcaption { padding: 9px 11px 11px; }
+.photo .cap {
+ color: var(--text);
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.photo .cred {
+ margin-top: 7px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--gold);
+ letter-spacing: 0.02em;
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.photo .lic {
+ margin-top: 3px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--faint);
+ letter-spacing: 0.02em;
+}
+.photos-note {
+ margin-top: 12px;
+ color: var(--faint);
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+}
+
+/* The Board band */
+.board-band .postform.big { max-width: 720px; }
+.board-band .postform.big { display: grid; grid-template-columns: 1fr 2fr; gap: 10px 14px; align-items: end; }
+.board-band .postform.big label[for="bnick"] { grid-column: 1; }
+.board-band .postform.big #bnick { grid-column: 1; }
+.board-band .postform.big label[for="bmsg"] { grid-column: 2; }
+.board-band .postform.big #bmsg { grid-column: 2; }
+.board-band .postform.big .go { grid-column: 2; justify-self: start; }
+.board-band .postform.big .err { grid-column: 1 / -1; }
+.postlist.board { max-width: none; margin: 16px auto 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
+.postlist.board .post { margin-bottom: 0; }
+
+@media (max-width: 640px) {
+ .board-band .postform.big { grid-template-columns: 1fr; }
+ .board-band .postform.big label[for="bnick"], .board-band .postform.big #bnick,
+ .board-band .postform.big label[for="bmsg"], .board-band .postform.big #bmsg,
+ .board-band .postform.big .go { grid-column: 1; }
+}
@keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
diff --git a/app/page.js b/app/page.js
index ee511d2..61f87d5 100644
--- a/app/page.js
+++ b/app/page.js
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CATEGORIES, sectionName } from "../lib/categories.js";
import { clipText, dek } from "../lib/text.js";
+import { PHOTOS, commonsUrl } from "../lib/photos.js";
// BidaAngMamamayan — a PUBLIC tribute news desk for Senator BAM Aquino.
// No login, no accounts, no "I like this" — the site's whole identity is the
@@ -200,7 +201,7 @@ export default function Home() {
{c.label}
))}
- { e.preventDefault(); setView("board"); window.scrollTo({ top: 0 }); }}>
+ { e.preventDefault(); if (view === "front") { document.getElementById("board-band")?.scrollIntoView({ behavior: "smooth", block: "start" }); } else { setView("front"); setTimeout(() => document.getElementById("board-band")?.scrollIntoView({ behavior: "smooth", block: "start" }), 60); } }}>
{boardCount > 0 && }Thank You Board
@@ -246,6 +247,30 @@ export default function Home() {
{view === "front" && (
<>
+ {/* ── IN PICTURES — credited public photos, its own full-width band ── */}
+
+
+ In Pictures
+ Public photos of the senator — every image credited to its source
+
+
+ {PHOTOS.map((p, i) => (
+
window.open(commonsUrl(p.commons), "_blank", "noopener")}>
+
+
+ {p.caption}
+ 📷 {p.source}
+ {p.credit}
+
+
+ ))}
+
+
+ Click a photo to view it on Wikimedia Commons. Photos are public-domain, sourced from the
+ senator's official Senate / social pages. This section is separate from the news wire.
+
+
+
{!edition && (
@@ -296,21 +321,6 @@ export default function Home() {
-
-
Who he is
@@ -319,11 +329,41 @@ export default function Home() {
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.
+ the wire for every story that bears his name — from verified Philippine and
+ international news outlets only.
+
+ {/* ── THE BOARD — its own prominent full-width band, not a news desk ── */}
+
+
+ Thank you, Senator BAM
+ The community board — say it to the senator, no account needed
+
+
+
+ {(boardPosts || []).slice(0, 12).map((p) => (
+
+
{p.nick} {ago(p.createdAt)}
+
{p.message}
+
+ ))}
+ {boardPosts && boardPosts.length === 0 && (
+
The board is empty. Say the first thank you.
+ )}
+
+
>
)}
diff --git a/lib/crawl.js b/lib/crawl.js
index 5a94e5c..d8dab29 100644
--- a/lib/crawl.js
+++ b/lib/crawl.js
@@ -20,7 +20,7 @@
import Parser from "rss-parser";
import { getCollections } from "./db.js";
import { sanitize, extractImage } from "./llm.js";
-import { BAM_QUERIES, PH_NATIVE, queryFeed, unwrapFeedLink, stripPublisherSuffix, publisherFrom, publisherFromUrl } from "./sources.js";
+import { BAM_QUERIES, PH_NATIVE, queryFeed, unwrapFeedLink, stripPublisherSuffix, publisherFrom, publisherFromUrl, publisherVerified } from "./sources.js";
import { normalizeCategory } from "./categories.js";
import { prefetchArticles } from "./article.js";
@@ -193,7 +193,7 @@ export async function crawlOnce({ perFeed = 20, maxStories = 300, concurrency =
// Normalise + gate (Pass 1).
let candidates = [];
const errors = [];
- let skippedStale = 0, skippedUndated = 0, skippedSynd = 0, gatedOut = 0;
+ let skippedStale = 0, skippedUndated = 0, skippedSynd = 0, skippedUnverified = 0, gatedOut = 0;
for (const r of results) {
if (!r || r.error || !r.feed) {
if (r && r.error) errors.push(`${r.job?.name || "job"}: ${r.error}`);
@@ -226,6 +226,10 @@ export async function crawlOnce({ perFeed = 20, maxStories = 300, concurrency =
it.named = gate.named;
return true;
}).filter((it) => {
+ // VERIFIED PUBLISHERS ONLY (Daniel, 2026-09-15): the article URL's host
+ // must be on the allowlist. This runs before mirror/stale so a tabloid
+ // story never reaches the desk even if it names him.
+ if (!publisherVerified(it.link)) { skippedUnverified++; return false; }
if (isNaN(it.published.getTime())) { skippedUndated++; return false; }
if (isMirrorHost(it.link)) { skippedSynd++; return false; }
if (it.published.getTime() < newestAllowed) { skippedStale++; return false; }
@@ -264,6 +268,7 @@ export async function crawlOnce({ perFeed = 20, maxStories = 300, concurrency =
const gate = bamRelevance(it.title, it.desc, { requireNamed: false });
if (!gate.ok) { gatedOut++; return false; }
it.named = gate.named;
+ if (!publisherVerified(it.link)) { skippedUnverified++; return false; }
if (isNaN(it.published.getTime())) { skippedUndated++; return false; }
if (isMirrorHost(it.link)) { skippedSynd++; return false; }
if (it.published.getTime() < newestAllowed) { skippedStale++; return false; }
@@ -369,5 +374,5 @@ export async function crawlOnce({ perFeed = 20, maxStories = 300, concurrency =
console.warn("[bama] article prefetch failed:", String((e && e.message) || e).slice(0, 120));
}
- return { added, reingested, withImage, gatedOut, skippedStale, skippedUndated, skippedSynd, named: namedCount, feeds: jobs.length, usedFallback, articles, errors: errors.slice(0, 12), collectedAt: now };
+ return { added, reingested, withImage, gatedOut, skippedStale, skippedUndated, skippedSynd, skippedUnverified, named: namedCount, feeds: jobs.length, usedFallback, articles, errors: errors.slice(0, 12), collectedAt: now };
}
diff --git a/lib/photos.js b/lib/photos.js
new file mode 100644
index 0000000..260ae76
--- /dev/null
+++ b/lib/photos.js
@@ -0,0 +1,75 @@
+// BidaAngMamamayan — curated public photos of Senator Bam Aquino.
+//
+// Every image is REAL and VERIFIED: fetched + license-checked via the
+// Wikimedia Commons API, and each URL confirmed to load as an image
+// (2006-09-15). Sources are the senator's official Senate / Facebook pages
+// (and one allied senator's page), mirrored on Wikimedia Commons, all
+// public domain. The site credits the original source on each card and
+// links out to the Commons file page — Daniel asked that we cite where
+// each photo came from.
+//
+// URL NOTE: we use the ORIGINALS on upload.wikimedia.org (not the
+// thumb.wikimedia.org scaled copies). The thumb host returns 502 to
+// datacenter egress under load; the originals are small (<320 KB each,
+// the portrait PNG is 1.6 MB) and load reliably. Do NOT scrape random
+// image hosts — only Commons-hosted, credited, public-domain photos.
+
+const CDN = "https://commons.wikimedia.org/wiki/";
+
+// url = verified image. caption/source/credit = the attribution shown on
+// the card. commons = the File: page (the "view source" link).
+export const PHOTOS = [
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/4/4a/Bam_Aquino_2025_%28Cropped%29.png",
+ caption: "The 2025 Senate portrait",
+ source: "Senator Bam Aquino's official page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Bam_Aquino_2025_(Cropped).png",
+ },
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/7/71/Bam_Aquino_and_Pope_Francis.jpg",
+ caption: "Meets Pope Francis at St. Peter's Square, Vatican",
+ source: "Senator Bam Aquino's official Facebook page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Bam_Aquino_and_Pope_Francis.jpg",
+ },
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/5/5c/Senator_Bam_Aquino_and_Education_Secretary_Sonny_Angara.jpg",
+ caption: "With DepEd Sec. Sonny Angara on the classroom / free-college push",
+ source: "Senator Bam Aquino's official Facebook page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Senator_Bam_Aquino_and_Education_Secretary_Sonny_Angara.jpg",
+ },
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/4/4e/Senator_Bam_Aquino_with_Bini_%2801%29.jpg",
+ caption: "Nation's girl group BINI visits the Philippine Senate",
+ source: "Senator Bam Aquino's official Facebook page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Senator_Bam_Aquino_with_Bini_(01).jpg",
+ },
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/1/12/Bam_Aquino%2C_Brent_Manalo_and_Mika_Salamanca_in_2025.jpg",
+ caption: "With PBB: Celebrity Collab winners Brent Manalo & Mika Salamanca",
+ source: "Senator Bam Aquino's official Facebook page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Bam_Aquino,_Brent_Manalo_and_Mika_Salamanca_in_2025.jpg",
+ },
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/f/f5/Risa_Hontiveros%2C_Bam_Aquino%2C_and_Kiko_Pangilinan_in_2025.jpg",
+ caption: "At a 2025 campaign event with Sen. Risa Hontiveros & Sen. Kiko Pangilinan",
+ source: "Senator Risa Hontiveros's official Facebook page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Risa_Hontiveros,_Bam_Aquino,_and_Kiko_Pangilinan_in_2025.jpg",
+ },
+ {
+ url: "https://upload.wikimedia.org/wikipedia/commons/6/60/Senator_Bam_Aquino_at_the_Ninoy_Aquino_International_Airport_Terminal_1_to_commemorate_the_42nd_anniversary_of_the_assassination_of_Ninoy_Aquino_%2801%29.jpg",
+ caption: "NAIA Terminal 1 — 42nd anniversary of Ninoy Aquino's assassination",
+ source: "Senator Bam Aquino's official Facebook page",
+ credit: "Public domain, via Wikimedia Commons",
+ commons: "File:Senator_Bam_Aquino_at_the_Ninoy_Aquino_International_Airport_Terminal_1_to_commemorate_the_42nd_anniversary_of_the_assassination_of_Ninoy_Aquino_(01).jpg",
+ },
+];
+
+export function commonsUrl(file) {
+ return CDN + encodeURIComponent(file).replace(/%20/g, "_");
+}
diff --git a/lib/sources.js b/lib/sources.js
index 263b36d..dec9aa3 100644
--- a/lib/sources.js
+++ b/lib/sources.js
@@ -35,14 +35,78 @@ export const BAM_QUERIES = [
{ query: "Bam Aquino bill Senate", name: "Legislative Desk", category: "politics" },
];
+// ── Verified publishers only ─────────────────────────────────────────────
+// Daniel's rule (2026-09-15): the site features ESTABLISHED news outlets and
+// state media — no tabloids, blogs, regional radio, or nameless aggregators.
+// The gate is HOST-BASED (not the Bing label, which is
+// unreliable): every story's unwrapped article URL must resolve to one of
+// these suffixes, or it is dropped before the relevance gate even runs.
+//
+// Each entry is a hostname or a hostname-suffix (matched with endsWith ".").
+// PNA = state wire; Inquirer / Rappler / GMA / ABS-CBN / Manila Bulletin /
+// Philstar / BusinessWorld / Manila Times / SunStar / Interaksyon / Daily
+// Tribune / NewsLine / Cebu Daily = established PH newsrooms; the international
+// set covers his global stories (Pope Francis, etc.).
+export const VERIFIED_HOSTS = [
+ // state wire
+ "pna.gov.ph",
+ // Philippine Daily Inquirer group
+ "inquirer.net", "newsinfo.inquirer.net", "manilastandard.inquirer.net", "cebudailynews.inquirer.net",
+ // Rappler
+ "rappler.com",
+ // GMA
+ "gmanetwork.com", "news.gmanetwork.com", "interaksyon.com",
+ // ABS-CBN
+ "abs-cbn.com", "news.abs-cbn.com", "tvnews.abs-cbn.com",
+ // Manila Bulletin
+ "mb.com.ph", "manilabulletin.com",
+ // Philstar (ABS-CBN)
+ "philstar.com",
+ // BusinessWorld
+ "bworldonline.com", "businessworld.com.ph",
+ // BusinessMirror (Manila Times)
+ "businessmirror.com.ph",
+ // Manila Times
+ "manilatimes.net",
+ // SunStar (established regional wire)
+ "sunstar.com.ph",
+ // Daily Tribune
+ "dailytribune.com.ph",
+ // NewsLine
+ "newsline.ph",
+ // international (his global coverage)
+ "bbc.com", "bbc.co.uk", "reuters.com", "apnews.com", "aljazeera.com", "cna.com.sg",
+];
+
+// A story's publisher is verified when its article URL's host equals a
+// VERIFIED_HOSTS entry or sits under it (subdomains included: e.g.
+// "news.rappler.com" is under "rappler.com"). Bing apiclick wrappers are
+// unwrapped to the publisher URL BEFORE this check, so the host is real.
+export function publisherVerified(url) {
+ let host;
+ try { host = new URL(String(url || "")).hostname.toLowerCase(); } catch { return false; }
+ host = host.replace(/^www\./, "");
+ if (!host) return false;
+ for (const v of VERIFIED_HOSTS) {
+ const vh = v.replace(/^www\./, "");
+ if (host === vh || host.endsWith("." + vh)) return true;
+ }
+ return false;
+}
+
// ── Associated pool (fallback only) ──────────────────────────────────────
-// General PH wires. The crawler runs these ONLY when the BAM queries produced
-// zero named stories this tick (verified: that happens on quiet days). Their
-// items pass through the same gate, so only genuine senate/BAM-adjacent copy
-// ever prints from here.
+// General PH wires from VERIFIED publishers. The crawler runs these ONLY when
+// the BAM queries produced zero named stories this tick (quiet days). Their
+// items pass the same verified-publisher + relevance gates, so only genuine
+// senate / Aquino-family copy ever prints from here.
export const PH_NATIVE = [
{ key: "pna", name: "Philippine News Agency", category: "ph", feeds: ["https://www.pna.gov.ph/feed/"] },
{ key: "inqnews", name: "Inquirer News", category: "ph", feeds: ["https://newsinfo.inquirer.net/feed"] },
+ { key: "rappler", name: "Rappler", category: "ph", feeds: ["https://www.rappler.com/rss"] },
+ { key: "gma", name: "GMA News", category: "ph", feeds: ["https://www.gmanetwork.com/rss"] },
+ { key: "interaksyon", name: "Interaksyon (GMA)", category: "ph", feeds: ["https://www.interaksyon.com/feed"] },
+ { key: "abs", name: "ABS-CBN News", category: "ph", feeds: ["https://news.abs-cbn.com/rss"] },
+ { key: "mb", name: "Manila Bulletin", category: "ph", feeds: ["https://mb.com.ph/rss"] },
];
// ── Bing News RSS ────────────────────────────────────────────────────────
diff --git a/scripts/cron-crawl.cjs b/scripts/cron-crawl.cjs
index aa3b138..ec265d8 100644
--- a/scripts/cron-crawl.cjs
+++ b/scripts/cron-crawl.cjs
@@ -25,7 +25,7 @@ let run = async () => {
const j = await res.json().catch(() => ({}));
const c = (j && j.crawl) || {};
console.log(
- `[bama-crawl] ${res.status} added=${c.added} named=${c.named} fallback=${c.usedFallback ? "yes" : "no"} gated=${c.gatedOut} imgs=${c.withImage} articles=${c.articles ? c.articles.ok + "/" + c.articles.fetched : "?"} ${c.errors && c.errors.length ? "errs=" + c.errors.length : ""}`
+ `[bama-crawl] ${res.status} added=${c.added} named=${c.named} fallback=${c.usedFallback ? "yes" : "no"} gated=${c.gatedOut} unverified=${c.skippedUnverified} imgs=${c.withImage} articles=${c.articles ? c.articles.ok + "/" + c.articles.fetched : "?"} ${c.errors && c.errors.length ? "errs=" + c.errors.length : ""}`
);
process.exit(res.ok ? 0 : 1);
};
diff --git a/tests/ui-check.mjs b/tests/ui-check.mjs
index 6e75e8d..df3765d 100644
--- a/tests/ui-check.mjs
+++ b/tests/ui-check.mjs
@@ -107,18 +107,35 @@ if (await lead.count()) {
errors.push("no lead story card");
}
-// board view — the nav tab (there's also a "SIGN THE BOARD" link on the front)
-await page.getByRole("link", { name: "Thank You Board" }).click();
-await page.waitForTimeout(800);
-await page.screenshot({ path: OUT + "/03-board-empty.png" });
-await page.fill("#nick", "PampangaGamer");
-await page.fill("#msg", "Thank you Senator BAM for the scholarship expansion — my whole batch is riding on it!");
-await page.locator("button.go").click();
-await page.waitForTimeout(1200);
-await page.screenshot({ path: OUT + "/04-board-posted.png" });
-const boardText = await page.textContent(".postlist");
-if (!/PampangaGamer/.test(boardText || "")) errors.push("posted nick not rendered");
-if (!/Thank you Senator BAM for the scholarship/.test(boardText || "")) errors.push("posted message not rendered");
+// In Pictures band — credited photos, its own area (not a desk)
+const photosBand = page.locator(".photos-band");
+if (await photosBand.count()) {
+ const photos = await photosBand.locator(".photo").count();
+ if (photos < 5) errors.push("expected >=5 credited photos, got " + photos);
+ const credText = await photosBand.textContent();
+ if (!/Public domain/i.test(credText)) errors.push("photo source credit missing");
+ if (!/official/i.test(credText)) errors.push("photo 'official' source label missing");
+ await photosBand.screenshot({ path: OUT + "/03a-in-pictures.png" }).catch(() => {});
+} else {
+ errors.push("In Pictures band missing");
+}
+
+// the front-page BOARD band (its own area, not a desk) — post a thank-you
+await page.locator("#board-band").scrollIntoViewIfNeeded().catch(() => {});
+await page.waitForTimeout(300);
+const boardBand = page.locator("#board-band");
+if (await boardBand.count()) {
+ await page.fill("#bnick", "PampangaGamer");
+ await page.fill("#bmsg", "Thank you Senator BAM for the scholarship expansion — my whole batch is riding on it!");
+ await boardBand.locator("button.go").click();
+ await page.waitForTimeout(1200);
+ await boardBand.screenshot({ path: OUT + "/04-board-posted.png" }).catch(() => {});
+ const boardText = await boardBand.textContent();
+ if (!/PampangaGamer/.test(boardText || "")) errors.push("board band: posted nick not rendered");
+ if (!/Thank you Senator BAM for the scholarship/.test(boardText || "")) errors.push("board band: posted message not rendered");
+} else {
+ errors.push("front-page board band missing");
+}
// back to front, then a desk view — click the Education desk card (grid lives on front)
await page.getByRole("link", { name: "Front", exact: true }).click();