[object Object]

← 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

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(/&amp;/g, "&")
+    .replace(/&#39;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&lt;/g, "<")
+    .replace(/&gt;/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 →