e9c7e9df1a
- 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
127 lines
4.2 KiB
TypeScript
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(/&/g, "&")
|
|
.replace(/"/g, '"')
|
|
.replace(/'|'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/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",
|
|
},
|
|
},
|
|
);
|
|
}
|