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.
- package/LICENSE +21 -0
- package/README.md +22 -2
- package/package.json +57 -4
- package/server/dist/api/archive.js +19 -0
- package/server/dist/api/auth.js +167 -0
- package/server/dist/api/feeds.js +207 -0
- package/server/dist/api/filters.js +32 -0
- package/server/dist/api/folders.js +53 -0
- package/server/dist/api/index.js +37 -0
- package/server/dist/api/items.js +129 -0
- package/server/dist/api/oauth.js +14 -0
- package/server/dist/api/onboarding.js +30 -0
- package/server/dist/api/push.js +51 -0
- package/server/dist/api/system.js +42 -0
- package/server/dist/api/tokens.js +28 -0
- package/server/dist/api/v1/index.js +178 -0
- package/server/dist/api/v1/resources.js +653 -0
- package/server/dist/api/v1/serialize.js +48 -0
- package/server/dist/auth/avatar.js +13 -0
- package/server/dist/auth/capabilities.js +53 -0
- package/server/dist/auth/middleware.js +77 -0
- package/server/dist/auth/pairing.js +36 -0
- package/server/dist/auth/password.js +21 -0
- package/server/dist/auth/security.js +126 -0
- package/server/dist/auth/securityRoutes.js +249 -0
- package/server/dist/auth/tokens.js +64 -0
- package/server/dist/auth/users.js +54 -0
- package/server/dist/backup-cli.js +101 -0
- package/server/dist/cli.js +135 -0
- package/server/dist/clientAddress.js +26 -0
- package/server/dist/config.js +51 -0
- package/server/dist/db/index.js +40 -0
- package/server/dist/db/migrations/0001_init.sql +70 -0
- package/server/dist/db/migrations/0002_feed_health.sql +5 -0
- package/server/dist/db/migrations/0003_retention.sql +13 -0
- package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
- package/server/dist/db/migrations/0005_folders.sql +15 -0
- package/server/dist/db/migrations/0006_feed_position.sql +5 -0
- package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
- package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
- package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
- package/server/dist/db/migrations/0010_token_scope.sql +3 -0
- package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
- package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
- package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
- package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
- package/server/dist/db/migrations/0015_push.sql +13 -0
- package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
- package/server/dist/db/migrations/0017_devices.sql +13 -0
- package/server/dist/db/migrations/0018_change_log.sql +166 -0
- package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
- package/server/dist/db/migrations/0020_fts.sql +32 -0
- package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
- package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
- package/server/dist/db/migrations/0023_server_editions.sql +81 -0
- package/server/dist/db/migrations/0024_inbox.sql +5 -0
- package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
- package/server/dist/db/migrations/0026_article_content.sql +43 -0
- package/server/dist/db/migrations/0027_oauth.sql +38 -0
- package/server/dist/db/migrations/0028_user_intro.sql +2 -0
- package/server/dist/db/migrations/0029_account_security.sql +35 -0
- package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
- package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
- package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
- package/server/dist/feedkeeper.js +38 -0
- package/server/dist/feeds/archive.js +334 -0
- package/server/dist/feeds/articleContent.js +26 -0
- package/server/dist/feeds/cleanup.js +179 -0
- package/server/dist/feeds/dates.js +21 -0
- package/server/dist/feeds/discovery.js +153 -0
- package/server/dist/feeds/extractor.js +208 -0
- package/server/dist/feeds/feedIcon.js +171 -0
- package/server/dist/feeds/fetcher.js +217 -0
- package/server/dist/feeds/fullText.js +91 -0
- package/server/dist/feeds/fullTextQueue.js +159 -0
- package/server/dist/feeds/icon.js +97 -0
- package/server/dist/feeds/imageCache.js +266 -0
- package/server/dist/feeds/imageCacheSettings.js +19 -0
- package/server/dist/feeds/imageTypes.js +16 -0
- package/server/dist/feeds/inbox.js +128 -0
- package/server/dist/feeds/opml.js +190 -0
- package/server/dist/feeds/pollQueue.js +301 -0
- package/server/dist/feeds/pollSettings.js +23 -0
- package/server/dist/feeds/poller.js +223 -0
- package/server/dist/feeds/repository.js +687 -0
- package/server/dist/feeds/requestGate.js +99 -0
- package/server/dist/feeds/service.js +162 -0
- package/server/dist/feeds/ssrfGuard.js +98 -0
- package/server/dist/feeds/text.js +35 -0
- package/server/dist/mcp/digest.js +73 -0
- package/server/dist/mcp/http.js +46 -0
- package/server/dist/mcp/items.js +73 -0
- package/server/dist/mcp/promptsAndResources.js +43 -0
- package/server/dist/mcp/server.js +547 -0
- package/server/dist/native/editionSelection.js +127 -0
- package/server/dist/native/editionText.js +15 -0
- package/server/dist/native/editions.js +218 -0
- package/server/dist/native/preferences.js +66 -0
- package/server/dist/native/requests.js +35 -0
- package/server/dist/oauth/constants.js +6 -0
- package/server/dist/oauth/host.js +8 -0
- package/server/dist/oauth/pages.js +148 -0
- package/server/dist/oauth/routes.js +370 -0
- package/server/dist/oauth/store.js +104 -0
- package/server/dist/push.js +147 -0
- package/server/dist/pushOutbox.js +164 -0
- package/server/dist/security-recover-cli.js +29 -0
- package/server/dist/server.js +108 -0
- package/server/dist/setup-cli.js +97 -0
- package/server/dist/sync/changeLog.js +170 -0
- package/server/dist/sync/mutations.js +144 -0
- package/server/dist/trustProxy.js +14 -0
- package/server/dist/version.js +6 -0
- package/server/package.json +6 -0
- package/web/dist/app-icon-180.png +0 -0
- package/web/dist/app-icon-192.png +0 -0
- package/web/dist/app-icon-512.png +0 -0
- package/web/dist/assets/index-B4e2oqbo.css +2 -0
- package/web/dist/assets/index-xx6TyOwB.js +32 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
- package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
- package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/index.html +40 -0
- package/web/dist/logo.svg +50 -0
- package/web/dist/manifest.webmanifest +64 -0
- package/web/dist/robots.txt +3 -0
- package/web/dist/sw.js +198 -0
- package/web/dist/theme-init.js +10 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { fetch as undiciFetch } from "undici";
|
|
2
|
+
import { assertPublicHttpUrl, createPublicDispatcher, normalizeUrlCandidate } from "./ssrfGuard.js";
|
|
3
|
+
import { APP_VERSION } from "../version.js";
|
|
4
|
+
import { feedRequests, sourceHost } from "./requestGate.js";
|
|
5
|
+
import { readPollSettings } from "./pollSettings.js";
|
|
6
|
+
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
7
|
+
const MAX_REDIRECTS = 5;
|
|
8
|
+
const USER_AGENT = `FeedKeeper/${APP_VERSION} (+https://github.com/visualfusion/feedkeeper)`;
|
|
9
|
+
function cacheLifetime(headers) {
|
|
10
|
+
const control = headers.get("cache-control") ?? "";
|
|
11
|
+
if (/(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(control))
|
|
12
|
+
return 0;
|
|
13
|
+
const maxAge = /(?:^|,)\s*max-age\s*=\s*"?(\d+)"?/i.exec(control);
|
|
14
|
+
const age = Number(headers.get("age") ?? 0);
|
|
15
|
+
const date = Date.parse(headers.get("date") ?? "");
|
|
16
|
+
const elapsed = Math.max(0, Number.isFinite(age) ? age * 1_000 : 0, Number.isFinite(date) ? Date.now() - date : 0);
|
|
17
|
+
if (maxAge)
|
|
18
|
+
return Math.max(0, Number(maxAge[1]) * 1_000 - elapsed);
|
|
19
|
+
const expires = Date.parse(headers.get("expires") ?? "");
|
|
20
|
+
return Number.isFinite(expires) ? Math.max(0, expires - Date.now()) : undefined;
|
|
21
|
+
}
|
|
22
|
+
export class FeedHttpError extends Error {
|
|
23
|
+
status;
|
|
24
|
+
retryAt;
|
|
25
|
+
host;
|
|
26
|
+
constructor(status, retryAt, host) {
|
|
27
|
+
super(`Feed responded with HTTP ${status}`);
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.retryAt = retryAt;
|
|
30
|
+
this.host = host;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** RFC 9110: non-negative integer seconds or an HTTP date (including obsolete HTTP dates). */
|
|
34
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
35
|
+
if (value === null)
|
|
36
|
+
return undefined;
|
|
37
|
+
const text = value.trim();
|
|
38
|
+
if (/^\d+$/.test(text)) {
|
|
39
|
+
const seconds = Number(text);
|
|
40
|
+
if (!Number.isFinite(seconds))
|
|
41
|
+
return undefined;
|
|
42
|
+
return Math.min(8.64e15, now + seconds * 1000);
|
|
43
|
+
}
|
|
44
|
+
const day = "(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)";
|
|
45
|
+
const month = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)";
|
|
46
|
+
const time = "\\d{2}:\\d{2}:\\d{2}";
|
|
47
|
+
const imf = new RegExp(`^${day}, \\d{2} ${month} \\d{4} ${time} GMT$`);
|
|
48
|
+
const obsolete = text.match(new RegExp(`^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${month})-(\\d{2}) (${time}) GMT$`));
|
|
49
|
+
const asctime = text.match(new RegExp(`^(${day}) (${month}) ( [1-9]|\\d{2}) (${time}) (\\d{4})$`));
|
|
50
|
+
let normalized = text;
|
|
51
|
+
if (obsolete) {
|
|
52
|
+
const yearLimit = new Date(now).getUTCFullYear() + 50;
|
|
53
|
+
let year = Math.floor(yearLimit / 100) * 100 + Number(obsolete[4]);
|
|
54
|
+
if (year > yearLimit)
|
|
55
|
+
year -= 100;
|
|
56
|
+
normalized = `${obsolete[1].slice(0, 3)}, ${obsolete[2]} ${obsolete[3]} ${year} ${obsolete[5]} GMT`;
|
|
57
|
+
}
|
|
58
|
+
else if (asctime) {
|
|
59
|
+
// asctime has no zone suffix; HTTP dates are always UTC, never the server's local time.
|
|
60
|
+
normalized = `${asctime[1]}, ${asctime[3].trim().padStart(2, "0")} ${asctime[2]} ${asctime[5]} ${asctime[4]} GMT`;
|
|
61
|
+
}
|
|
62
|
+
else if (!imf.test(text))
|
|
63
|
+
return undefined;
|
|
64
|
+
const date = Date.parse(normalized);
|
|
65
|
+
if (!Number.isFinite(date) || new Date(date).toUTCString() !== normalized)
|
|
66
|
+
return undefined;
|
|
67
|
+
return Math.max(now, date);
|
|
68
|
+
}
|
|
69
|
+
/** DNS preflight cannot be cancelled, but must not hold the caller past its deadline. */
|
|
70
|
+
async function validateUrl(url, signal) {
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const abort = () => reject(signal.reason);
|
|
74
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
75
|
+
assertPublicHttpUrl(url).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// Re-validate every redirect hop and bound headers, body, DNS and permit waiting together.
|
|
79
|
+
async function downloadPublic(url, opts) {
|
|
80
|
+
let currentUrl = url;
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(new DOMException("Download deadline exceeded", "TimeoutError")), readPollSettings().requestTimeoutMs);
|
|
83
|
+
const signal = opts.signal ? AbortSignal.any([controller.signal, opts.signal]) : controller.signal;
|
|
84
|
+
const dispatcher = createPublicDispatcher();
|
|
85
|
+
try {
|
|
86
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
87
|
+
const candidate = new URL(normalizeUrlCandidate(currentUrl));
|
|
88
|
+
const host = sourceHost(candidate.href);
|
|
89
|
+
const release = await feedRequests.acquire(host, signal);
|
|
90
|
+
try {
|
|
91
|
+
const validated = await validateUrl(candidate.href, signal);
|
|
92
|
+
signal.throwIfAborted();
|
|
93
|
+
const headers = { "User-Agent": USER_AGENT, Accept: opts.accept };
|
|
94
|
+
if (redirects === 0 && opts.etag)
|
|
95
|
+
headers["If-None-Match"] = opts.etag;
|
|
96
|
+
if (redirects === 0 && opts.lastModified)
|
|
97
|
+
headers["If-Modified-Since"] = opts.lastModified;
|
|
98
|
+
const response = await undiciFetch(validated, { headers, redirect: "manual", signal, dispatcher });
|
|
99
|
+
if (response.status === 304) {
|
|
100
|
+
await response.body?.cancel();
|
|
101
|
+
return { notModified: true, buffer: Buffer.alloc(0), finalUrl: currentUrl, contentType: response.headers.get("content-type") ?? undefined };
|
|
102
|
+
}
|
|
103
|
+
if (response.status >= 300 && response.status < 400) {
|
|
104
|
+
const location = response.headers.get("location");
|
|
105
|
+
await response.body?.cancel();
|
|
106
|
+
if (!location)
|
|
107
|
+
throw new Error("Redirect without a Location header");
|
|
108
|
+
currentUrl = new URL(location, validated).toString();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
let retryAt = parseRetryAfter(response.headers.get("retry-after"));
|
|
113
|
+
if (response.status === 429 || response.status === 503) {
|
|
114
|
+
retryAt = Math.max(retryAt ?? 0, Date.now() + readPollSettings().retryBaseMs * (0.5 + Math.random() * 0.5));
|
|
115
|
+
feedRequests.defer(host, retryAt);
|
|
116
|
+
}
|
|
117
|
+
await response.body?.cancel();
|
|
118
|
+
throw new FeedHttpError(response.status, retryAt, host);
|
|
119
|
+
}
|
|
120
|
+
const contentLength = response.headers.get("content-length");
|
|
121
|
+
if (contentLength && Number(contentLength) > opts.maxBytes) {
|
|
122
|
+
await response.body?.cancel();
|
|
123
|
+
throw new Error("Feed response exceeds the maximum allowed size");
|
|
124
|
+
}
|
|
125
|
+
const reader = response.body?.getReader();
|
|
126
|
+
if (!reader)
|
|
127
|
+
throw new Error("Feed response had no body");
|
|
128
|
+
const chunks = [];
|
|
129
|
+
let total = 0;
|
|
130
|
+
for (;;) {
|
|
131
|
+
const { done, value } = await reader.read();
|
|
132
|
+
if (done)
|
|
133
|
+
break;
|
|
134
|
+
total += value.byteLength;
|
|
135
|
+
if (total > opts.maxBytes) {
|
|
136
|
+
await reader.cancel();
|
|
137
|
+
throw new Error("Feed response exceeds the maximum allowed size");
|
|
138
|
+
}
|
|
139
|
+
chunks.push(value);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
cacheTtlMs: cacheLifetime(response.headers),
|
|
143
|
+
notModified: false, buffer: Buffer.concat(chunks, total),
|
|
144
|
+
contentType: response.headers.get("content-type") ?? undefined,
|
|
145
|
+
etag: response.headers.get("etag") ?? undefined,
|
|
146
|
+
lastModified: response.headers.get("last-modified") ?? undefined, finalUrl: currentUrl,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
release();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
throw new Error("Too many redirects while fetching feed");
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
clearTimeout(timeout);
|
|
157
|
+
await dispatcher.destroy();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Fetches a feed URL with conditional GET headers and decodes it to text.
|
|
161
|
+
export async function fetchFeed(url, opts = {}) {
|
|
162
|
+
const download = await downloadPublic(url, {
|
|
163
|
+
accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, text/html, application/xhtml+xml;q=0.9, */*;q=0.5",
|
|
164
|
+
maxBytes: MAX_RESPONSE_BYTES,
|
|
165
|
+
etag: opts.etag,
|
|
166
|
+
lastModified: opts.lastModified,
|
|
167
|
+
signal: opts.signal,
|
|
168
|
+
});
|
|
169
|
+
if (download.notModified) {
|
|
170
|
+
return { notModified: true, body: "", finalUrl: download.finalUrl, contentType: download.contentType };
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
body: decodeFeedBuffer(download.buffer, download.contentType),
|
|
174
|
+
notModified: false,
|
|
175
|
+
etag: download.etag,
|
|
176
|
+
lastModified: download.lastModified,
|
|
177
|
+
contentType: download.contentType,
|
|
178
|
+
finalUrl: download.finalUrl,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Downloads an image for the archive; the caller checks the file signature. */
|
|
182
|
+
export async function fetchImage(url, maxBytes, signal) {
|
|
183
|
+
const download = await downloadPublic(url, { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.5", maxBytes, signal });
|
|
184
|
+
return { buffer: download.buffer, finalUrl: download.finalUrl, cacheTtlMs: download.cacheTtlMs };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Decodes raw response buffer into a string respecting character encoding from:
|
|
188
|
+
* 1. HTTP Content-Type header (e.g. charset=ISO-8859-1)
|
|
189
|
+
* 2. XML declaration header (e.g. <?xml version="1.0" encoding="ISO-8859-1"?>)
|
|
190
|
+
* Defaults to UTF-8.
|
|
191
|
+
*/
|
|
192
|
+
function decodeFeedBuffer(buffer, contentType) {
|
|
193
|
+
let charset = null;
|
|
194
|
+
if (contentType) {
|
|
195
|
+
const match = contentType.match(/charset=([^;]+)/i);
|
|
196
|
+
if (match) {
|
|
197
|
+
charset = match[1].trim().replace(/^["']|["']$/g, "");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!charset) {
|
|
201
|
+
const snippet = buffer.subarray(0, 1024).toString("ascii");
|
|
202
|
+
const xmlMatch = snippet.match(/<\?xml[^>]+encoding=["']([^"']+)["']/i);
|
|
203
|
+
if (xmlMatch) {
|
|
204
|
+
charset = xmlMatch[1].trim();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (charset) {
|
|
208
|
+
try {
|
|
209
|
+
return new TextDecoder(charset).decode(buffer);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// If TextDecoder does not recognize the charset, fall back to utf-8
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return buffer.toString("utf8");
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=fetcher.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { extractArticle, isCachedConsentSnippet } from "./extractor.js";
|
|
2
|
+
import { hasUserCapability } from "../auth/capabilities.js";
|
|
3
|
+
import { db } from "../db/index.js";
|
|
4
|
+
import { improvesContent } from "./articleContent.js";
|
|
5
|
+
import { canAccessItem, findItemById, findSubscriptionFullTextMode, updateItemFullContent } from "./repository.js";
|
|
6
|
+
// Kept for integrations importing the old constant; retries now belong to individual articles.
|
|
7
|
+
export const FULL_TEXT_BLOCK_THRESHOLD = 2;
|
|
8
|
+
const inFlight = new Map();
|
|
9
|
+
const hosts = new Set();
|
|
10
|
+
let active = 0;
|
|
11
|
+
const waiters = [];
|
|
12
|
+
async function acquire(host) {
|
|
13
|
+
while (active >= 2 || hosts.has(host))
|
|
14
|
+
await new Promise(resolve => waiters.push(resolve));
|
|
15
|
+
active++;
|
|
16
|
+
hosts.add(host);
|
|
17
|
+
return () => { active--; hosts.delete(host); waiters.splice(0).forEach(resolve => resolve()); };
|
|
18
|
+
}
|
|
19
|
+
function recordAttempt(id, status) {
|
|
20
|
+
const blocked = ["paywall", "consent_wall"].includes(status);
|
|
21
|
+
const retry = status === "ready" || status === "feed_only" ? null :
|
|
22
|
+
new Date(Date.now() + (blocked ? 7 * 86400_000 : status === "partial" ? 86400_000 : 3600_000)).toISOString();
|
|
23
|
+
db.prepare("UPDATE items SET extraction_status = ?, extraction_attempted_at = ?, extraction_retry_at = ? WHERE id = ?")
|
|
24
|
+
.run(status, new Date().toISOString(), retry, id);
|
|
25
|
+
}
|
|
26
|
+
/** All callers share bounded host-aware extraction, cache quality and per-article retry policy. */
|
|
27
|
+
export async function loadFullText(userId, itemId, options = {}) {
|
|
28
|
+
const item = findItemById(itemId);
|
|
29
|
+
const mode = item ? findSubscriptionFullTextMode(userId, item.feed_id) ?? (canAccessItem(userId, itemId) ? "auto" : undefined) : undefined;
|
|
30
|
+
if (!item || !mode)
|
|
31
|
+
return { ok: false, error: "item_not_found" };
|
|
32
|
+
if (item.full_content_html && isCachedConsentSnippet(item.full_content_html)) {
|
|
33
|
+
// Repair cached walls before any caller can mistake them for reader content again.
|
|
34
|
+
db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(itemId);
|
|
35
|
+
item.full_content_html = null;
|
|
36
|
+
}
|
|
37
|
+
if (!options.force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
|
|
38
|
+
return { ok: true, id: item.id, fullContentHtml: item.full_content_html, cached: true };
|
|
39
|
+
}
|
|
40
|
+
if (!item.link)
|
|
41
|
+
return { ok: false, error: "item_has_no_link" };
|
|
42
|
+
if (mode === "never")
|
|
43
|
+
return { ok: false, error: "full_text_disabled" };
|
|
44
|
+
if (!options.force && item.extraction_status === "feed_only")
|
|
45
|
+
return { ok: true, id: itemId, fullContentHtml: item.content_html ?? "", cached: true };
|
|
46
|
+
if (!options.force && item.extraction_retry_at && Date.parse(item.extraction_retry_at) > Date.now()) {
|
|
47
|
+
const status = item.extraction_status;
|
|
48
|
+
return { ok: false, error: ["consent_wall", "paywall", "bot_blocked", "timeout", "partial"].includes(status) ? status : "could_not_extract_content" };
|
|
49
|
+
}
|
|
50
|
+
if (!hasUserCapability(userId, "fulltext"))
|
|
51
|
+
return { ok: false, error: "capability_not_available" };
|
|
52
|
+
if (inFlight.has(itemId))
|
|
53
|
+
return inFlight.get(itemId);
|
|
54
|
+
const task = (async () => {
|
|
55
|
+
let release;
|
|
56
|
+
try {
|
|
57
|
+
release = await acquire(new URL(item.link).host);
|
|
58
|
+
if (!hasUserCapability(userId, "fulltext") || !canAccessItem(userId, itemId))
|
|
59
|
+
return { ok: false, error: "capability_not_available" };
|
|
60
|
+
const result = await extractArticle(item.link);
|
|
61
|
+
if (result.status !== "ok") {
|
|
62
|
+
recordAttempt(itemId, result.status);
|
|
63
|
+
return { ok: false, error: result.status === "failed" ? "could_not_extract_content" : result.status };
|
|
64
|
+
}
|
|
65
|
+
// Content may have improved while this request was running.
|
|
66
|
+
const current = findItemById(itemId);
|
|
67
|
+
if (!current)
|
|
68
|
+
return { ok: false, error: "item_not_found" };
|
|
69
|
+
if (current.link !== item.link)
|
|
70
|
+
return { ok: false, error: "extraction_failed" };
|
|
71
|
+
if (improvesContent(current, result.article.contentHtml)) {
|
|
72
|
+
db.transaction(() => { updateItemFullContent(itemId, result.article.contentHtml); recordAttempt(itemId, "ready"); })();
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
recordAttempt(itemId, current.full_content_html ? "ready" : "feed_only");
|
|
76
|
+
return { ok: true, id: itemId, fullContentHtml: findItemById(itemId).full_content_html || current.content_html || "",
|
|
77
|
+
title: result.article.title, byline: result.article.byline, cached: false };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
recordAttempt(itemId, "failed");
|
|
81
|
+
return { ok: false, error: "extraction_failed" };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
release?.();
|
|
85
|
+
inFlight.delete(itemId);
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
inFlight.set(itemId, task);
|
|
89
|
+
return task;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=fullText.js.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { isCachedConsentSnippet } from "./extractor.js";
|
|
2
|
+
import { db } from "../db/index.js";
|
|
3
|
+
import { hasUserCapability } from "../auth/capabilities.js";
|
|
4
|
+
import { loadFullText } from "./fullText.js";
|
|
5
|
+
import { canAccessItem, findSubscriptionFullTextMode, findItemById } from "./repository.js";
|
|
6
|
+
/** Refillable durable queue, bounded independently of library size. */
|
|
7
|
+
export function scheduleFullTexts() {
|
|
8
|
+
const queued = db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get();
|
|
9
|
+
const room = Math.max(0, 200 - queued.n);
|
|
10
|
+
if (!room)
|
|
11
|
+
return 0;
|
|
12
|
+
const eligible = db.prepare("SELECT id FROM users").all().filter(user => hasUserCapability(user.id, "fulltext")).map(user => user.id);
|
|
13
|
+
if (!eligible.length)
|
|
14
|
+
return 0;
|
|
15
|
+
const candidates = db.prepare(`
|
|
16
|
+
WITH candidate_articles AS (
|
|
17
|
+
SELECT ids.value AS item_id, e.user_id, 3 AS priority, i.created_at
|
|
18
|
+
FROM editions e, json_each(e.item_ids) ids
|
|
19
|
+
JOIN items i ON i.id = ids.value
|
|
20
|
+
WHERE e.user_id IN (SELECT value FROM json_each(?))
|
|
21
|
+
AND e.status = 'active' AND e.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
22
|
+
AND i.link IS NOT NULL AND i.full_content_html IS NULL
|
|
23
|
+
AND i.extraction_status != 'feed_only'
|
|
24
|
+
AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
25
|
+
AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
|
|
26
|
+
|
|
27
|
+
UNION ALL
|
|
28
|
+
|
|
29
|
+
SELECT b.item_id, b.user_id, 2 AS priority, i.created_at
|
|
30
|
+
FROM item_bookmarks b
|
|
31
|
+
JOIN items i ON i.id = b.item_id
|
|
32
|
+
WHERE b.user_id IN (SELECT value FROM json_each(?))
|
|
33
|
+
AND i.link IS NOT NULL AND i.full_content_html IS NULL
|
|
34
|
+
AND i.extraction_status != 'feed_only'
|
|
35
|
+
AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
36
|
+
AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
|
|
37
|
+
|
|
38
|
+
UNION ALL
|
|
39
|
+
|
|
40
|
+
SELECT n.item_id, n.user_id, 2 AS priority, i.created_at
|
|
41
|
+
FROM item_notes n
|
|
42
|
+
JOIN items i ON i.id = n.item_id
|
|
43
|
+
WHERE n.user_id IN (SELECT value FROM json_each(?))
|
|
44
|
+
AND i.link IS NOT NULL AND i.full_content_html IS NULL
|
|
45
|
+
AND i.extraction_status != 'feed_only'
|
|
46
|
+
AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
47
|
+
AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
|
|
48
|
+
|
|
49
|
+
UNION ALL
|
|
50
|
+
|
|
51
|
+
SELECT i.id AS item_id, s.user_id, 1 AS priority, i.created_at
|
|
52
|
+
FROM subscriptions s
|
|
53
|
+
JOIN items i ON i.feed_id = s.feed_id
|
|
54
|
+
WHERE s.user_id IN (SELECT value FROM json_each(?))
|
|
55
|
+
AND COALESCE(s.full_text_mode, 'auto') = 'auto'
|
|
56
|
+
AND i.link IS NOT NULL AND i.full_content_html IS NULL
|
|
57
|
+
AND i.extraction_status != 'feed_only'
|
|
58
|
+
AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
59
|
+
AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
|
|
60
|
+
)
|
|
61
|
+
SELECT item_id AS id, user_id, MAX(priority) AS priority
|
|
62
|
+
FROM candidate_articles
|
|
63
|
+
GROUP BY item_id
|
|
64
|
+
ORDER BY priority DESC, created_at DESC LIMIT ?`).all(JSON.stringify(eligible), JSON.stringify(eligible), JSON.stringify(eligible), JSON.stringify(eligible), room);
|
|
65
|
+
const add = db.prepare("INSERT OR IGNORE INTO full_text_jobs(item_id, user_id, priority) VALUES (?, ?, ?)");
|
|
66
|
+
let count = 0;
|
|
67
|
+
db.transaction(() => {
|
|
68
|
+
for (const row of candidates)
|
|
69
|
+
if (hasUserCapability(row.user_id, "fulltext"))
|
|
70
|
+
count += add.run(row.id, row.user_id, row.priority).changes;
|
|
71
|
+
})();
|
|
72
|
+
return count;
|
|
73
|
+
}
|
|
74
|
+
/** Explicit priority requests never run publisher I/O inside an HTTP batch request. */
|
|
75
|
+
export function enqueueArticleContents(userId, ids) {
|
|
76
|
+
if (ids.length > 50 || ids.some(id => !Number.isSafeInteger(id) || id <= 0))
|
|
77
|
+
throw new Error("invalid_source_selection");
|
|
78
|
+
if (!hasUserCapability(userId, "fulltext"))
|
|
79
|
+
throw new Error("capability_not_available");
|
|
80
|
+
const unique = [...new Set(ids)];
|
|
81
|
+
if (unique.some(id => !canAccessItem(userId, id)))
|
|
82
|
+
throw new Error("item_not_found");
|
|
83
|
+
return db.transaction(() => {
|
|
84
|
+
const count = db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get();
|
|
85
|
+
if (count.n + unique.length > 1000)
|
|
86
|
+
throw new Error("queue_full");
|
|
87
|
+
const add = db.prepare("INSERT INTO full_text_jobs(item_id,user_id,priority) VALUES (?,?,4) ON CONFLICT(item_id) DO UPDATE SET priority = MAX(priority,4), user_id = excluded.user_id");
|
|
88
|
+
const queued = [];
|
|
89
|
+
for (const id of unique) {
|
|
90
|
+
const item = findItemById(id);
|
|
91
|
+
if (item.full_content_html || item.extraction_status === "feed_only" || findSubscriptionFullTextMode(userId, item.feed_id) === "never")
|
|
92
|
+
continue;
|
|
93
|
+
if (item.extraction_retry_at && Date.parse(item.extraction_retry_at) > Date.now())
|
|
94
|
+
continue;
|
|
95
|
+
add.run(id, userId);
|
|
96
|
+
queued.push(id);
|
|
97
|
+
}
|
|
98
|
+
return queued;
|
|
99
|
+
})();
|
|
100
|
+
}
|
|
101
|
+
/** Synchronous consumers can prepare selected articles without waiting for the worker. */
|
|
102
|
+
export async function prepareArticleContents(userId, ids) {
|
|
103
|
+
if (ids.length > 50 || ids.some(id => !Number.isSafeInteger(id) || id <= 0))
|
|
104
|
+
throw new Error("invalid_source_selection");
|
|
105
|
+
const unique = [...new Set(ids)];
|
|
106
|
+
if (unique.some(id => !canAccessItem(userId, id)))
|
|
107
|
+
throw new Error("item_not_found");
|
|
108
|
+
if (!hasUserCapability(userId, "fulltext"))
|
|
109
|
+
throw new Error("capability_not_available");
|
|
110
|
+
const results = [];
|
|
111
|
+
// Avoid queueing an unbounded number of suspended host slots.
|
|
112
|
+
for (let start = 0; start < unique.length; start += 2) {
|
|
113
|
+
results.push(...await Promise.all(unique.slice(start, start + 2).map(async (id) => {
|
|
114
|
+
if (!hasUserCapability(userId, "fulltext"))
|
|
115
|
+
throw new Error("capability_not_available");
|
|
116
|
+
const result = await loadFullText(userId, id);
|
|
117
|
+
return { id, ok: result.ok, ...(!result.ok ? { error: result.error } : {}) };
|
|
118
|
+
})));
|
|
119
|
+
}
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
let running = false;
|
|
123
|
+
export async function runFullTextQueue() {
|
|
124
|
+
if (running)
|
|
125
|
+
return;
|
|
126
|
+
running = true;
|
|
127
|
+
try {
|
|
128
|
+
const queued = db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get();
|
|
129
|
+
if (queued.n < 200)
|
|
130
|
+
scheduleFullTexts();
|
|
131
|
+
const jobs = db.prepare("SELECT * FROM full_text_jobs WHERE available_at <= ? ORDER BY priority DESC, available_at LIMIT 1").all(new Date().toISOString());
|
|
132
|
+
await Promise.all(jobs.map(async (job) => {
|
|
133
|
+
const item = findItemById(job.item_id);
|
|
134
|
+
if (item && hasUserCapability(job.user_id, "fulltext") && canAccessItem(job.user_id, job.item_id) && findSubscriptionFullTextMode(job.user_id, item.feed_id) !== "never") {
|
|
135
|
+
await loadFullText(job.user_id, job.item_id);
|
|
136
|
+
}
|
|
137
|
+
db.prepare("DELETE FROM full_text_jobs WHERE item_id = ?").run(job.item_id);
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
running = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function startFullTextScheduler() {
|
|
145
|
+
const tick = () => { void runFullTextQueue().catch(() => console.error("[fulltext] queue failed")); };
|
|
146
|
+
// One-time startup repair of old consent-only caches, without contacting publishers.
|
|
147
|
+
const cached = db.prepare("SELECT id, full_content_html FROM items WHERE full_content_html IS NOT NULL AND (lower(full_content_html) LIKE '%cookie%' OR lower(full_content_html) LIKE '%consent%') LIMIT 100").all();
|
|
148
|
+
db.transaction(() => {
|
|
149
|
+
for (const item of cached)
|
|
150
|
+
if (isCachedConsentSnippet(item.full_content_html)) {
|
|
151
|
+
db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(item.id);
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
tick();
|
|
155
|
+
const timer = setInterval(tick, 15000);
|
|
156
|
+
timer.unref();
|
|
157
|
+
return timer;
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=fullTextQueue.js.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { fetchFeed } from "./fetcher.js";
|
|
2
|
+
import { decodeEntities } from "./text.js";
|
|
3
|
+
const ICON_RECHECK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
function attribute(tag, name) {
|
|
5
|
+
const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i"));
|
|
6
|
+
return match ? (match[1] ?? match[2] ?? match[3] ?? null) : null;
|
|
7
|
+
}
|
|
8
|
+
function headLinks(html) {
|
|
9
|
+
const end = html.search(/<\/head>/i);
|
|
10
|
+
return html.slice(0, end === -1 ? 200_000 : Math.min(end, 200_000)).match(/<link\b[^>]*>/gi) ?? [];
|
|
11
|
+
}
|
|
12
|
+
function httpUrl(href, baseUrl) {
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(decodeEntities(href), baseUrl);
|
|
15
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function candidate(href, baseUrl, sizes, type, touch = false) {
|
|
22
|
+
const url = httpUrl(href, baseUrl);
|
|
23
|
+
if (!url)
|
|
24
|
+
return null;
|
|
25
|
+
// Use the shortest edge of every declared size; a wide banner is not a large square icon.
|
|
26
|
+
const dimensions = [...sizes.matchAll(/(\d+)x(\d+)/gi)].map((match) => Math.min(Number(match[1]), Number(match[2])));
|
|
27
|
+
return {
|
|
28
|
+
url,
|
|
29
|
+
vector: type.toLowerCase() === "image/svg+xml" || /\.svg$/i.test(new URL(url).pathname),
|
|
30
|
+
size: Math.max(0, ...dimensions) || (touch ? 180 : 0),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function rankedUrls(candidates) {
|
|
34
|
+
candidates.sort((a, b) => Number(b.vector) - Number(a.vector) || b.size - a.size);
|
|
35
|
+
return [...new Set(candidates.map((icon) => icon.url))].slice(0, 16);
|
|
36
|
+
}
|
|
37
|
+
function declaredCandidates(html, baseUrl) {
|
|
38
|
+
const candidates = [];
|
|
39
|
+
for (const tag of headLinks(html)) {
|
|
40
|
+
const rel = attribute(tag, "rel")?.toLowerCase().split(/\s+/) ?? [];
|
|
41
|
+
const href = attribute(tag, "href");
|
|
42
|
+
if (!href || rel.includes("mask-icon"))
|
|
43
|
+
continue;
|
|
44
|
+
const touch = rel.includes("apple-touch-icon") || rel.includes("apple-touch-icon-precomposed");
|
|
45
|
+
if (!touch && !rel.includes("icon"))
|
|
46
|
+
continue;
|
|
47
|
+
const icon = candidate(href, baseUrl, attribute(tag, "sizes") ?? "", attribute(tag, "type") ?? "", touch);
|
|
48
|
+
if (icon)
|
|
49
|
+
candidates.push(icon);
|
|
50
|
+
}
|
|
51
|
+
return candidates;
|
|
52
|
+
}
|
|
53
|
+
/** Prefer scalable artwork, then the largest declared raster icons, including touch icons. */
|
|
54
|
+
export function pickIconUrls(html, baseUrl) {
|
|
55
|
+
return rankedUrls(declaredCandidates(html, baseUrl));
|
|
56
|
+
}
|
|
57
|
+
export function pickIconUrl(html, baseUrl) {
|
|
58
|
+
return pickIconUrls(html, baseUrl)[0] ?? null;
|
|
59
|
+
}
|
|
60
|
+
export function iconCheckDue(checkedAt) {
|
|
61
|
+
return !checkedAt || Date.now() - Date.parse(checkedAt) > ICON_RECHECK_MS;
|
|
62
|
+
}
|
|
63
|
+
/** Include a linked web-app manifest, which often contains the site's highest-resolution icons. */
|
|
64
|
+
export async function discoverIconUrls(siteUrl, signal) {
|
|
65
|
+
try {
|
|
66
|
+
const page = await fetchFeed(siteUrl, { signal });
|
|
67
|
+
const candidates = declaredCandidates(page.body, page.finalUrl);
|
|
68
|
+
const manifestTag = headLinks(page.body).find((tag) => attribute(tag, "rel")?.toLowerCase().split(/\s+/).includes("manifest"));
|
|
69
|
+
const href = manifestTag && attribute(manifestTag, "href");
|
|
70
|
+
const manifestUrl = href && httpUrl(href, page.finalUrl);
|
|
71
|
+
if (manifestUrl) {
|
|
72
|
+
try {
|
|
73
|
+
const manifest = await fetchFeed(manifestUrl, { signal });
|
|
74
|
+
const data = JSON.parse(manifest.body);
|
|
75
|
+
const icons = data && typeof data === "object" && "icons" in data ? data.icons : null;
|
|
76
|
+
for (const entry of Array.isArray(icons) ? icons.slice(0, 32) : []) {
|
|
77
|
+
if (!entry || typeof entry !== "object" || typeof entry.src !== "string")
|
|
78
|
+
continue;
|
|
79
|
+
const purpose = typeof entry.purpose === "string" ? entry.purpose.split(/\s+/) : ["any"];
|
|
80
|
+
if (!purpose.includes("any") && !purpose.includes("maskable"))
|
|
81
|
+
continue;
|
|
82
|
+
const icon = candidate(entry.src, manifest.finalUrl, typeof entry.sizes === "string" ? entry.sizes : "", typeof entry.type === "string" ? entry.type : "");
|
|
83
|
+
if (icon)
|
|
84
|
+
candidates.push(icon);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// A missing or invalid manifest must not discard the page's own icons.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return rankedUrls(candidates);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=icon.js.map
|