feedkeeper 0.0.0-stage → 0.23.1

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 (136) 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 +167 -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/auth/avatar.js +13 -0
  20. package/server/dist/auth/capabilities.js +53 -0
  21. package/server/dist/auth/middleware.js +77 -0
  22. package/server/dist/auth/pairing.js +36 -0
  23. package/server/dist/auth/password.js +21 -0
  24. package/server/dist/auth/security.js +126 -0
  25. package/server/dist/auth/securityRoutes.js +249 -0
  26. package/server/dist/auth/tokens.js +64 -0
  27. package/server/dist/auth/users.js +54 -0
  28. package/server/dist/backup-cli.js +101 -0
  29. package/server/dist/cli.js +135 -0
  30. package/server/dist/clientAddress.js +26 -0
  31. package/server/dist/config.js +51 -0
  32. package/server/dist/db/index.js +40 -0
  33. package/server/dist/db/migrations/0001_init.sql +70 -0
  34. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  35. package/server/dist/db/migrations/0003_retention.sql +13 -0
  36. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  37. package/server/dist/db/migrations/0005_folders.sql +15 -0
  38. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  39. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  40. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  41. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  42. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  43. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  44. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  45. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  46. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  47. package/server/dist/db/migrations/0015_push.sql +13 -0
  48. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  49. package/server/dist/db/migrations/0017_devices.sql +13 -0
  50. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  51. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  52. package/server/dist/db/migrations/0020_fts.sql +32 -0
  53. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  54. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  55. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  56. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  57. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  58. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  59. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  60. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  61. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  62. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  63. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  64. package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
  65. package/server/dist/feedkeeper.js +38 -0
  66. package/server/dist/feeds/archive.js +334 -0
  67. package/server/dist/feeds/articleContent.js +26 -0
  68. package/server/dist/feeds/cleanup.js +179 -0
  69. package/server/dist/feeds/dates.js +21 -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 +223 -0
  85. package/server/dist/feeds/repository.js +687 -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/mcp/digest.js +73 -0
  91. package/server/dist/mcp/http.js +46 -0
  92. package/server/dist/mcp/items.js +73 -0
  93. package/server/dist/mcp/promptsAndResources.js +43 -0
  94. package/server/dist/mcp/server.js +547 -0
  95. package/server/dist/native/editionSelection.js +127 -0
  96. package/server/dist/native/editionText.js +15 -0
  97. package/server/dist/native/editions.js +218 -0
  98. package/server/dist/native/preferences.js +66 -0
  99. package/server/dist/native/requests.js +35 -0
  100. package/server/dist/oauth/constants.js +6 -0
  101. package/server/dist/oauth/host.js +8 -0
  102. package/server/dist/oauth/pages.js +148 -0
  103. package/server/dist/oauth/routes.js +370 -0
  104. package/server/dist/oauth/store.js +104 -0
  105. package/server/dist/push.js +147 -0
  106. package/server/dist/pushOutbox.js +164 -0
  107. package/server/dist/security-recover-cli.js +29 -0
  108. package/server/dist/server.js +108 -0
  109. package/server/dist/setup-cli.js +97 -0
  110. package/server/dist/sync/changeLog.js +170 -0
  111. package/server/dist/sync/mutations.js +144 -0
  112. package/server/dist/trustProxy.js +14 -0
  113. package/server/dist/version.js +6 -0
  114. package/server/package.json +6 -0
  115. package/web/dist/app-icon-180.png +0 -0
  116. package/web/dist/app-icon-192.png +0 -0
  117. package/web/dist/app-icon-512.png +0 -0
  118. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  119. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  120. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  121. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  122. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  124. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  126. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  128. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  130. package/web/dist/favicon-32.png +0 -0
  131. package/web/dist/index.html +40 -0
  132. package/web/dist/logo.svg +50 -0
  133. package/web/dist/manifest.webmanifest +64 -0
  134. package/web/dist/robots.txt +3 -0
  135. package/web/dist/sw.js +198 -0
  136. package/web/dist/theme-init.js +10 -0
@@ -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
@@ -0,0 +1,171 @@
1
+ import crypto from "node:crypto";
2
+ import { Resvg } from "@resvg/resvg-js";
3
+ import { extractLargestImageAsPng } from "@humanwhocodes/ico-to-png";
4
+ import { db } from "../db/index.js";
5
+ import { fetchImage } from "./fetcher.js";
6
+ import { readPollSettings } from "./pollSettings.js";
7
+ import { detectImageType } from "./archive.js";
8
+ const MAX_ICON_BYTES = 1024 * 1024;
9
+ const TTL_MS = 24 * 60 * 60 * 1000;
10
+ const MAX_CACHED = 500;
11
+ const cache = new Map();
12
+ /** Compute stable content hash for versioning and caching. */
13
+ export function hashIconBuffer(buffer) {
14
+ return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 16);
15
+ }
16
+ /** Icons are small, so besides raster images the formats typical for favicons are accepted. */
17
+ export function detectIconType(buffer) {
18
+ const raster = detectImageType(buffer);
19
+ if (raster)
20
+ return raster.mime;
21
+ if (buffer.length >= 4 && buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0)
22
+ return "image/x-icon";
23
+ const head = Buffer.from(buffer.subarray(0, 512)).toString("utf8").trimStart();
24
+ return /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)?<svg[\s>]/i.test(head) ? "image/svg+xml" : null;
25
+ }
26
+ /** Whether the user subscribes to the feed or has saved or annotated one of its articles. */
27
+ export function canAccessFeed(userId, feedId) {
28
+ return Boolean(db.prepare(`SELECT 1 FROM feeds f WHERE f.id = ?
29
+ AND (EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id AND s.user_id = ?)
30
+ 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 = ?)
31
+ 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
+ }
33
+ function candidates(feed, declaredUrls) {
34
+ const urls = new Set(declaredUrls);
35
+ if (feed.icon_url)
36
+ urls.add(feed.icon_url);
37
+ for (const source of [feed.site_url, feed.url]) {
38
+ try {
39
+ if (source)
40
+ urls.add(`${new URL(source).origin}/favicon.ico`);
41
+ }
42
+ catch {
43
+ // Try the next source.
44
+ }
45
+ }
46
+ return [...urls];
47
+ }
48
+ /** Keep the same raster icon in every client, including clients without SVG or ICO support. */
49
+ function rasterIcon(buffer, mime) {
50
+ if (mime === "image/svg+xml") {
51
+ const svg = new Resvg(buffer, { fitTo: { mode: "width", value: 512 }, font: { loadSystemFonts: false } });
52
+ if (svg.width > 4096 || svg.height > 4096 || svg.width < 1 || svg.height < 1 ||
53
+ svg.width / svg.height > 8 || svg.height / svg.width > 8)
54
+ throw new Error("Invalid SVG icon size");
55
+ return { buffer: svg.render().asPng(), mime: "image/png" };
56
+ }
57
+ if (mime === "image/x-icon") {
58
+ const image = extractLargestImageAsPng(buffer);
59
+ if (!image)
60
+ throw new Error("Invalid ICO icon");
61
+ return { buffer: Buffer.from(image.data), mime: "image/png" };
62
+ }
63
+ return { buffer, mime };
64
+ }
65
+ /** Store or update feed icon in database, only notifying subscribers when its content changes. */
66
+ export function storeFeedIcon(feedId, buffer, mime) {
67
+ ({ buffer, mime } = rasterIcon(buffer, mime));
68
+ const hash = hashIconBuffer(buffer);
69
+ db.prepare(`INSERT INTO feed_icons (feed_id, data, mime, hash, updated_at)
70
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
71
+ ON CONFLICT (feed_id) DO UPDATE SET data = excluded.data, mime = excluded.mime, hash = excluded.hash, updated_at = excluded.updated_at
72
+ WHERE feed_icons.hash != excluded.hash OR feed_icons.mime != excluded.mime`).run(feedId, buffer, mime, hash);
73
+ const icon = { buffer, mime, hash };
74
+ if (cache.size >= MAX_CACHED)
75
+ cache.delete(cache.keys().next().value);
76
+ cache.set(feedId, { icon, fetchedAt: Date.now() });
77
+ return icon;
78
+ }
79
+ /** Re-check remote candidates during the weekly feed poll, even if a stored icon exists. */
80
+ const refreshing = new Map();
81
+ export function refreshFeedIcon(feedId, siteUrl, declaredUrls = [], signal) {
82
+ const current = refreshing.get(feedId);
83
+ if (current)
84
+ return current;
85
+ if (refreshing.size >= readPollSettings().maxWaiters)
86
+ return Promise.resolve(null);
87
+ const controller = new AbortController();
88
+ const timeout = setTimeout(() => controller.abort(new DOMException("Icon deadline exceeded", "TimeoutError")), readPollSettings().attemptTimeoutMs);
89
+ const deadline = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
90
+ const promise = refreshIcon(feedId, siteUrl, declaredUrls, deadline).finally(() => {
91
+ clearTimeout(timeout);
92
+ refreshing.delete(feedId);
93
+ });
94
+ refreshing.set(feedId, promise);
95
+ return promise;
96
+ }
97
+ async function refreshIcon(feedId, siteUrl, declaredUrls, signal) {
98
+ const feed = db.prepare("SELECT icon_url, site_url, url FROM feeds WHERE id = ?").get(feedId);
99
+ for (const url of feed ? candidates({ ...feed, site_url: siteUrl ?? feed.site_url }, declaredUrls) : []) {
100
+ if (signal.aborted)
101
+ break;
102
+ try {
103
+ const { buffer } = await fetchImage(url, MAX_ICON_BYTES, signal);
104
+ const mime = detectIconType(buffer);
105
+ if (mime) {
106
+ const icon = storeFeedIcon(feedId, buffer, mime);
107
+ db.prepare("UPDATE feeds SET icon_url = ? WHERE id = ?").run(url, feedId);
108
+ return icon;
109
+ }
110
+ }
111
+ catch {
112
+ // Keep the last good icon and try the next candidate.
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ /** Retrieve icon hash from database without reading full blob. */
118
+ export function getFeedIconHash(feedId) {
119
+ const row = db.prepare("SELECT hash FROM feed_icons WHERE feed_id = ?").get(feedId);
120
+ return row?.hash ?? null;
121
+ }
122
+ /**
123
+ * The feed's icon, fetched by this server so the browser and native apps can keep it for offline use
124
+ * without asking third-party sites. Stored persistently in the database and cached in memory.
125
+ */
126
+ export async function loadFeedIcon(feedId) {
127
+ const known = cache.get(feedId);
128
+ if (known && Date.now() - known.fetchedAt < TTL_MS)
129
+ return known.icon;
130
+ // Check persistent storage
131
+ const row = db.prepare("SELECT data, mime, hash FROM feed_icons WHERE feed_id = ?").get(feedId);
132
+ if (row) {
133
+ // Icons stored by older versions may still be SVG or ICO.
134
+ let icon;
135
+ try {
136
+ icon = row.mime === "image/svg+xml" || row.mime === "image/x-icon"
137
+ ? storeFeedIcon(feedId, row.data, row.mime)
138
+ : { buffer: row.data, mime: row.mime, hash: row.hash };
139
+ }
140
+ catch {
141
+ return refreshFeedIcon(feedId);
142
+ }
143
+ if (cache.size >= MAX_CACHED)
144
+ cache.delete(cache.keys().next().value);
145
+ cache.set(feedId, { icon, fetchedAt: Date.now() });
146
+ return icon;
147
+ }
148
+ // Capacity pressure is temporary, not evidence that the publisher has no icon.
149
+ // Do not poison the 24-hour negative cache when another refresh can try shortly.
150
+ if (!refreshing.has(feedId) && refreshing.size >= readPollSettings().maxWaiters)
151
+ return null;
152
+ const icon = await refreshFeedIcon(feedId);
153
+ if (!icon) {
154
+ if (cache.size >= MAX_CACHED)
155
+ cache.delete(cache.keys().next().value);
156
+ cache.set(feedId, { icon: null, fetchedAt: Date.now() });
157
+ }
158
+ return icon;
159
+ }
160
+ /** Forget remembered icons (tests). */
161
+ export function clearIconCache(feedId) {
162
+ if (feedId !== undefined) {
163
+ cache.delete(feedId);
164
+ db.prepare("DELETE FROM feed_icons WHERE feed_id = ?").run(feedId);
165
+ }
166
+ else {
167
+ cache.clear();
168
+ db.prepare("DELETE FROM feed_icons").run();
169
+ }
170
+ }
171
+ //# sourceMappingURL=feedIcon.js.map