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.
127 lines
4.0 KiB
JavaScript
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 };
|
|
}
|
|
}
|