feedkeeper 0.35.3 → 0.35.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/server/dist/api/auth.js +1 -1
  3. package/server/dist/api/feeds.js +1 -1
  4. package/server/dist/api/folders.js +11 -8
  5. package/server/dist/api/items.js +3 -3
  6. package/server/dist/api/onboarding.js +2 -1
  7. package/server/dist/api/v1/resources.js +14 -19
  8. package/server/dist/api/v1/serialize.js +1 -1
  9. package/server/dist/auth/middleware.js +2 -2
  10. package/server/dist/db/migrations/0041_sync_feed_metadata.sql +16 -0
  11. package/server/dist/feeds/archive.js +3 -2
  12. package/server/dist/feeds/articleContent.js +3 -3
  13. package/server/dist/feeds/content/index.js +2 -1
  14. package/server/dist/feeds/content/noise/lazyImages.js +23 -0
  15. package/server/dist/feeds/content/noise/videoPages.js +67 -0
  16. package/server/dist/feeds/content/patterns/snippet-metadata/de.js +3 -0
  17. package/server/dist/feeds/content/patterns/snippet-metadata/en.js +3 -0
  18. package/server/dist/feeds/content/patterns/snippet-metadata/index.js +10 -0
  19. package/server/dist/feeds/content/patterns/snippet-metadata/ja.js +3 -0
  20. package/server/dist/feeds/content/patterns/video-pages/de.js +3 -0
  21. package/server/dist/feeds/content/patterns/video-pages/en.js +3 -0
  22. package/server/dist/feeds/content/patterns/video-pages/ja.js +3 -0
  23. package/server/dist/feeds/contentNoise.js +21 -5
  24. package/server/dist/feeds/extractor.js +2 -1
  25. package/server/dist/feeds/feedIcon.js +4 -2
  26. package/server/dist/feeds/imageSources.js +69 -0
  27. package/server/dist/feeds/inbox.js +4 -9
  28. package/server/dist/feeds/opml.js +122 -27
  29. package/server/dist/feeds/repository.js +19 -7
  30. package/server/dist/mcp/items.js +1 -1
  31. package/server/dist/mcp/server.js +17 -3
  32. package/server/dist/server.js +1 -1
  33. package/server/dist/sync/changeLog.js +1 -0
  34. package/server/package.json +1 -1
  35. package/web/dist/assets/index-CilgZLF5.js +32 -0
  36. package/web/dist/index.html +1 -1
  37. package/web/dist/sw.js +1 -1
  38. package/web/dist/assets/index-CYi9R_Ds.js +0 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.35.3",
3
+ "version": "0.35.5",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -44,7 +44,7 @@
44
44
  "cors": "^2.8.6",
45
45
  "dotenv": "^18.0.8",
46
46
  "express": "^5.2.1",
47
- "express-rate-limit": "^8.7.1",
47
+ "express-rate-limit": "^8.7.2",
48
48
  "fast-xml-parser": "^5.11.2",
49
49
  "helmet": "^8.3.0",
50
50
  "ipaddr.js": "^2.5.0",
@@ -52,7 +52,7 @@
52
52
  "node-cron": "^4.6.0",
53
53
  "otpauth": "^9.5.2",
54
54
  "rss-parser": "^3.13.0",
55
- "undici": "^8.12.2",
55
+ "undici": "^8.12.3",
56
56
  "web-push": "^3.6.7",
57
57
  "zod": "^4.6.5"
58
58
  }
@@ -161,7 +161,7 @@ authRouter.post("/signup", (req, res) => {
161
161
  return;
162
162
  }
163
163
  const user = createUser({ ...parsed.data, role: "user" });
164
- req.session.userId = user.id;
164
+ req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
165
165
  res.status(201).json(toPublicUser(user));
166
166
  });
167
167
  //# sourceMappingURL=auth.js.map
@@ -54,7 +54,7 @@ feedsRouter.get("/:feedId/icon", iconLimiter, async (req, res) => {
54
54
  });
55
55
  feedsRouter.get("/opml", (req, res) => {
56
56
  const opml = generateOpml(req.user.id);
57
- res.setHeader("Content-Type", "application/xml; charset=utf-8");
57
+ res.setHeader("Content-Type", "text/x-opml; charset=utf-8");
58
58
  res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
59
59
  res.send(opml);
60
60
  });
@@ -19,11 +19,7 @@ foldersRouter.post("/", requireCapability("sync"), (req, res) => {
19
19
  return;
20
20
  }
21
21
  const folder = createFolder(req.user.id, parsed.data.name);
22
- res.status(201).json({
23
- ...folder,
24
- feed_count: 0,
25
- unread_count: 0,
26
- });
22
+ res.status(201).json(listFoldersForUser(req.user.id).find((entry) => entry.id === folder.id));
27
23
  });
28
24
  foldersRouter.patch("/:folderId", (req, res) => {
29
25
  const folderId = Number(req.params.folderId);
@@ -37,15 +33,22 @@ foldersRouter.patch("/:folderId", (req, res) => {
37
33
  res.status(400).json({ error: "invalid_input", details: parsed.error.flatten() });
38
34
  return;
39
35
  }
40
- const updated = updateFolder(req.user.id, folderId, parsed.data.name);
41
- res.json(updated);
36
+ try {
37
+ const updated = updateFolder(req.user.id, folderId, parsed.data.name);
38
+ res.json(updated);
39
+ }
40
+ catch (error) {
41
+ if (!(error instanceof Error && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE"))
42
+ throw error;
43
+ res.status(409).json({ error: "folder_name_taken" });
44
+ }
42
45
  });
43
46
  foldersRouter.delete("/:folderId", (req, res) => {
44
47
  const folderId = Number(req.params.folderId);
45
48
  deleteFolder(req.user.id, folderId);
46
49
  res.json({ success: true });
47
50
  });
48
- foldersRouter.post("/:folderId/read", (req, res) => {
51
+ foldersRouter.post("/:folderId/read", requireCapability("sync"), (req, res) => {
49
52
  const folderId = Number(req.params.folderId);
50
53
  const updatedCount = markAllRead(req.user.id, { folderId });
51
54
  res.json({ read: updatedCount });
@@ -49,8 +49,8 @@ itemsRouter.get("/", (req, res) => {
49
49
  res.json(items.map(({ content_lead: _lead, lead_version: _leadVersion, ...item }) => ({
50
50
  ...item,
51
51
  content_snippet: cleanSnippet(item.content_snippet, item.title),
52
- content_html: readerHtml(item.content_html, item.title, item.author),
53
- full_content_html: readerHtml(item.full_content_html, item.title, item.author),
52
+ content_html: readerHtml(item.content_html, item.title, item.author, item.link),
53
+ full_content_html: readerHtml(item.full_content_html, item.title, item.author, item.link),
54
54
  read: Boolean(item.read),
55
55
  bookmarked: Boolean(item.bookmarked),
56
56
  })));
@@ -127,7 +127,7 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
127
127
  const [served] = withArchivedImages([{ id: result.id, link: currentItem?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
128
128
  res.json({
129
129
  id: result.id,
130
- full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author),
130
+ full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author, currentItem?.link),
131
131
  ...(result.cached ? {} : { title: result.title, byline: result.byline }),
132
132
  cached: result.cached,
133
133
  });
@@ -2,6 +2,7 @@ import { Router } from "express";
2
2
  import { z } from "zod";
3
3
  import { hasAnyUser } from "../db/index.js";
4
4
  import { createUser } from "../auth/users.js";
5
+ import { securityVersion } from "../auth/security.js";
5
6
  export const onboardingRouter = Router();
6
7
  onboardingRouter.get("/status", (_req, res) => {
7
8
  res.json({ needsOnboarding: !hasAnyUser() });
@@ -24,7 +25,7 @@ onboardingRouter.post("/", (req, res) => {
24
25
  return;
25
26
  }
26
27
  const user = createUser({ ...parsed.data, role: "admin" });
27
- req.session.userId = user.id;
28
+ req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
28
29
  res.status(201).json({ id: user.id, email: user.email, displayName: user.display_name, role: user.role });
29
30
  });
30
31
  //# sourceMappingURL=onboarding.js.map
@@ -53,7 +53,7 @@ const subscribeSchema = z.object({
53
53
  label: z.string().max(200).nullable().optional(),
54
54
  folderId: z.number().int().positive().nullable().optional(),
55
55
  });
56
- resourcesRouter.post("/subscriptions", async (req, res) => {
56
+ resourcesRouter.post("/subscriptions", requireCapability("sync"), async (req, res) => {
57
57
  const parsed = subscribeSchema.safeParse(req.body);
58
58
  if (!parsed.success)
59
59
  return invalid(res, parsed.error.flatten());
@@ -97,6 +97,9 @@ resourcesRouter.patch("/subscriptions/:id", async (req, res) => {
97
97
  if (!parsed.success)
98
98
  return invalid(res, parsed.error.flatten());
99
99
  const { url, ...settings } = parsed.data;
100
+ if (url && !hasUserCapability(req.user.id, "sync")) {
101
+ return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
102
+ }
100
103
  try {
101
104
  let targetId = id;
102
105
  if (url)
@@ -132,13 +135,13 @@ async function refresh(userId, id) {
132
135
  const result = await pollFeed(feed);
133
136
  return { subscription: subscriptionOf(userId, id), ...result };
134
137
  }
135
- resourcesRouter.post("/subscriptions/:id/refresh", async (req, res) => {
138
+ resourcesRouter.post("/subscriptions/:id/refresh", requireCapability("sync"), async (req, res) => {
136
139
  const result = await refresh(req.user.id, Number(req.params.id));
137
140
  if (!result)
138
141
  return void res.status(404).json({ error: "not_found" });
139
142
  res.json(result);
140
143
  });
141
- resourcesRouter.post("/refresh", async (req, res) => {
144
+ resourcesRouter.post("/refresh", requireCapability("sync"), async (req, res) => {
142
145
  const subscriptions = listSubscriptionsForUser(req.user.id);
143
146
  const { newItems, errors, deferred } = await pollFeeds(subscriptions);
144
147
  res.json({ refreshed: subscriptions.length, newItems, errors, deferred });
@@ -166,7 +169,7 @@ resourcesRouter.get("/subscriptions/:id/icon", imageLimiter, async (req, res) =>
166
169
  res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
167
170
  res.type(icon.mime).send(icon.buffer);
168
171
  });
169
- resourcesRouter.post("/discover", async (req, res) => {
172
+ resourcesRouter.post("/discover", requireCapability("sync"), async (req, res) => {
170
173
  const parsed = z.object({ url: urlField }).safeParse(req.body);
171
174
  if (!parsed.success)
172
175
  return invalid(res, parsed.error.flatten());
@@ -184,7 +187,7 @@ resourcesRouter.get("/opml", (req, res) => {
184
187
  res.setHeader("Content-Disposition", 'attachment; filename="feedkeeper-subscriptions.opml"');
185
188
  res.send(generateOpml(req.user.id));
186
189
  });
187
- resourcesRouter.post("/opml", async (req, res) => {
190
+ resourcesRouter.post("/opml", requireCapability("sync"), async (req, res) => {
188
191
  const xml = typeof req.body === "string" ? req.body : typeof req.body?.opml === "string" ? req.body.opml : "";
189
192
  if (!xml.trim())
190
193
  return void res.status(400).json({ error: "missing_opml_content" });
@@ -233,19 +236,9 @@ resourcesRouter.post("/folders", (req, res) => {
233
236
  if (parsed.data.iconSymbol !== undefined && !hasUserCapability(req.user.id, "sync")) {
234
237
  return void res.status(403).json(capabilityDenial(req.user.id, "sync"));
235
238
  }
236
- try {
237
- const folder = createFolder(req.user.id, parsed.data.name, parsed.data.iconSymbol);
238
- res.status(201).json({
239
- id: folder.id,
240
- name: folder.name,
241
- iconSymbol: folder.icon_symbol ?? null,
242
- subscriptionCount: 0,
243
- unreadCount: 0,
244
- });
245
- }
246
- catch {
247
- res.status(409).json({ error: "folder_name_taken" });
248
- }
239
+ const folder = createFolder(req.user.id, parsed.data.name, parsed.data.iconSymbol);
240
+ const detail = listFoldersForUser(req.user.id).find((entry) => entry.id === folder.id);
241
+ res.status(201).json(serializeFolder(detail));
249
242
  });
250
243
  resourcesRouter.patch("/folders/:id", (req, res) => {
251
244
  const id = Number(req.params.id);
@@ -260,7 +253,9 @@ resourcesRouter.patch("/folders/:id", (req, res) => {
260
253
  try {
261
254
  updateFolder(req.user.id, id, parsed.data);
262
255
  }
263
- catch {
256
+ catch (error) {
257
+ if (!(error instanceof Error && "code" in error && error.code === "SQLITE_CONSTRAINT_UNIQUE"))
258
+ throw error;
264
259
  return void res.status(409).json({ error: "folder_name_taken" });
265
260
  }
266
261
  const folder = listFoldersForUser(req.user.id).find((item) => item.id === id);
@@ -38,7 +38,7 @@ export function serializeItem(row, progress, withContent, hasNote = false) {
38
38
  imageUrl: row.image_url,
39
39
  hasFullText: Boolean(row.full_content_html),
40
40
  content: contentMetadata(row),
41
- ...(withContent ? { contentHtml: readerHtml(row.content_html, row.title, row.author), fullTextHtml: readerHtml(row.full_content_html, row.title, row.author) } : {}),
41
+ ...(withContent ? { contentHtml: readerHtml(row.content_html, row.title, row.author, row.link), fullTextHtml: readerHtml(row.full_content_html, row.title, row.author, row.link) } : {}),
42
42
  state: {
43
43
  read: Boolean(row.read),
44
44
  saved: Boolean(row.bookmarked),
@@ -19,7 +19,7 @@ export function requireSession(req, res, next) {
19
19
  }
20
20
  function bearerToken(req) {
21
21
  const header = req.headers.authorization;
22
- return header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
22
+ return header?.startsWith("Bearer ") ? header.slice("Bearer ".length).trim() : undefined;
23
23
  }
24
24
  // MCP auth: Bearer personal access token or OAuth access token (not a paired device), scopes the request to its owner.
25
25
  export function requireBearerToken(req, res, next) {
@@ -27,7 +27,7 @@ export function requireBearerToken(req, res, next) {
27
27
  }
28
28
  // Native app API: a paired device token or a hand-made token; the web UI may call it with its session.
29
29
  export function requireSessionOrDevice(req, res, next) {
30
- if (bearerToken(req))
30
+ if (req.headers.authorization !== undefined)
31
31
  authenticateBearer(["api", "device"])(req, res, next);
32
32
  else
33
33
  requireSession(req, res, next);
@@ -0,0 +1,16 @@
1
+ -- Notify subscribers when a feed's URL, metadata, poll interval, or error state is updated
2
+ DROP TRIGGER IF EXISTS trg_changes_feed_update;
3
+ CREATE TRIGGER trg_changes_feed_update
4
+ AFTER UPDATE OF url, title, site_url, poll_interval_minutes, last_error ON feeds
5
+ WHEN OLD.url IS NOT NEW.url
6
+ OR OLD.title IS NOT NEW.title
7
+ OR OLD.site_url IS NOT NEW.site_url
8
+ OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
9
+ OR OLD.last_error IS NOT NEW.last_error
10
+ BEGIN
11
+ INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
12
+ SELECT s.user_id, 'subscription', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
13
+ FROM subscriptions s
14
+ WHERE s.feed_id = NEW.id
15
+ ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
16
+ END;
@@ -12,6 +12,7 @@ import { ImageCache, ImageCacheBusyError } from "./imageCache.js";
12
12
  import { detectImageType, isVectorImageUrl } from "./imageTypes.js";
13
13
  export { detectImageType } from "./imageTypes.js";
14
14
  import { comparableImageKey } from "./heroImage.js";
15
+ import { normalizeImageSources } from "./imageSources.js";
15
16
  const MAX_IMAGES_PER_ARTICLE = 30;
16
17
  const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
17
18
  const FILE_NAME = /^\d+-[0-9a-f]{16}\.(jpg|png|webp|gif|avif)$/;
@@ -100,7 +101,7 @@ function resolveImageUrl(raw, baseUrl) {
100
101
  /** Image URLs an article shows, in order, without tracking pixels. */
101
102
  export function articleImageUrls(html, baseUrl) {
102
103
  const urls = new Set();
103
- for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
104
+ for (const [tag] of normalizeImageSources(html).matchAll(/<img\b[^>]*>/gi)) {
104
105
  if (/\swidth\s*=\s*["']?1["'\s>]/i.test(tag) && /\sheight\s*=\s*["']?1["'\s>]/i.test(tag))
105
106
  continue;
106
107
  const src = srcAttribute(tag);
@@ -112,7 +113,7 @@ export function articleImageUrls(html, baseUrl) {
112
113
  }
113
114
  /** Replace the `src` of every image for which `replacement` knows a new address. */
114
115
  function rewriteImageSources(html, baseUrl, replacement) {
115
- return html.replace(/<img\b[^>]*>/gi, (tag) => {
116
+ return normalizeImageSources(html).replace(/<img\b[^>]*>/gi, (tag) => {
116
117
  const src = srcAttribute(tag);
117
118
  const url = src && resolveImageUrl(src.value, baseUrl);
118
119
  const next = url ? replacement(url) : undefined;
@@ -21,14 +21,14 @@ const readerCache = new Map();
21
21
  * Article HTML as a reader shows it, without breadcrumbs, repeated headline and similar page chrome. Computed when
22
22
  * served and never stored, so the publisher's original stays intact and rule changes apply to old articles too.
23
23
  */
24
- export function readerHtml(html, title, author) {
24
+ export function readerHtml(html, title, author, url) {
25
25
  if (!html)
26
26
  return html ?? null;
27
- const key = createHash("sha1").update(title ?? "").update("\0").update(author ?? "").update("\0").update(html).digest("base64");
27
+ const key = createHash("sha1").update(title ?? "").update("\0").update(author ?? "").update("\0").update(url ?? "").update("\0").update(html).digest("base64");
28
28
  const cached = readerCache.get(key);
29
29
  if (cached !== undefined)
30
30
  return cached;
31
- const cleaned = cleanReaderHtml(html, title, {}, author);
31
+ const cleaned = cleanReaderHtml(html, title, {}, author, url);
32
32
  if (readerCache.size >= READER_CACHE_LIMIT)
33
33
  readerCache.delete(readerCache.keys().next().value);
34
34
  readerCache.set(key, cleaned);
@@ -1,3 +1,4 @@
1
+ import { lazyImages } from "./noise/lazyImages.js";
1
2
  import { EMBED_RULES, resolveEmbeds } from "./embeds/index.js";
2
3
  import { embedHeadings } from "./embeds/headings.js";
3
4
  import { boilerplate } from "./noise/boilerplate.js";
@@ -10,7 +11,7 @@ import { teaserLinks } from "./noise/teaserLinks.js";
10
11
  * tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
11
12
  * `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
12
13
  */
13
- export const PAGE_RULES = [...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
14
+ export const PAGE_RULES = [lazyImages, ...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate, teaserLinks];
14
15
  export { resolveEmbeds };
15
16
  /** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
16
17
  export function cleanPage(root, stats = {}) {
@@ -0,0 +1,23 @@
1
+ import { firstSrcsetSource, imageSource, usableImageSource } from "../../imageSources.js";
2
+ export const lazyImages = {
3
+ name: "lazy-images",
4
+ description: "Lazy image addresses replace missing, data placeholders or script fragments parsed as image URLs.",
5
+ apply(root, stats) {
6
+ let changed = false;
7
+ for (const image of root.querySelectorAll("img")) {
8
+ if (usableImageSource(image.getAttribute("src")))
9
+ continue;
10
+ let source = imageSource((name) => image.getAttribute(name));
11
+ for (const candidate of image.closest("picture")?.querySelectorAll("source") ?? []) {
12
+ source ??= firstSrcsetSource(candidate.getAttribute("data-lazy-srcset") ?? candidate.getAttribute("data-srcset") ?? candidate.getAttribute("srcset"));
13
+ }
14
+ if (!source)
15
+ continue;
16
+ image.setAttribute("src", source);
17
+ stats["lazy-images"] = (stats["lazy-images"] ?? 0) + 1;
18
+ changed = true;
19
+ }
20
+ return changed;
21
+ },
22
+ };
23
+ //# sourceMappingURL=lazyImages.js.map
@@ -0,0 +1,67 @@
1
+ import { flat, linkParagraph } from "../shared.js";
2
+ import de from "../patterns/video-pages/de.js";
3
+ import en from "../patterns/video-pages/en.js";
4
+ import ja from "../patterns/video-pages/ja.js";
5
+ const labels = new Set([...de, ...en, ...ja].map(value => value.toLowerCase()));
6
+ const date = /\b\d{1,2}\.\d{1,2}\.\d{4}\b|\b[A-Za-z]{3,9} \d{1,2},? \d{4}\b|\d{4}年\d{1,2}月\d{1,2}日/;
7
+ /** A video landing page with a poster and metadata, but no editorial paragraphs. */
8
+ export function cleanVideoPage(root, url, title, stats) {
9
+ if (!url)
10
+ return false;
11
+ try {
12
+ const address = new URL(url);
13
+ if (!/^https?:$/.test(address.protocol) || !/(^|\/)(videos?|mediathek|watch|clips?)(\/|$|[-.])/i.test(address.pathname))
14
+ return false;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ const mains = root.querySelectorAll("main");
20
+ if (mains.length !== 1)
21
+ return false;
22
+ const main = mains[0];
23
+ const images = main.querySelectorAll("img");
24
+ if (images.length !== 1 || main.querySelector("article, blockquote, table, a[data-media]"))
25
+ return false;
26
+ for (const link of main.querySelectorAll("a[href]")) {
27
+ if (link.closest("nav, ul, ol") || link.querySelector("img"))
28
+ continue;
29
+ // A home link is navigation; a transcript or another editorial link is content and must survive.
30
+ try {
31
+ const target = new URL(link.getAttribute("href"), url);
32
+ if (target.origin !== new URL(url).origin || target.pathname !== "/" || target.search || target.hash)
33
+ return false;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ const copy = main.cloneNode(true);
40
+ copy.querySelectorAll("nav, ul, ol, form, img, picture, a, h1").forEach(element => element.remove());
41
+ const blocks = [...copy.querySelectorAll("p")].filter(element => flat(element.textContent));
42
+ if (!blocks.some(element => labels.has(flat(element.textContent).toLowerCase())))
43
+ return false;
44
+ // All remaining words must be short metadata lines containing an explicit date.
45
+ for (const block of blocks) {
46
+ const text = flat(block.textContent);
47
+ if (!labels.has(text.toLowerCase()) && (text.length > 160 || !date.test(text)))
48
+ return false;
49
+ block.remove();
50
+ }
51
+ if (flat(copy.textContent))
52
+ return false;
53
+ const document = main.ownerDocument;
54
+ const poster = document.createElement("p");
55
+ poster.append(images[0].cloneNode(true));
56
+ const card = linkParagraph(document, url, flat(title) || "Video");
57
+ card.querySelector("a").setAttribute("data-media", "video");
58
+ // Preserve attribution/broadcast metadata; omit the repeated publication timestamp and kind label.
59
+ const credits = [...main.querySelectorAll("p")].filter(element => {
60
+ const text = flat(element.textContent);
61
+ return date.test(text) && /[,、]/.test(text) && text.length <= 160 && !element.querySelector("a, img");
62
+ }).map(element => element.cloneNode(true));
63
+ root.replaceChildren(poster, card, ...credits);
64
+ stats["video-page"] = (stats["video-page"] ?? 0) + 1;
65
+ return true;
66
+ }
67
+ //# sourceMappingURL=videoPages.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
2
+ export default [/^von\s+[^.!?\n]{2,60}\s+[I|·–—-]\s+\d{1,2}\.\d{1,2}\.\d{2,4}\s*[-–|·]\s*\d{1,2}:\d{2}$/iu];
3
+ //# sourceMappingURL=de.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
2
+ export default [/^(?:by|written by)\s+[^.!?\n]{2,60}\s+[I|·–—-]\s+(?:[A-Za-z]{3,9}\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2})\s*[-–|·]\s*\d{1,2}:\d{2}(?:\s*[ap]m)?$/iu];
3
+ //# sourceMappingURL=en.js.map
@@ -0,0 +1,10 @@
1
+ import de from "./de.js";
2
+ import en from "./en.js";
3
+ import ja from "./ja.js";
4
+ export const SNIPPET_BYLINES = [...de, ...en, ...ja];
5
+ /** File names and copyright notices are metadata only when they stand alone before the text. */
6
+ export const SNIPPET_IMAGE_METADATA = [
7
+ /^[\p{L}\p{N}_. -]{3,120}\.(?:jpe?g|png|webp|gif|avif)$/iu,
8
+ /^(?:©|\(c\)|copyright(?:\s*©)?)\s*[^.!?\n]{1,100}$/iu,
9
+ ];
10
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole bylines with an explicit timestamp, only ahead of the first editorial line. */
2
+ export default [/^(?:著者|執筆|文)[::]\s*[^。!?\n]{1,40}\s+[I|·–—-]\s*\d{4}年\d{1,2}月\d{1,2}日\s*[-–|·]\s*\d{1,2}:\d{2}$/u];
3
+ //# sourceMappingURL=ja.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole metadata labels of video landing pages, never words inside prose. */
2
+ export default ["Video", "Videobeitrag"];
3
+ //# sourceMappingURL=de.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole metadata labels of video landing pages, never words inside prose. */
2
+ export default ["Video", "Video report"];
3
+ //# sourceMappingURL=en.js.map
@@ -0,0 +1,3 @@
1
+ /** Whole metadata labels of video landing pages, never words inside prose. */
2
+ export default ["動画", "映像", "動画ニュース"];
3
+ //# sourceMappingURL=ja.js.map
@@ -1,4 +1,7 @@
1
+ import { cleanVideoPage } from "./content/noise/videoPages.js";
2
+ import { lazyImages } from "./content/noise/lazyImages.js";
1
3
  import { JSDOM } from "jsdom";
4
+ import { SNIPPET_BYLINES, SNIPPET_IMAGE_METADATA } from "./content/patterns/snippet-metadata/index.js";
2
5
  import { CONSENT_ANCHORS, CONSENT_COMPANIONS } from "./content/patterns/consent/index.js";
3
6
  import { SIDEBAR_HEADINGS } from "./content/patterns/sidebars/index.js";
4
7
  import { resolveEmbeds } from "./content/embeds/index.js";
@@ -186,7 +189,7 @@ function removeReaderNoise(root, title, stats, author) {
186
189
  let pastStart = false;
187
190
  for (const block of leafBlocks(root)) {
188
191
  const text = flat(block.textContent);
189
- if (!text)
192
+ if (!text || block.querySelector("a[data-media]"))
190
193
  continue;
191
194
  const position = leading++;
192
195
  const heading = /^H[1-3]$/.test(block.tagName);
@@ -245,7 +248,7 @@ function removeReaderNoise(root, title, stats, author) {
245
248
  }
246
249
  if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
247
250
  const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
248
- if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
251
+ if (/^(news|ニュース|nachrichten|stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
249
252
  drop(block, "dateline");
250
253
  continue;
251
254
  }
@@ -452,17 +455,19 @@ function serialize(root) {
452
455
  return holder.innerHTML;
453
456
  }
454
457
  /** The article HTML as a reader should show it. Unchanged input is returned as is. */
455
- export function cleanReaderHtml(html, title, stats = {}, author) {
458
+ export function cleanReaderHtml(html, title, stats = {}, author, url) {
456
459
  if (!html)
457
460
  return html;
458
461
  const root = JSDOM.fragment(html);
462
+ const videoPage = cleanVideoPage(root, url, title, stats);
463
+ const lazy = lazyImages.apply(root, stats);
459
464
  const images = resolveCustomImages(root, stats);
460
465
  const duplicates = collapseAdjacentImages(root, stats);
461
466
  const variants = collapseImageVariants(root, stats);
462
467
  const embeds = resolveEmbeds(root, stats);
463
468
  const noise = removeReaderNoise(root, title, stats, author);
464
469
  const typography = removeTypography(root, stats);
465
- return images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
470
+ return videoPage || lazy || images || duplicates || variants || embeds || noise || typography ? serialize(root) : html;
466
471
  }
467
472
  /**
468
473
  * Some publishers wrap a picture in their own element and let a script fill the image: `<a-img src="/imgs/…">`
@@ -780,12 +785,23 @@ export function cleanSnippet(snippet, title) {
780
785
  for (const pattern of SNIPPET_INLINE_PATTERNS)
781
786
  text = text.replace(pattern, "");
782
787
  const headline = normalized(title ?? "");
788
+ let inMetadata = true;
789
+ text = text.split("\n").filter(line => {
790
+ const value = line.trim();
791
+ if (!value)
792
+ return false;
793
+ if (inMetadata && (matchesAny(value, SNIPPET_BYLINES) || matchesAny(value, SNIPPET_IMAGE_METADATA) ||
794
+ (headline && normalized(value) === headline)))
795
+ return false;
796
+ inMetadata = false;
797
+ return true;
798
+ }).join("\n");
783
799
  const lines = text.split("\n").map(line => line.trim()).filter((line, index) => line && !SNIPPET_LINE_PATTERNS.some(pattern => pattern.test(line)) && !isSocialList(line) &&
784
800
  !(index === 0 && headline && normalized(line) === headline));
785
801
  return lines.join("\n").trim();
786
802
  }
787
803
  /** Raised whenever the rules above change what they remove, so stored leads are derived again. */
788
- export const LEAD_VERSION = 2;
804
+ export const LEAD_VERSION = 3;
789
805
  const LEAD_LENGTH = 600;
790
806
  function shorten(text, max) {
791
807
  if (text.length <= max)
@@ -1,3 +1,4 @@
1
+ import { SCRIPT_IMAGE_SOURCE } from "./imageSources.js";
1
2
  import { Readability } from "@mozilla/readability";
2
3
  import { JSDOM } from "jsdom";
3
4
  import { fetch as undiciFetch } from "undici";
@@ -44,7 +45,7 @@ function resolveLazyImages(doc) {
44
45
  img.getAttribute("data-original") ||
45
46
  img.getAttribute("data-actualsrc");
46
47
  const src = img.getAttribute("src");
47
- if (lazySrc && (!src || src.startsWith("data:") || src.length < 10)) {
48
+ if (lazySrc && (!src || src.startsWith("data:") || src.length < 10 || SCRIPT_IMAGE_SOURCE.test(src))) {
48
49
  img.setAttribute("src", lazySrc);
49
50
  }
50
51
  const lazySrcset = img.getAttribute("data-lazy-srcset") || img.getAttribute("data-srcset");
@@ -24,12 +24,14 @@ export function detectIconType(buffer) {
24
24
  const head = Buffer.from(buffer.subarray(0, 512)).toString("utf8").trimStart();
25
25
  return /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)?<svg[\s>]/i.test(head) ? "image/svg+xml" : null;
26
26
  }
27
- /** Whether the user subscribes to the feed or has saved or annotated one of its articles. */
27
+ /** Whether the user subscribes to the feed or has saved, annotated, or curated one of its articles. */
28
28
  export function canAccessFeed(userId, feedId) {
29
29
  return Boolean(db.prepare(`SELECT 1 FROM feeds f WHERE f.id = ?
30
30
  AND (EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id AND s.user_id = ?)
31
31
  OR EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = f.id AND b.user_id = ?)
32
- OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?))`).get(feedId, userId, userId, userId));
32
+ OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?)
33
+ OR EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected WHERE i.feed_id = f.id AND e.user_id = ?
34
+ AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(feedId, userId, userId, userId, userId));
33
35
  }
34
36
  /**
35
37
  * Declared icons first, then the stored one, then the well-known files of the site, and last the feed's own logo (`<image>` of an RSS channel):
@@ -0,0 +1,69 @@
1
+ /** A script accidentally parsed as a URL is never an image, even after it was made absolute. */
2
+ export const SCRIPT_IMAGE_SOURCE = /\bthis\.src\s*=/i;
3
+ export function usableImageSource(raw) {
4
+ const source = raw?.trim();
5
+ if (!source || SCRIPT_IMAGE_SOURCE.test(source))
6
+ return null;
7
+ try {
8
+ const url = new URL(source, "https://image-source.invalid/");
9
+ return url.protocol === "https:" || url.protocol === "http:" ? source : null;
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /** URLs may contain commas (notably data placeholders); descriptors end at the next comma. */
16
+ export function firstSrcsetSource(srcset) {
17
+ let rest = srcset?.trim() ?? "";
18
+ while (rest) {
19
+ const match = /^([^\s]+)\s*/.exec(rest);
20
+ const source = match[1].replace(/,+$/, "");
21
+ rest = rest.slice(match[0].length);
22
+ if (!match[1].endsWith(",")) {
23
+ const comma = rest.indexOf(",");
24
+ rest = comma < 0 ? "" : rest.slice(comma + 1).trim();
25
+ }
26
+ const usable = usableImageSource(source);
27
+ if (usable)
28
+ return usable;
29
+ }
30
+ return null;
31
+ }
32
+ export function imageSource(attribute) {
33
+ for (const name of ["src", "data-lazy-src", "data-src", "data-original", "data-actualsrc"]) {
34
+ const source = usableImageSource(attribute(name));
35
+ if (source)
36
+ return source;
37
+ }
38
+ for (const name of ["data-lazy-srcset", "data-srcset", "srcset"]) {
39
+ const source = firstSrcsetSource(attribute(name));
40
+ if (source)
41
+ return source;
42
+ }
43
+ return null;
44
+ }
45
+ function attribute(tag, name) {
46
+ const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
47
+ return match ? match[1] ?? match[2] ?? match[3] : null;
48
+ }
49
+ /** Keep one fallback image before archive/proxy rewriting, including raw HTML stored by older versions. */
50
+ export function normalizeImageSources(html) {
51
+ const normalize = (tag, fallback = null) => {
52
+ const source = imageSource((name) => attribute(tag, name)) ?? fallback;
53
+ if (!source)
54
+ return tag;
55
+ const escaped = source.replace(/"/g, "&quot;");
56
+ const withoutSources = tag.replace(/\s+(?:(?:data-(?:lazy-)?)?src(?:set)?|sizes|data-sizes|data-original|data-actualsrc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+)/gi, "");
57
+ return withoutSources.replace(/\s*\/?>$/, (end) => ` src="${escaped}"${end}`);
58
+ };
59
+ const pictures = html.replace(/<picture\b[^>]*>[\s\S]*?<\/picture\s*>/gi, (picture) => {
60
+ let fallback = null;
61
+ const withoutSources = picture.replace(/<source\b[^>]*>/gi, (tag) => {
62
+ fallback ??= firstSrcsetSource(attribute(tag, "data-lazy-srcset") ?? attribute(tag, "data-srcset") ?? attribute(tag, "srcset"));
63
+ return "";
64
+ });
65
+ return withoutSources.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag, fallback));
66
+ });
67
+ return pictures.replace(/<img\b[^>]*>/gi, (tag) => normalize(tag));
68
+ }
69
+ //# sourceMappingURL=imageSources.js.map