← back to Nineoh Guide
news: index 760 90210 articles (Google News RSS, original+reboot) as headline+source+date+link-out only (no bodies); paginated /news, noopener outbound
8dc29d73dd5f7f077b9d992598f65df49b471b03 · 2026-07-25 11:33:37 -0700 · Steve
Files touched
M apps/web/app/news/page.tsxA db/ingest-news.mjs
Diff
commit 8dc29d73dd5f7f077b9d992598f65df49b471b03
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jul 25 11:33:37 2026 -0700
news: index 760 90210 articles (Google News RSS, original+reboot) as headline+source+date+link-out only (no bodies); paginated /news, noopener outbound
---
apps/web/app/news/page.tsx | 89 ++++++++++++++++++++++++++++-----------
db/ingest-news.mjs | 103 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 168 insertions(+), 24 deletions(-)
diff --git a/apps/web/app/news/page.tsx b/apps/web/app/news/page.tsx
index 4a6bf0c..3f2ddfc 100644
--- a/apps/web/app/news/page.tsx
+++ b/apps/web/app/news/page.tsx
@@ -3,47 +3,88 @@ import { pool } from "@/lib/db";
export const dynamic = "force-dynamic";
export const metadata = { title: "News" };
-async function getNews() {
+const PER_PAGE = 60;
+
+async function getNews(offset: number) {
try {
+ const total = await pool.query(`select count(*)::int as n from news_items`);
const { rows } = await pool.query(
`select id, headline, summary_original, canonical_url, publisher_name, published_at
- from news_items order by published_at desc nulls last limit 100`
+ from news_items
+ order by published_at desc nulls last
+ limit $1 offset $2`,
+ [PER_PAGE, offset]
);
- return rows;
+ return { rows, total: total.rows[0]?.n ?? 0 };
} catch {
- return [];
+ return { rows: [], total: 0 };
}
}
-export default async function News() {
- const news = await getNews();
+export default async function News({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string }>;
+}) {
+ const { page } = await searchParams;
+ const p = Math.max(1, Number(page) || 1);
+ const { rows: news, total } = await getNews((p - 1) * PER_PAGE);
+ const pages = Math.ceil(total / PER_PAGE);
+
return (
<section>
- <h1>News</h1>
- <p style={{ color: "#666", fontSize: 13 }}>
- Short, attributed summaries that link out to the original source.
+ <h1>90210 in the News</h1>
+ <p style={{ color: "var(--muted)", fontSize: 13 }}>
+ A running index of {total.toLocaleString()} articles about 90210 — original
+ and reboot. Each headline links to the original publisher; we don't
+ reproduce article text.
</p>
+
{news.length === 0 ? (
- <p style={{ color: "#8a1c1c" }}>No news items yet.</p>
+ <p style={{ color: "var(--maroon)" }}>No news items yet.</p>
) : (
- <ul style={{ listStyle: "none", padding: 0 }}>
+ <div style={{ display: "grid", gap: 10, marginTop: 16 }}>
{news.map((n) => (
- <li key={n.id} style={{ padding: "10px 0", borderBottom: "1px solid #eee" }}>
- <a href={n.canonical_url} target="_blank" rel="noopener noreferrer">
- <strong>{n.headline}</strong>
- </a>
- <p style={{ margin: "4px 0", color: "#555", fontSize: 13 }}>
- {n.summary_original}
- </p>
- <span style={{ fontSize: 12, color: "#999" }}>
- {n.publisher_name}
+ <a
+ key={n.id}
+ className="card"
+ href={n.canonical_url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ display: "block", color: "inherit" }}
+ >
+ <strong style={{ fontFamily: "var(--font-display)", fontSize: 16, lineHeight: 1.3 }}>
+ {n.headline}
+ </strong>
+ {n.summary_original ? (
+ <div style={{ color: "var(--ink-soft)", fontSize: 13, marginTop: 4 }}>
+ {n.summary_original}
+ </div>
+ ) : null}
+ <div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
+ {n.publisher_name ?? "Source"}
{n.published_at
- ? ` · ${new Date(n.published_at).toLocaleDateString()}`
+ ? ` · ${new Date(n.published_at).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ })}`
: ""}
- </span>
- </li>
+ {" "}↗
+ </div>
+ </a>
))}
- </ul>
+ </div>
+ )}
+
+ {pages > 1 && (
+ <div style={{ display: "flex", gap: 12, marginTop: 20, alignItems: "center" }}>
+ {p > 1 ? <a href={`/news?page=${p - 1}`}>← Newer</a> : <span />}
+ <span style={{ fontSize: 13, color: "var(--muted)" }}>
+ Page {p} of {pages}
+ </span>
+ {p < pages ? <a href={`/news?page=${p + 1}`}>Older →</a> : <span />}
+ </div>
)}
</section>
);
diff --git a/db/ingest-news.mjs b/db/ingest-news.mjs
new file mode 100644
index 0000000..b0367ca
--- /dev/null
+++ b/db/ingest-news.mjs
@@ -0,0 +1,103 @@
+// Aggregates 90210 news as an INDEX of links — headline + source + date +
+// canonical URL only. NO article bodies are stored or reproduced (copyright);
+// each item links out to the publisher. Source = free Google News RSS (no key).
+// "All news" is honestly bounded by what the free index surfaces per query.
+// Run: node db/ingest-news.mjs
+import pg from "pg";
+
+const DATABASE_URL =
+ process.env.DATABASE_URL ?? "postgresql://localhost/nineoh_guide?host=/tmp";
+const pool = new pg.Pool({ connectionString: DATABASE_URL });
+
+const show = (await pool.query(`select id from shows where canonical_title='90210'`)).rows[0];
+const showId = show?.id ?? null;
+
+// On-topic queries spanning the original series + the 2008 reboot + cast.
+const QUERIES = [
+ '"Beverly Hills, 90210"',
+ '"90210" CW reboot',
+ '"90210" reunion',
+ '"90210" cast',
+ '"AnnaLynne McCord" 90210',
+ '"Shenae Grimes" 90210',
+ '"Jennie Garth" 90210',
+ '"Tori Spelling" 90210',
+ '"Jason Priestley" 90210',
+ '"90210" reboot news',
+];
+
+function decode(s) {
+ return (s || "")
+ .replace(/<!\[CDATA\[(.*?)\]\]>/gs, "$1")
+ .replace(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .trim();
+}
+
+function parseItems(xml) {
+ const items = [];
+ const re = /<item>(.*?)<\/item>/gs;
+ let m;
+ while ((m = re.exec(xml))) {
+ const block = m[1];
+ const title = decode((block.match(/<title>(.*?)<\/title>/s) || [])[1]);
+ const link = decode((block.match(/<link>(.*?)<\/link>/s) || [])[1]);
+ const pub = decode((block.match(/<pubDate>(.*?)<\/pubDate>/s) || [])[1]);
+ const source = decode((block.match(/<source[^>]*>(.*?)<\/source>/s) || [])[1]);
+ if (!title || !link) continue;
+ // Google News titles are "Headline - Publisher" — split off the publisher.
+ let headline = title;
+ let publisher = source;
+ const dash = title.lastIndexOf(" - ");
+ if (dash > 20 && !source) {
+ headline = title.slice(0, dash);
+ publisher = title.slice(dash + 3);
+ }
+ items.push({ headline, link, pub, publisher });
+ }
+ return items;
+}
+
+let seen = 0,
+ inserted = 0;
+for (const q of QUERIES) {
+ const url = `https://news.google.com/rss/search?q=${encodeURIComponent(q)}&hl=en-US&gl=US&ceid=US:en`;
+ let xml;
+ try {
+ const r = await fetch(url, { headers: { "User-Agent": "nineoh-guide/0.1 (fan project)" } });
+ xml = await r.text();
+ } catch (e) {
+ console.warn(`query failed: ${q} — ${e.message}`);
+ continue;
+ }
+ const items = parseItems(xml);
+ for (const it of items) {
+ seen++;
+ const when = it.pub ? new Date(it.pub) : null;
+ const res = await pool.query(
+ `insert into news_items (headline, canonical_url, publisher_name, published_at,
+ topic_tags, show_ids, snippet_status, source_query)
+ values ($1,$2,$3,$4,$5,$6,'indexed',$7)
+ on conflict (canonical_url) do nothing`,
+ [
+ it.headline,
+ it.link,
+ it.publisher || null,
+ when && !isNaN(when) ? when.toISOString() : null,
+ ["90210"],
+ showId ? [showId] : [],
+ q,
+ ]
+ );
+ inserted += res.rowCount;
+ }
+ console.log(`"${q}" → ${items.length} items`);
+}
+
+const total = await pool.query(`select count(*)::int n from news_items`);
+console.log(`\nseen ${seen}, newly inserted ${inserted}, total in index: ${total.rows[0].n}`);
+console.log("Headlines + source + link only — no article bodies stored (copyright).");
+await pool.end();
← e86cb34 professional redesign: original luxe design system (globals.
·
back to Nineoh Guide
·
auto-save: 2026-07-25T11:35:57 (1 files) — db/ingest-wikimed 8af5353 →