feat: reader-compat batch — JSON Feed, OPML export, conditional GET, dedup

Batch of four reader-facing improvements (TODO "Compat lecteurs + dedup"):

- JSON Feed at /json/:feedId (feed lib .json1()); all formats cross-link
- OPML export at /admin/opml (admin-protected; the registry lists every
  feed URL, so it must not be public)
- Conditional GET on /rss + /atom: strong ETag + Last-Modified, 304 on
  If-None-Match/If-Modified-Since, validators shared via http-cache.ts
- Duplicate-send dedup in ingestion: match by Message-ID, fall back to a
  SHA-256 of normalized subject+content; a duplicate is a no-op and bumps
  the new emails_deduplicated counter (status page + /api/v1/stats)

429 tests green, tsc clean, build dry-run OK. Docs (README/CLAUDE/TODO +
landing cards) updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-24 20:47:54 +02:00
parent 334713fbd9
commit 0abd5f306c
23 changed files with 1015 additions and 11 deletions
+18
View File
@@ -5,6 +5,11 @@ import { fetchFeedData } from "../application/feed-fetcher";
import { baseUrl, feedRssUrl } from "../infrastructure/urls";
import { isExpired } from "../domain/feed";
import { FeedId } from "../domain/value-objects/feed-id";
import {
computeFeedValidators,
isNotModified,
notModifiedResponse,
} from "../infrastructure/http-cache";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
try {
@@ -21,6 +26,17 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
return new Response("Feed has expired", { status: 410 });
}
const validators = computeFeedValidators(
"rss",
feedId,
feedData.feedConfig,
feedData.emails,
);
if (isNotModified(c.req.raw, validators)) {
return notModifiedResponse(validators);
}
const base = baseUrl(c.env);
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
const rssXml = generateRssFeed(
@@ -42,6 +58,8 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
"Cache-Control": "max-age=1800",
"X-Robots-Tag": "noindex",
Link: linkHeader,
ETag: validators.etag,
"Last-Modified": validators.lastModified,
},
});
} catch (error) {