Add digest RSS feed endpoint for blog consumers
This commit is contained in:
parent
e40bf61b04
commit
914bbecffb
@ -41,6 +41,11 @@ Set these in `.env.local`:
|
|||||||
- `POST /api/digest` requires `x-api-key: <CRON_API_KEY>`
|
- `POST /api/digest` requires `x-api-key: <CRON_API_KEY>`
|
||||||
- Used for cron-based digest publishing
|
- Used for cron-based digest publishing
|
||||||
|
|
||||||
|
## RSS feeds
|
||||||
|
|
||||||
|
- `GET /api/rss` - Standard digest RSS feed for blog/article consumers
|
||||||
|
- `GET /api/podcast/rss` - Podcast RSS feed (audio episodes)
|
||||||
|
|
||||||
## MP3 Audio Hosting
|
## MP3 Audio Hosting
|
||||||
|
|
||||||
Upload and host MP3 files for individual blog posts.
|
Upload and host MP3 files for individual blog posts.
|
||||||
|
|||||||
143
src/app/api/rss/route.ts
Normal file
143
src/app/api/rss/route.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
import { extractExcerpt, extractTitle } from "@/lib/podcast";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
interface DigestRow {
|
||||||
|
id: string;
|
||||||
|
date: string | null;
|
||||||
|
content: string;
|
||||||
|
timestamp: number | string;
|
||||||
|
tags: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(input: string): string {
|
||||||
|
return input
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cdataSafe(input: string): string {
|
||||||
|
return input.replaceAll("]]>", "]]]]><![CDATA[>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRfc2822(value: string | number | null | undefined): string {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return new Date(value).toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isFinite(numeric)) {
|
||||||
|
return new Date(numeric).toUTCString();
|
||||||
|
}
|
||||||
|
return new Date(value).toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date().toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildErrorFeed(baseUrl: string): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>OpenClaw Daily Digest</title>
|
||||||
|
<link>${escapeXml(baseUrl)}</link>
|
||||||
|
<description>Error loading digest RSS feed. Please try again later.</description>
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const baseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
||||||
|
`${url.protocol}//${url.host}`;
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseKey =
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
return new NextResponse(buildErrorFeed(baseUrl), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("blog_messages")
|
||||||
|
.select("id, date, content, timestamp, tags")
|
||||||
|
.order("timestamp", { ascending: false })
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (data || []) as DigestRow[];
|
||||||
|
|
||||||
|
const itemsXml = rows
|
||||||
|
.map((row) => {
|
||||||
|
const title = escapeXml(extractTitle(row.content || "Daily Digest"));
|
||||||
|
const excerpt = escapeXml(extractExcerpt(row.content || "", 420));
|
||||||
|
const pubDate = asRfc2822(row.timestamp || row.date);
|
||||||
|
const postUrl = `${baseUrl}/?post=${row.id}`;
|
||||||
|
const guid = `openclaw-digest-${row.id}`;
|
||||||
|
const categories = (row.tags || [])
|
||||||
|
.map((tag) => `<category>${escapeXml(tag)}</category>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<item>
|
||||||
|
<title>${title}</title>
|
||||||
|
<link>${escapeXml(postUrl)}</link>
|
||||||
|
<guid isPermaLink="false">${escapeXml(guid)}</guid>
|
||||||
|
<pubDate>${pubDate}</pubDate>
|
||||||
|
<description>${excerpt}</description>
|
||||||
|
<content:encoded><![CDATA[${cdataSafe(row.content || "")}]]></content:encoded>
|
||||||
|
${categories}
|
||||||
|
</item>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const lastBuildDate =
|
||||||
|
rows.length > 0 ? asRfc2822(rows[0].timestamp || rows[0].date) : new Date().toUTCString();
|
||||||
|
|
||||||
|
const feedXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||||
|
<channel>
|
||||||
|
<title>OpenClaw Daily Digest</title>
|
||||||
|
<link>${escapeXml(baseUrl)}</link>
|
||||||
|
<description>Daily digest posts from OpenClaw Blog Backup.</description>
|
||||||
|
<language>en-us</language>
|
||||||
|
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||||
|
<atom:link href="${escapeXml(`${baseUrl}/api/rss`)}" rel="self" type="application/rss+xml"/>
|
||||||
|
${itemsXml}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return new NextResponse(feedXml, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating digest RSS feed:", error);
|
||||||
|
return new NextResponse(buildErrorFeed(baseUrl), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user