feedkeeper 0.0.0-stage → 0.23.0

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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +162 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/app.js +100 -0
  20. package/server/dist/auth/avatar.js +13 -0
  21. package/server/dist/auth/capabilities.js +53 -0
  22. package/server/dist/auth/middleware.js +77 -0
  23. package/server/dist/auth/pairing.js +36 -0
  24. package/server/dist/auth/password.js +9 -0
  25. package/server/dist/auth/plan.js +58 -0
  26. package/server/dist/auth/security.js +117 -0
  27. package/server/dist/auth/securityRoutes.js +249 -0
  28. package/server/dist/auth/tokens.js +64 -0
  29. package/server/dist/auth/users.js +54 -0
  30. package/server/dist/backup-cli.js +101 -0
  31. package/server/dist/cli.js +135 -0
  32. package/server/dist/clientAddress.js +26 -0
  33. package/server/dist/config.js +51 -0
  34. package/server/dist/db/index.js +40 -0
  35. package/server/dist/db/migrations/0001_init.sql +70 -0
  36. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  37. package/server/dist/db/migrations/0003_retention.sql +13 -0
  38. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  39. package/server/dist/db/migrations/0005_folders.sql +15 -0
  40. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  41. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  42. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  43. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  44. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  45. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  46. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  47. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  48. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  49. package/server/dist/db/migrations/0015_push.sql +13 -0
  50. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  51. package/server/dist/db/migrations/0017_devices.sql +13 -0
  52. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  53. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  54. package/server/dist/db/migrations/0020_fts.sql +32 -0
  55. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  56. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  57. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  58. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  59. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  60. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  61. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  62. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  63. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  64. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  65. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  66. package/server/dist/feedkeeper.js +38 -0
  67. package/server/dist/feeds/archive.js +334 -0
  68. package/server/dist/feeds/articleContent.js +26 -0
  69. package/server/dist/feeds/cleanup.js +179 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +222 -0
  85. package/server/dist/feeds/repository.js +674 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/index.js +15 -0
  91. package/server/dist/mcp/digest.js +73 -0
  92. package/server/dist/mcp/http.js +46 -0
  93. package/server/dist/mcp/items.js +73 -0
  94. package/server/dist/mcp/promptsAndResources.js +43 -0
  95. package/server/dist/mcp/server.js +547 -0
  96. package/server/dist/native/editionSelection.js +127 -0
  97. package/server/dist/native/editionText.js +15 -0
  98. package/server/dist/native/editions.js +218 -0
  99. package/server/dist/native/preferences.js +66 -0
  100. package/server/dist/native/requests.js +35 -0
  101. package/server/dist/oauth/constants.js +6 -0
  102. package/server/dist/oauth/host.js +8 -0
  103. package/server/dist/oauth/pages.js +148 -0
  104. package/server/dist/oauth/routes.js +361 -0
  105. package/server/dist/oauth/store.js +104 -0
  106. package/server/dist/push.js +147 -0
  107. package/server/dist/pushOutbox.js +164 -0
  108. package/server/dist/security-recover-cli.js +29 -0
  109. package/server/dist/server.js +108 -0
  110. package/server/dist/setup-cli.js +97 -0
  111. package/server/dist/sync/changeLog.js +170 -0
  112. package/server/dist/sync/mutations.js +144 -0
  113. package/server/dist/trustProxy.js +14 -0
  114. package/server/dist/version.js +6 -0
  115. package/server/package.json +6 -0
  116. package/web/dist/app-icon-180.png +0 -0
  117. package/web/dist/app-icon-192.png +0 -0
  118. package/web/dist/app-icon-512.png +0 -0
  119. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  120. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  121. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  122. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  124. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  126. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  128. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  130. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  131. package/web/dist/favicon-32.png +0 -0
  132. package/web/dist/index.html +40 -0
  133. package/web/dist/logo.svg +50 -0
  134. package/web/dist/manifest.webmanifest +64 -0
  135. package/web/dist/robots.txt +3 -0
  136. package/web/dist/sw.js +198 -0
  137. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,26 @@
1
+ import { JSDOM } from "jsdom";
2
+ import { decodeEntities } from "./text.js";
3
+ /** Plain publisher content, shared by readers, search consumers and MCP. */
4
+ export function articleText(html) {
5
+ if (!html)
6
+ return "";
7
+ const fragment = JSDOM.fragment(html);
8
+ fragment.querySelectorAll("script, style, noscript, nav, footer").forEach(node => node.remove());
9
+ fragment.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
10
+ return (fragment.textContent ?? "").replace(/\s+/g, " ").trim();
11
+ }
12
+ export function contentMetadata(item) {
13
+ const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
14
+ return { revision: item.content_revision, source, status: item.extraction_status,
15
+ attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
16
+ }
17
+ export function sourceText(item) {
18
+ return `${decodeEntities(item.title) ?? ""}\n${articleText(item.full_content_html || item.content_html) || decodeEntities(item.content_snippet) || ""}`.trim();
19
+ }
20
+ /** Prefer existing substantive publisher text over a shorter extracted teaser. */
21
+ export function improvesContent(item, extracted) {
22
+ const candidate = articleText(extracted).length;
23
+ const existing = articleText(item.full_content_html || item.content_html).length;
24
+ return candidate >= 200 && candidate >= existing * 0.9;
25
+ }
26
+ //# sourceMappingURL=articleContent.js.map
@@ -0,0 +1,179 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import cron from "node-cron";
3
+ import { db } from "../db/index.js";
4
+ import { config } from "../config.js";
5
+ import { removeUnusedFeeds } from "./repository.js";
6
+ import { pruneArchive } from "./archive.js";
7
+ import { pruneChangeLog } from "../sync/changeLog.js";
8
+ export function getDatabaseFileSizeBytes() {
9
+ let size = 0;
10
+ for (const path of [config.databasePath, `${config.databasePath}-wal`, `${config.databasePath}-shm`]) {
11
+ if (existsSync(path)) {
12
+ try {
13
+ size += statSync(path).size;
14
+ }
15
+ catch {
16
+ // ignore if concurrently removed
17
+ }
18
+ }
19
+ }
20
+ return size;
21
+ }
22
+ export function getDatabaseStats() {
23
+ const totalItemsRow = db.prepare("SELECT COUNT(*) AS count FROM items").get();
24
+ const readItemsRow = db.prepare("SELECT COUNT(DISTINCT item_id) AS count FROM item_reads").get();
25
+ const feedsRow = db.prepare("SELECT COUNT(*) AS count FROM feeds").get();
26
+ const oldestRow = db.prepare("SELECT MIN(COALESCE(published_at, created_at)) AS oldest FROM items").get();
27
+ return {
28
+ totalItems: totalItemsRow.count,
29
+ readItems: readItemsRow.count,
30
+ feedsCount: feedsRow.count,
31
+ oldestItemDate: oldestRow.oldest,
32
+ databaseSizeBytes: getDatabaseFileSizeBytes(),
33
+ };
34
+ }
35
+ export function getRetentionSettings() {
36
+ try {
37
+ const rows = db.prepare("SELECT key, value FROM system_settings").all();
38
+ const map = new Map(rows.map((r) => [r.key, r.value]));
39
+ return {
40
+ retentionReadDays: Number(map.get("retention_read_days") ?? config.retentionReadDays),
41
+ retentionMaxDays: Number(map.get("retention_max_days") ?? config.retentionMaxDays),
42
+ retentionMaxItemsPerFeed: Number(map.get("retention_max_items_per_feed") ?? config.retentionMaxItemsPerFeed),
43
+ autoCleanupEnabled: (map.get("auto_cleanup_enabled") ?? String(config.autoCleanupEnabled)) === "true",
44
+ };
45
+ }
46
+ catch {
47
+ return {
48
+ retentionReadDays: config.retentionReadDays,
49
+ retentionMaxDays: config.retentionMaxDays,
50
+ retentionMaxItemsPerFeed: config.retentionMaxItemsPerFeed,
51
+ autoCleanupEnabled: config.autoCleanupEnabled,
52
+ };
53
+ }
54
+ }
55
+ export function updateRetentionSettings(settings) {
56
+ const updateStmt = db.prepare(`INSERT INTO system_settings (key, value, updated_at)
57
+ VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
58
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`);
59
+ const apply = db.transaction(() => {
60
+ if (settings.retentionReadDays !== undefined) {
61
+ updateStmt.run("retention_read_days", String(Math.max(0, settings.retentionReadDays)));
62
+ }
63
+ if (settings.retentionMaxDays !== undefined) {
64
+ updateStmt.run("retention_max_days", String(Math.max(0, settings.retentionMaxDays)));
65
+ }
66
+ if (settings.retentionMaxItemsPerFeed !== undefined) {
67
+ updateStmt.run("retention_max_items_per_feed", String(Math.max(0, settings.retentionMaxItemsPerFeed)));
68
+ }
69
+ if (settings.autoCleanupEnabled !== undefined) {
70
+ updateStmt.run("auto_cleanup_enabled", String(settings.autoCleanupEnabled));
71
+ }
72
+ });
73
+ apply();
74
+ return getRetentionSettings();
75
+ }
76
+ export function runCleanup(custom) {
77
+ const settings = {
78
+ ...getRetentionSettings(),
79
+ ...custom,
80
+ };
81
+ const sizeBefore = getDatabaseFileSizeBytes();
82
+ let deletedReadItems = 0;
83
+ let deletedOldItems = 0;
84
+ let deletedPerFeedExcess = 0;
85
+ // 1. Delete read items older than retentionReadDays (excluding bookmarked items and items with notes)
86
+ if (settings.retentionReadDays > 0) {
87
+ const res = db.prepare(`DELETE FROM items
88
+ WHERE id IN (
89
+ SELECT i.id
90
+ FROM items i
91
+ WHERE i.id NOT IN (SELECT item_id FROM item_bookmarks)
92
+ AND i.id NOT IN (SELECT item_id FROM item_notes)
93
+ AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
94
+ AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
95
+ WHERE selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
96
+ AND i.id IN (SELECT item_id FROM item_reads)
97
+ AND NOT EXISTS (
98
+ SELECT 1 FROM subscriptions s
99
+ WHERE s.feed_id = i.feed_id
100
+ AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = s.user_id)
101
+ )
102
+ AND COALESCE(i.published_at, i.created_at) <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' days')
103
+ )`).run(settings.retentionReadDays);
104
+ deletedReadItems = res.changes;
105
+ }
106
+ // 2. Delete all items older than retentionMaxDays (excluding bookmarked items and items with notes)
107
+ if (settings.retentionMaxDays > 0) {
108
+ const res = db.prepare(`DELETE FROM items
109
+ WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
110
+ AND id NOT IN (SELECT item_id FROM item_notes)
111
+ AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
112
+ AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
+ WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
114
+ AND COALESCE(published_at, created_at) <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
115
+ deletedOldItems = res.changes;
116
+ }
117
+ // 3. Delete items exceeding retentionMaxItemsPerFeed (excluding bookmarked items and items with notes)
118
+ if (settings.retentionMaxItemsPerFeed > 0) {
119
+ const res = db.prepare(`DELETE FROM items
120
+ WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
121
+ AND id NOT IN (SELECT item_id FROM item_notes)
122
+ AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
123
+ AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
124
+ WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
125
+ AND id IN (
126
+ SELECT id FROM (
127
+ SELECT id, ROW_NUMBER() OVER (
128
+ PARTITION BY feed_id
129
+ ORDER BY COALESCE(published_at, created_at) DESC, id DESC
130
+ ) as rn
131
+ FROM items
132
+ )
133
+ WHERE rn > ?
134
+ )`).run(settings.retentionMaxItemsPerFeed);
135
+ deletedPerFeedExcess = res.changes;
136
+ }
137
+ // Feeds kept only for saved articles go once nothing of them is saved anymore.
138
+ removeUnusedFeeds();
139
+ pruneArchive();
140
+ pruneChangeLog();
141
+ const totalDeleted = deletedReadItems + deletedOldItems + deletedPerFeedExcess;
142
+ // Optimize query planning; rebuild storage only when this run freed rows.
143
+ db.exec("PRAGMA optimize;");
144
+ if (totalDeleted > 0) {
145
+ try {
146
+ db.exec("VACUUM;");
147
+ }
148
+ catch (err) {
149
+ console.warn("[cleanup] VACUUM skipped or could not run:", err);
150
+ }
151
+ }
152
+ const sizeAfter = getDatabaseFileSizeBytes();
153
+ return {
154
+ deletedReadItems,
155
+ deletedOldItems,
156
+ deletedPerFeedExcess,
157
+ totalDeleted,
158
+ sizeBefore,
159
+ sizeAfter,
160
+ };
161
+ }
162
+ export function startCleanupScheduler() {
163
+ // Run daily at 03:00 AM server time
164
+ cron.schedule("0 3 * * *", () => {
165
+ const settings = getRetentionSettings();
166
+ if (!settings.autoCleanupEnabled) {
167
+ return;
168
+ }
169
+ try {
170
+ console.log("[cleanup] running daily automated housekeeping...");
171
+ const result = runCleanup(settings);
172
+ console.log(`[cleanup] automated housekeeping complete: ${result.totalDeleted} items deleted (read: ${result.deletedReadItems}, max age: ${result.deletedOldItems}, feed cap: ${result.deletedPerFeedExcess}). Database size: ${(result.sizeBefore / 1024 / 1024).toFixed(2)} MB -> ${(result.sizeAfter / 1024 / 1024).toFixed(2)} MB`);
173
+ }
174
+ catch (err) {
175
+ console.error("[cleanup] error during automated housekeeping:", err);
176
+ }
177
+ });
178
+ }
179
+ //# sourceMappingURL=cleanup.js.map
@@ -0,0 +1,153 @@
1
+ import Parser from "rss-parser";
2
+ import { fetchFeed } from "./fetcher.js";
3
+ import { assertPublicHttpUrl } from "./ssrfGuard.js";
4
+ import { decodeEntities, plainTitle } from "./text.js";
5
+ const rssParser = new Parser();
6
+ // Common alternate feed types found in HTML <head>
7
+ const FEED_MIME_TYPES = [
8
+ "application/rss+xml",
9
+ "application/atom+xml",
10
+ "text/xml",
11
+ "application/xml",
12
+ ];
13
+ const MAX_PROBES = 15;
14
+ const COMMON_PATHS = ["/feed", "/rss.xml", "/atom.xml", "/feed.xml", "/index.xml", "/rss", "/rss.php?feed=RSS2.0", "/rss.php?feed=ATOM1.0"];
15
+ // Feed-looking URLs anywhere in a page, including JSON embedded in attributes.
16
+ const FEED_URL_IN_TEXT = /https?:\/\/[^\s"'<>\\]+?(?:\.rss|\.atom|\/feed\/?|\/(?:rss|atom|feed)\.xml)(?=["'\s<>\\]|$)/gi;
17
+ function sameUrl(a, b) {
18
+ const normalize = (value) => {
19
+ try {
20
+ const url = new URL(value);
21
+ url.hash = "";
22
+ return url.toString().replace(/\/$/, "");
23
+ }
24
+ catch {
25
+ return value;
26
+ }
27
+ };
28
+ return normalize(a) === normalize(b);
29
+ }
30
+ async function parseFeedAt(url) {
31
+ const fetched = await fetchFeed(url);
32
+ const finalUrl = fetched.finalUrl || url;
33
+ try {
34
+ const parsed = await rssParser.parseString(fetched.body);
35
+ if (parsed.items && (parsed.title || parsed.description || parsed.items.length > 0)) {
36
+ return { feed: { url: finalUrl, title: plainTitle(parsed.title) ?? null, type: "application/rss+xml" }, html: null, finalUrl };
37
+ }
38
+ }
39
+ catch {
40
+ // Not a feed; the caller may look for feed links in the HTML instead.
41
+ }
42
+ return { feed: null, html: fetched.body, finalUrl };
43
+ }
44
+ /**
45
+ * Keep only candidates that really are feeds. Sites sometimes announce their own
46
+ * homepage as RSS, so self-references are dropped and every candidate is fetched.
47
+ */
48
+ async function keepRealFeeds(candidates, pageUrl) {
49
+ const toProbe = candidates.filter((candidate) => !sameUrl(candidate.url, pageUrl)).slice(0, MAX_PROBES);
50
+ const probed = await Promise.all(toProbe.map(async (candidate) => {
51
+ try {
52
+ const { feed } = await parseFeedAt(candidate.url);
53
+ return feed ? { ...feed, title: candidate.title ?? feed.title } : null;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }));
59
+ const seen = new Set();
60
+ return probed.filter((feed) => {
61
+ if (!feed || seen.has(feed.url))
62
+ return false;
63
+ seen.add(feed.url);
64
+ return true;
65
+ });
66
+ }
67
+ /**
68
+ * Given a URL, determines if it is already a direct RSS/Atom feed
69
+ * or if it is an HTML page that links to one or more feeds (auto-discovery).
70
+ */
71
+ export async function discoverFeeds(inputUrl) {
72
+ const validated = await assertPublicHttpUrl(inputUrl);
73
+ const targetUrl = validated.toString();
74
+ // 1. The URL is already a feed.
75
+ const page = await parseFeedAt(targetUrl);
76
+ if (page.feed)
77
+ return [page.feed];
78
+ if (!page.html)
79
+ return [];
80
+ // 2. Feeds announced or linked on the page.
81
+ const fromPage = await keepRealFeeds(extractFeedsFromHtml(page.html, page.finalUrl), page.finalUrl);
82
+ if (fromPage.length > 0)
83
+ return fromPage;
84
+ // 3. Common feed paths; some lead to an overview page that lists the site's feeds.
85
+ for (const path of COMMON_PATHS) {
86
+ try {
87
+ const candidateUrl = new URL(path, page.finalUrl).toString();
88
+ if (sameUrl(candidateUrl, targetUrl))
89
+ continue;
90
+ const candidate = await parseFeedAt(candidateUrl);
91
+ if (candidate.feed)
92
+ return [candidate.feed];
93
+ if (candidate.html && !sameUrl(candidate.finalUrl, page.finalUrl)) {
94
+ const listed = await keepRealFeeds(extractFeedsFromHtml(candidate.html, candidate.finalUrl), candidate.finalUrl);
95
+ if (listed.length > 0)
96
+ return listed;
97
+ }
98
+ }
99
+ catch {
100
+ // Try the next path.
101
+ }
102
+ }
103
+ return [];
104
+ }
105
+ /**
106
+ * Collect feed candidates from an HTML page: `<link rel="alternate">` tags first,
107
+ * then feed-like links, then feed URLs mentioned in the page text. Candidates are
108
+ * unverified; discoverFeeds checks them before use.
109
+ */
110
+ export function extractFeedsFromHtml(html, baseUrl) {
111
+ const results = [];
112
+ const seenUrls = new Set();
113
+ const add = (href, title, type) => {
114
+ try {
115
+ const resolved = new URL(href, baseUrl).toString();
116
+ if (!/^https?:/i.test(resolved) || seenUrls.has(resolved))
117
+ return;
118
+ seenUrls.add(resolved);
119
+ results.push({ url: resolved, title, type });
120
+ }
121
+ catch {
122
+ // Invalid URL, skip.
123
+ }
124
+ };
125
+ for (const match of html.matchAll(/<link\b([^>]*)\/?>/gi)) {
126
+ const attrs = match[1];
127
+ const rel = getAttr(attrs, "rel")?.toLowerCase();
128
+ const type = getAttr(attrs, "type")?.toLowerCase();
129
+ const href = getAttr(attrs, "href");
130
+ const title = getAttr(attrs, "title");
131
+ if (href && type && rel?.split(/\s+/).includes("alternate") && FEED_MIME_TYPES.includes(type)) {
132
+ add(href, title ? decodeEntities(title.trim()) : null, type);
133
+ }
134
+ }
135
+ for (const match of html.matchAll(/<a\b([^>]*href=["']([^"']+)["'][^>]*)>(.*?)<\/a>/gis)) {
136
+ const href = match[2];
137
+ if (href.endsWith("/feed") || href.endsWith(".rss") || href.endsWith(".xml") || href.includes("/rss/")) {
138
+ add(href, decodeEntities(match[3].replace(/<[^>]+>/g, "").trim()) || null, "application/rss+xml");
139
+ }
140
+ }
141
+ for (const match of decodeEntities(html).matchAll(FEED_URL_IN_TEXT)) {
142
+ add(match[0], null, "application/rss+xml");
143
+ }
144
+ return results;
145
+ }
146
+ function getAttr(attrsStr, attrName) {
147
+ const regex = new RegExp(`\\b${attrName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
148
+ const match = regex.exec(attrsStr);
149
+ if (!match)
150
+ return null;
151
+ return match[1] ?? match[2] ?? match[3] ?? null;
152
+ }
153
+ //# sourceMappingURL=discovery.js.map
@@ -0,0 +1,208 @@
1
+ import { Readability } from "@mozilla/readability";
2
+ import { JSDOM } from "jsdom";
3
+ import { fetch as undiciFetch } from "undici";
4
+ import { assertPublicHttpUrl, createPublicDispatcher, SsrfBlockedError } from "./ssrfGuard.js";
5
+ import { APP_VERSION } from "../version.js";
6
+ const defaultFetch = undiciFetch;
7
+ const CONSENT_SELECTORS = [
8
+ "#onetrust-consent-sdk",
9
+ "#cmpbox",
10
+ "#usercentrics-root",
11
+ "[id*='sp_message_container']",
12
+ "[class*='cookie-banner']",
13
+ "[class*='cookie-consent']",
14
+ "[class*='consent-banner']",
15
+ "[id*='cookie-notice']",
16
+ "[id*='cookie-banner']",
17
+ ".qc-cmp2-container",
18
+ "#didomi-host",
19
+ "dialog[open]",
20
+ ".cookie-wall",
21
+ ".consent-wall",
22
+ "[data-nosnippet]",
23
+ ];
24
+ function cleanConsentDom(doc) {
25
+ for (const sel of CONSENT_SELECTORS) {
26
+ try {
27
+ doc.querySelectorAll(sel).forEach((el) => el.remove());
28
+ }
29
+ catch {
30
+ // ignore invalid selector in edge environments
31
+ }
32
+ }
33
+ }
34
+ /**
35
+ * Checks if a given URL or extracted HTML/text is a cookie / consent wall rather than article content.
36
+ */
37
+ export function isConsentContent(finalUrl, html, title, text) {
38
+ if (/\/zustimmung[/?]/i.test(finalUrl) ||
39
+ /\/consent[/?]/i.test(finalUrl) ||
40
+ /\/cookie-wall/i.test(finalUrl) ||
41
+ /\/pur-abo[/?]/i.test(finalUrl) ||
42
+ /\/cmp[/?]/i.test(finalUrl) ||
43
+ /\/privacy-wall/i.test(finalUrl)) {
44
+ return true;
45
+ }
46
+ const combined = `${title || ""} ${text || ""} ${html}`.toLowerCase();
47
+ const strongSignatures = [
48
+ "cookies zustimmen",
49
+ "nutzung aller cookies",
50
+ "tracking zustimmen",
51
+ "einwilligung verwalten",
52
+ "werbung und tracking, indem sie",
53
+ "zustimmen & weiter",
54
+ "bevor sie fortfahren",
55
+ "accept all cookies",
56
+ "cookie-einstellungen",
57
+ "alle cookies akzeptieren",
58
+ "cookies akzeptieren",
59
+ "we value your privacy",
60
+ ];
61
+ const headText = `${title || ""} ${text || ""}`.toLowerCase().slice(0, 600);
62
+ const hits = strongSignatures.filter(sig => combined.includes(sig));
63
+ if (hits.length === 0)
64
+ return false;
65
+ const heading = (title || "").toLowerCase();
66
+ if (strongSignatures.some(sig => heading.includes(sig)))
67
+ return true;
68
+ return (text || "").length < 1200 && hits.length >= 2 && hits.some(sig => headText.includes(sig));
69
+ }
70
+ /**
71
+ * Checks cached HTML to see if it was a saved consent banner instead of real article content.
72
+ */
73
+ export function isCachedConsentSnippet(html) {
74
+ return isConsentContent("", html, "", html.replace(/<[^>]+>/g, " "));
75
+ }
76
+ // Browser-like so article pages render normally, but identifies FeedKeeper honestly.
77
+ const FETCH_USER_AGENT = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 FeedKeeper/${APP_VERSION}`;
78
+ const FETCH_TIMEOUT_MS = 12_000;
79
+ const MAX_REDIRECTS = 5;
80
+ class ArticleHttpError extends Error {
81
+ status;
82
+ constructor(status) {
83
+ super("article_http_error");
84
+ this.status = status;
85
+ }
86
+ }
87
+ const MAX_HTML_BYTES = 2 * 1024 * 1024;
88
+ function stripHeavyTags(html) {
89
+ return html
90
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
91
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
92
+ .replace(/<svg\b[^<]*(?:(?!<\/svg>)<[^<]*)*<\/svg>/gi, "")
93
+ .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, "")
94
+ .replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, "");
95
+ }
96
+ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl) {
97
+ let currentUrl = rawUrl;
98
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
99
+ const safeUrl = await assertPublicHttpUrl(currentUrl);
100
+ const response = await fetchImpl(safeUrl, {
101
+ signal,
102
+ headers: {
103
+ "User-Agent": FETCH_USER_AGENT,
104
+ Accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8",
105
+ // Article URLs already point to one language; don't prefer any locale.
106
+ "Accept-Language": "*",
107
+ "Cache-Control": "no-cache",
108
+ },
109
+ redirect: "manual",
110
+ dispatcher,
111
+ });
112
+ if (response.status >= 300 && response.status < 400) {
113
+ const location = response.headers.get("location");
114
+ if (!location)
115
+ return null;
116
+ await response.body?.cancel();
117
+ currentUrl = new URL(location, safeUrl).toString();
118
+ continue;
119
+ }
120
+ if (!response.ok) {
121
+ await response.body?.cancel();
122
+ throw new ArticleHttpError(response.status);
123
+ }
124
+ const contentLength = Number(response.headers.get("content-length"));
125
+ if (contentLength > MAX_HTML_BYTES)
126
+ return null;
127
+ const reader = response.body?.getReader();
128
+ if (!reader)
129
+ return null;
130
+ const chunks = [];
131
+ let total = 0;
132
+ for (;;) {
133
+ const { done, value } = await reader.read();
134
+ if (done)
135
+ break;
136
+ total += value.byteLength;
137
+ if (total > MAX_HTML_BYTES) {
138
+ await reader.cancel();
139
+ return null;
140
+ }
141
+ chunks.push(value);
142
+ }
143
+ const html = new TextDecoder().decode(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))));
144
+ return { html, url: safeUrl.toString() };
145
+ }
146
+ return null;
147
+ }
148
+ export async function extractArticleFromUrl(rawUrl, fetchImpl = defaultFetch) {
149
+ const result = await extractArticle(rawUrl, fetchImpl);
150
+ return result.status === "ok" ? result.article : null;
151
+ }
152
+ /** Like extractArticleFromUrl, but reports whether the site answered with a consent wall. */
153
+ export async function extractArticle(rawUrl, fetchImpl = defaultFetch) {
154
+ const safeUrl = await assertPublicHttpUrl(rawUrl);
155
+ const dispatcher = createPublicDispatcher();
156
+ const controller = new AbortController();
157
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
158
+ try {
159
+ const fetched = await fetchArticleHtml(safeUrl.toString(), controller.signal, dispatcher, fetchImpl);
160
+ if (!fetched)
161
+ return { status: "failed" };
162
+ const cleanedHtml = stripHeavyTags(fetched.html);
163
+ const dom = new JSDOM(cleanedHtml, { url: fetched.url });
164
+ let parsed;
165
+ try {
166
+ const paywall = /"isAccessibleForFree"\s*:\s*(?:false|"false")/i.test(fetched.html);
167
+ cleanConsentDom(dom.window.document);
168
+ parsed = new Readability(dom.window.document, { charThreshold: 60 }).parse();
169
+ if (isConsentContent(fetched.url, parsed?.content ?? fetched.html, parsed?.title, parsed?.textContent)) {
170
+ return { status: "consent_wall" };
171
+ }
172
+ if (paywall || ((parsed?.textContent?.length ?? 0) < 2500 && /(?:subscribe to (?:continue|read)|sign in to (?:continue|read)|abonnieren und weiterlesen|nur für abonnenten|jetzt abonnieren.{0,40}weiterlesen)/i.test(parsed?.textContent ?? "")))
173
+ return { status: "paywall" };
174
+ if (!parsed?.content)
175
+ return { status: "failed" };
176
+ }
177
+ finally {
178
+ dom.window.close();
179
+ }
180
+ return {
181
+ status: (parsed.textContent?.trim().length ?? 0) < 200 ? "partial" : "ok",
182
+ article: {
183
+ title: parsed.title || null,
184
+ byline: parsed.byline || null,
185
+ contentHtml: parsed.content,
186
+ textContent: parsed.textContent || "",
187
+ excerpt: parsed.excerpt || null,
188
+ siteName: parsed.siteName || null,
189
+ },
190
+ };
191
+ }
192
+ catch (error) {
193
+ if (error instanceof SsrfBlockedError)
194
+ throw error;
195
+ if (controller.signal.aborted)
196
+ return { status: "timeout" };
197
+ if (error instanceof ArticleHttpError && [401, 402].includes(error.status))
198
+ return { status: "paywall" };
199
+ if (error instanceof ArticleHttpError && [403, 429, 503].includes(error.status))
200
+ return { status: "bot_blocked" };
201
+ return { status: "failed" };
202
+ }
203
+ finally {
204
+ clearTimeout(timeout);
205
+ dispatcher.destroy();
206
+ }
207
+ }
208
+ //# sourceMappingURL=extractor.js.map