Files
jeremy e9c7e9df1a feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:27:22 -06:00

127 lines
4.2 KiB
TypeScript

type FeedSource = {
name: string;
url: string;
};
type FeedItem = {
source: string;
title: string;
url: string;
publishedAt: string | null;
};
const feeds: FeedSource[] = [
{ name: "OPENAI", url: "https://openai.com/news/rss.xml" },
{ name: "GOOGLE AI", url: "https://blog.google/technology/ai/rss/" },
{ name: "DEEPMIND", url: "https://deepmind.google/blog/rss.xml" },
{ name: "NVIDIA", url: "https://nvidianews.nvidia.com/cats/generative_al.xml" },
{ name: "ARS TECHNICA", url: "https://feeds.arstechnica.com/arstechnica/technology-lab" },
{ name: "WIRED", url: "https://www.wired.com/feed/tag/ai/latest/rss" },
];
const maxFeedBytes = 1024 * 1024;
export const dynamic = "force-dynamic";
function decodeEntities(value: string) {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/\s+/g, " ")
.trim();
}
function extract(block: string, names: string[]) {
for (const name of names) {
const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "i"));
if (match?.[1]) return decodeEntities(match[1]);
}
return "";
}
function extractLink(block: string) {
const atomLink = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1];
if (atomLink) return decodeEntities(atomLink);
return extract(block, ["link", "guid"]);
}
function parseFeed(xml: string, source: string): FeedItem[] {
const blocks = xml.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi)
?? xml.match(/<entry(?:\s[^>]*)?>[\s\S]*?<\/entry>/gi)
?? [];
return blocks.slice(0, 6).flatMap((block) => {
const title = extract(block, ["title"]);
const url = extractLink(block);
const rawDate = extract(block, ["pubDate", "published", "updated", "dc:date"]);
const parsedDate = rawDate ? new Date(rawDate) : null;
if (!title || !url) return [];
return [{
source,
title: title.slice(0, 180),
url,
publishedAt: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
}];
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Headline feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Headline feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
async function readFeed(feed: FeedSource) {
const response = await fetch(feed.url, {
headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" },
signal: AbortSignal.timeout(6_000),
});
if (!response.ok) throw new Error(`${feed.name} returned ${response.status}`);
return parseFeed(await readLimitedText(response), feed.name);
}
export async function GET() {
const results = await Promise.allSettled(feeds.map(readFeed));
const items = results
.flatMap((result) => result.status === "fulfilled" ? result.value : [])
.filter((item, index, all) => all.findIndex((candidate) => candidate.url === item.url || candidate.title === item.title) === index)
.sort((a, b) => (Date.parse(b.publishedAt ?? "") || 0) - (Date.parse(a.publishedAt ?? "") || 0))
.slice(0, 10);
return Response.json(
{
items,
updatedAt: new Date().toISOString(),
feedsAvailable: results.filter((result) => result.status === "fulfilled").length,
feedsTotal: feeds.length,
},
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=900",
},
},
);
}