Ryan 6b06e08c08 BidaAngMamayan: public supporter tribute news desk for Senator BAM Aquino
Forked from Forthena (PERSONAL/forthena) and retargeted:
- NO login, NO LLM: the edition is public and the ranking is deterministic
  (stories that name Sen. BAM lead; freshest first) — Daniel: no
  like/dislike or store profile for visitors.
- Crawler scoped to BAM: Bing news keyword queries + Inquirer/Rappler
  fallback wires, with a two-tier relevance gate (named-BAM always;
  Aquino+legislative-context only from fallback) so op-eds and non-Bam
  senators are dropped.
- Desks: BAM (lead), Gaming, Education, Politics, Energy, PH, Business.
- Community board /board: 'Thank you Senator BAM' — nickname-only posts,
  HTML-stripped, URL-only rejected, IP rate-limited (3/10min, 20/day).
- Yellow-gamer theme (Orbitron/Rajdhani/JetBrains Mono, dark + gold,
  HUD corners), supporter disclaimer in header and footer.
- Tests: category/date/relevance/text suites + Mongo board suite +
  full HTTP e2e (all passing).

Infra: 66.181.46.132 (bidaangmamamayan.com), local Mongo 7.0,
nginx + certbot TLS, PM2 bama (:3000) + 15-min crawl cron.
2026-09-15 08:37:03 +00:00

127 lines
4.0 KiB
JavaScript

// BidaAngMamamayan feed read path — the public edition.
//
// No profiles, no learning, no LLM. The ranking is deliberate and
// deterministic: stories that NAME Sen. Bam lead; within each tier, freshness
// decides. This is a tribute site, not a taste engine — the "taste" is baked
// in by the crawler's relevance gate.
import { getCollections } from "./db.js";
import { normalizeCategory, sectionName, CATEGORY_LABELS, CATEGORIES } from "./categories.js";
export { sectionName };
const POOL_HOURS = 24 * 14; // the site's memory window for the edition
// Simple, stable score: named-BAM beats context, fresh beats old, image
// presence nudges a card up (a photoless tribute edition looks broken).
function rank(s, nowMs) {
let score = 0;
if (s.named) score += 1000;
if (s.image) score += 50;
const ageH = (nowMs - new Date(s.published || s.discoveredAt || 0).getTime()) / 3600e3;
if (Number.isFinite(ageH)) score += Math.max(0, 100 - ageH); // 0..100, decaying
return score;
}
function shape(s) {
return {
url: s.url,
title: s.title,
summary: s.summary,
image: s.image || null,
source: s.source,
category: normalizeCategory(s.category),
desk: s.desk || null,
published: s.published,
discoveredAt: s.discoveredAt,
named: !!s.named,
fallback: !!s.fallback,
score: 0,
feedKind: s.feedKind || "native",
};
}
export async function getFeed({ limit = 60 } = {}) {
const { stories } = await getCollections();
const now = new Date();
const rows = await stories
.find({
discoveredAt: { $gte: new Date(now.getTime() - POOL_HOURS * 3600e3) },
desk: { $ne: "system" },
})
.sort({ discoveredAt: -1 })
.limit(1000)
.toArray();
const nowMs = now.getTime();
const items = rows
.map((s) => {
const it = shape(s);
it.score = rank(s, nowMs);
return it;
})
.sort((a, b) => b.score - a.score || new Date(b.published || b.discoveredAt || 0) - new Date(a.published || a.discoveredAt || 0))
.slice(0, limit);
return {
items,
generatedAt: now,
mode: "public",
ranked: true,
// masthead counts
named: rows.filter((s) => s.named).length,
total: rows.length,
};
}
// One (or a few) headline(s) per desk, straight from the pool — the front
// page's desk grid is built from this, exactly as forthena's was. Zero LLM.
export async function deskHeadlines() {
try {
const { stories } = await getCollections();
const nowMs = Date.now();
const rows = await stories
.find({
discoveredAt: { $gte: new Date(nowMs - POOL_HOURS * 3600e3) },
desk: { $ne: "system" },
})
.limit(2000)
.toArray();
const per = new Map();
const count = new Map();
for (const s of rows) {
const cat = normalizeCategory(s.category);
count.set(cat, (count.get(cat) || 0) + 1);
const it = shape(s);
it.score = rank(s, nowMs);
const arr = per.get(cat) || [];
arr.push(it);
per.set(cat, arr);
}
return CATEGORIES
.map((c) => {
const arr = (per.get(c.key) || []).sort((a, b) => b.score - a.score);
if (!arr.length) return null;
return { key: c.key, label: sectionName(c.key), blurb: c.blurb || "", count: count.get(c.key) || arr.length, stories: arr.slice(0, 12) };
})
.filter(Boolean);
} catch (e) {
console.warn("[bama] desk headlines unavailable:", String((e && e.message) || e).slice(0, 140));
return [];
}
}
// Masthead line: how much of the wire the desk has scoured lately.
export async function editionStats() {
try {
const { stories } = await getCollections();
const [total, last24] = await Promise.all([
stories.countDocuments({ discoveredAt: { $gte: new Date(Date.now() - 14 * 86400e3) } }),
stories.countDocuments({ discoveredAt: { $gte: new Date(Date.now() - 24 * 3600e3) } }),
]);
const board = await (await getCollections()).board.countDocuments({});
return { stories: total, today: last24, boardPosts: board };
} catch {
return { stories: 0, today: 0, boardPosts: 0 };
}
}