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
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user