feedkeeper 0.23.1 → 0.25.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.
- package/package.json +1 -1
- package/server/dist/api/imageResponse.js +15 -0
- package/server/dist/api/items.js +3 -3
- package/server/dist/api/v1/resources.js +2 -2
- package/server/dist/db/migrations/0033_full_text_jobs.sql +5 -0
- package/server/dist/feeds/archive.js +1 -1
- package/server/dist/feeds/articleWorkPool.js +147 -0
- package/server/dist/feeds/extractor.js +33 -9
- package/server/dist/feeds/fullText.js +57 -50
- package/server/dist/feeds/fullTextQueue.js +235 -32
- package/server/dist/feeds/imageCache.js +2 -1
- package/server/dist/feeds/inbox.js +8 -3
- package/server/dist/feeds/poller.js +2 -0
- package/server/dist/feeds/repository.js +5 -2
- package/server/dist/securityHeaders.js +20 -0
- package/server/dist/server.js +2 -9
- package/server/package.json +1 -1
- package/web/dist/assets/index-BprbGvrl.css +2 -0
- package/web/dist/assets/index-CqwqZaF0.js +32 -0
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +30 -2
- package/web/dist/assets/index-B4e2oqbo.css +0 -2
- package/web/dist/assets/index-xx6TyOwB.js +0 -32
package/package.json
CHANGED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Mutable proxy images revalidate; archive routes keep their separate immutable policy. */
|
|
3
|
+
export function sendArticleImage(req, res, image) {
|
|
4
|
+
const age = Math.max(0, Math.min(21600, Math.floor((image.cacheTtlMs ?? 21_600_000) / 1000)));
|
|
5
|
+
const etag = `"${createHash("sha256").update(image.buffer).digest("hex")}"`;
|
|
6
|
+
res.setHeader("Cache-Control", age ? `private, max-age=${age}` : "private, no-store");
|
|
7
|
+
res.setHeader("ETag", etag);
|
|
8
|
+
const matches = req.headers["if-none-match"]?.split(",").some(value => value.trim().replace(/^W\//, "") === etag || value.trim() === "*");
|
|
9
|
+
if (matches) {
|
|
10
|
+
res.status(304).end();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
res.type(image.mime).send(image.buffer);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=imageResponse.js.map
|
package/server/dist/api/items.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sendArticleImage } from "./imageResponse.js";
|
|
1
2
|
import { Router } from "express";
|
|
2
3
|
import rateLimit from "express-rate-limit";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -13,7 +14,7 @@ export const FULL_TEXT_STATUS = {
|
|
|
13
14
|
full_text_disabled: 409,
|
|
14
15
|
consent_wall: 422,
|
|
15
16
|
could_not_extract_content: 422,
|
|
16
|
-
extraction_failed: 502,
|
|
17
|
+
extraction_failed: 502, queue_full: 503, source_busy: 503,
|
|
17
18
|
};
|
|
18
19
|
export const itemsRouter = Router();
|
|
19
20
|
// The origin the browser used (honours TRUST_PROXY), so archive URLs work behind proxies and in development.
|
|
@@ -62,8 +63,7 @@ itemsRouter.get("/:itemId/image", imageLimiter, async (req, res) => {
|
|
|
62
63
|
res.status(415).json({ error: "unsupported_image" });
|
|
63
64
|
}
|
|
64
65
|
else {
|
|
65
|
-
|
|
66
|
-
res.type(image.mime).send(image.buffer);
|
|
66
|
+
sendArticleImage(req, res, image);
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
catch (error) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sendArticleImage } from "../imageResponse.js";
|
|
1
2
|
import { enqueueArticleContents } from "../../feeds/fullTextQueue.js";
|
|
2
3
|
import { Router } from "express";
|
|
3
4
|
import rateLimit from "express-rate-limit";
|
|
@@ -628,8 +629,7 @@ resourcesRouter.get("/items/:id/image", imageLimiter, async (req, res) => {
|
|
|
628
629
|
return void res.status(404).json({ error: "not_found" });
|
|
629
630
|
if (image === "unsupported")
|
|
630
631
|
return void res.status(415).json({ error: "unsupported_image" });
|
|
631
|
-
|
|
632
|
-
res.type(image.mime).send(image.buffer);
|
|
632
|
+
sendArticleImage(req, res, image);
|
|
633
633
|
}
|
|
634
634
|
catch (error) {
|
|
635
635
|
if (error instanceof ImageCacheBusyError) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
ALTER TABLE full_text_jobs ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
|
|
2
|
+
UPDATE full_text_jobs SET created_at = available_at;
|
|
3
|
+
ALTER TABLE full_text_jobs ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
|
4
|
+
ALTER TABLE full_text_jobs ADD COLUMN force INTEGER NOT NULL DEFAULT 0;
|
|
5
|
+
CREATE TABLE full_text_hosts (host TEXT PRIMARY KEY, retry_at TEXT NOT NULL);
|
|
@@ -297,7 +297,7 @@ export function itemImageUrls(item) {
|
|
|
297
297
|
return [...new Set([...(hero ? [hero] : []), ...articleImageUrls(item.full_content_html ?? "", item.link), ...articleImageUrls(item.content_html ?? "", item.link)])];
|
|
298
298
|
}
|
|
299
299
|
function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
|
|
300
|
-
return `${origin.replace(/\/$/, "")}${apiBase}/items/${itemId}/image?src=${encodeURIComponent(imageUrl).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
300
|
+
return `${origin.replace(/\/$/, "")}${apiBase}/items/${itemId}/image?src=${encodeURIComponent(imageUrl).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}&v=2`;
|
|
301
301
|
}
|
|
302
302
|
/**
|
|
303
303
|
* For offline copies: point the first images of articles that are not archived at an endpoint
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export class ArticleWorkBusyError extends Error {
|
|
2
|
+
constructor() { super("article_work_busy"); }
|
|
3
|
+
}
|
|
4
|
+
export class ArticleAccessDeniedError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export class ArticleHostBusyError extends Error {
|
|
7
|
+
host;
|
|
8
|
+
retryAt;
|
|
9
|
+
constructor(host, retryAt) {
|
|
10
|
+
super("article_host_busy");
|
|
11
|
+
this.host = host;
|
|
12
|
+
this.retryAt = retryAt;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function readArticleWorkLimits(env = process.env) {
|
|
16
|
+
const integer = (name, fallback, min, max) => {
|
|
17
|
+
const value = Number(env[name] ?? fallback);
|
|
18
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
19
|
+
throw new Error(`${name} must be between ${min} and ${max}`);
|
|
20
|
+
return value;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
concurrency: integer("FULLTEXT_CONCURRENCY", 2, 1, 8),
|
|
24
|
+
hostConcurrency: integer("FULLTEXT_HOST_CONCURRENCY", 1, 1, 4),
|
|
25
|
+
maxWaiting: integer("FULLTEXT_MAX_WAITING", 32, 0, 128),
|
|
26
|
+
timeoutMs: integer("FULLTEXT_TIMEOUT_MS", 20_000, 50, 60_000),
|
|
27
|
+
agingMs: integer("FULLTEXT_AGING_MS", 60_000, 50, 600_000),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** A bounded admission pool shared by saved links and stored-article extraction. */
|
|
31
|
+
export class ArticleWorkPool {
|
|
32
|
+
limits;
|
|
33
|
+
now;
|
|
34
|
+
waiting = [];
|
|
35
|
+
active = new Map();
|
|
36
|
+
deadlines = new Map();
|
|
37
|
+
closing = false;
|
|
38
|
+
completed = 0;
|
|
39
|
+
failed = 0;
|
|
40
|
+
durationMs = 0;
|
|
41
|
+
drained = [];
|
|
42
|
+
constructor(limits, now = Date.now) {
|
|
43
|
+
this.limits = limits;
|
|
44
|
+
this.now = now;
|
|
45
|
+
}
|
|
46
|
+
cooldown = () => 0;
|
|
47
|
+
get isClosing() { return this.closing; }
|
|
48
|
+
metrics() { return { running: this.active.size, waiting: this.waiting.length, completed: this.completed, failed: this.failed, durationMs: this.durationMs }; }
|
|
49
|
+
canStart(host) {
|
|
50
|
+
return !this.closing && this.cooldown(host) <= this.now() && this.active.size < this.limits.concurrency && [...this.active.values()].filter(value => value === host).length < this.limits.hostConcurrency;
|
|
51
|
+
}
|
|
52
|
+
move(signal, host) {
|
|
53
|
+
const until = this.cooldown(host);
|
|
54
|
+
if (until > this.now())
|
|
55
|
+
throw new ArticleHostBusyError(host, until);
|
|
56
|
+
const controller = [...this.active.keys()].find(entry => entry.signal === signal);
|
|
57
|
+
if (!controller || this.active.get(controller) === host)
|
|
58
|
+
return;
|
|
59
|
+
const occupied = [...this.active.entries()].filter(([, current]) => current === host);
|
|
60
|
+
if (occupied.length >= this.limits.hostConcurrency) {
|
|
61
|
+
throw new ArticleHostBusyError(host, Math.min(this.now() + 1000, ...occupied.map(([entry]) => this.deadlines.get(entry))));
|
|
62
|
+
}
|
|
63
|
+
this.active.set(controller, host);
|
|
64
|
+
}
|
|
65
|
+
run(host, work, priority = 5) {
|
|
66
|
+
const until = this.cooldown(host);
|
|
67
|
+
if (until > this.now())
|
|
68
|
+
return Promise.reject(new ArticleHostBusyError(host, until));
|
|
69
|
+
if (this.closing || (!this.canStart(host) && this.waiting.length >= this.limits.maxWaiting))
|
|
70
|
+
return Promise.reject(new ArticleWorkBusyError());
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const added = this.now();
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
controller.abort(new DOMException("Article deadline exceeded", "TimeoutError"));
|
|
76
|
+
const index = this.waiting.indexOf(job);
|
|
77
|
+
if (index >= 0) {
|
|
78
|
+
this.waiting.splice(index, 1);
|
|
79
|
+
reject(controller.signal.reason);
|
|
80
|
+
}
|
|
81
|
+
}, this.limits.timeoutMs);
|
|
82
|
+
const job = { host, priority, added, timer, controller, reject, start: () => {
|
|
83
|
+
this.active.set(controller, host);
|
|
84
|
+
this.deadlines.set(controller, added + this.limits.timeoutMs);
|
|
85
|
+
const finish = () => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
this.active.delete(controller);
|
|
88
|
+
this.deadlines.delete(controller);
|
|
89
|
+
this.completed++;
|
|
90
|
+
this.durationMs += this.now() - added;
|
|
91
|
+
this.pump();
|
|
92
|
+
if (!this.active.size)
|
|
93
|
+
this.drained.splice(0).forEach(done => done());
|
|
94
|
+
};
|
|
95
|
+
void Promise.resolve().then(() => work(controller.signal)).then(value => {
|
|
96
|
+
if (this.now() - added >= this.limits.timeoutMs) {
|
|
97
|
+
this.failed++;
|
|
98
|
+
finish();
|
|
99
|
+
reject(new DOMException("Article deadline exceeded", "TimeoutError"));
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
finish();
|
|
103
|
+
resolve(value);
|
|
104
|
+
}
|
|
105
|
+
}, error => { this.failed++; finish(); reject(error); });
|
|
106
|
+
} };
|
|
107
|
+
this.waiting.push(job);
|
|
108
|
+
this.pump();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
pump() {
|
|
112
|
+
this.waiting.sort((a, b) => (b.priority + Math.floor((this.now() - b.added) / this.limits.agingMs)) -
|
|
113
|
+
(a.priority + Math.floor((this.now() - a.added) / this.limits.agingMs)) || a.added - b.added);
|
|
114
|
+
for (let index = 0; index < this.waiting.length;) {
|
|
115
|
+
const job = this.waiting[index];
|
|
116
|
+
const until = this.cooldown(job.host);
|
|
117
|
+
if (until > this.now()) {
|
|
118
|
+
this.waiting.splice(index, 1);
|
|
119
|
+
clearTimeout(job.timer);
|
|
120
|
+
job.reject(new ArticleHostBusyError(job.host, until));
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!this.canStart(job.host)) {
|
|
124
|
+
index++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
this.waiting.splice(index, 1);
|
|
128
|
+
job.start();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async close() {
|
|
132
|
+
this.closing = true;
|
|
133
|
+
for (const job of this.waiting.splice(0)) {
|
|
134
|
+
clearTimeout(job.timer);
|
|
135
|
+
job.reject(new ArticleWorkBusyError());
|
|
136
|
+
}
|
|
137
|
+
for (const controller of this.active.keys())
|
|
138
|
+
controller.abort();
|
|
139
|
+
if (this.active.size)
|
|
140
|
+
await new Promise(resolve => this.drained.push(resolve));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let shared;
|
|
144
|
+
export function articleWorkPool() { return shared ??= new ArticleWorkPool(readArticleWorkLimits()); }
|
|
145
|
+
export function restartArticleWorkPool() { if (shared?.isClosing)
|
|
146
|
+
shared = undefined; }
|
|
147
|
+
//# sourceMappingURL=articleWorkPool.js.map
|
|
@@ -2,6 +2,9 @@ import { Readability } from "@mozilla/readability";
|
|
|
2
2
|
import { JSDOM } from "jsdom";
|
|
3
3
|
import { fetch as undiciFetch } from "undici";
|
|
4
4
|
import { assertPublicHttpUrl, createPublicDispatcher, SsrfBlockedError } from "./ssrfGuard.js";
|
|
5
|
+
import { articleWorkPool, ArticleHostBusyError, ArticleAccessDeniedError } from "./articleWorkPool.js";
|
|
6
|
+
import { parseRetryAfter } from "./fetcher.js";
|
|
7
|
+
import { sourceHost } from "./requestGate.js";
|
|
5
8
|
import { APP_VERSION } from "../version.js";
|
|
6
9
|
const defaultFetch = undiciFetch;
|
|
7
10
|
const CONSENT_SELECTORS = [
|
|
@@ -79,9 +82,13 @@ const FETCH_TIMEOUT_MS = 12_000;
|
|
|
79
82
|
const MAX_REDIRECTS = 5;
|
|
80
83
|
class ArticleHttpError extends Error {
|
|
81
84
|
status;
|
|
82
|
-
|
|
85
|
+
retryAt;
|
|
86
|
+
host;
|
|
87
|
+
constructor(status, retryAt, host) {
|
|
83
88
|
super("article_http_error");
|
|
84
89
|
this.status = status;
|
|
90
|
+
this.retryAt = retryAt;
|
|
91
|
+
this.host = host;
|
|
85
92
|
}
|
|
86
93
|
}
|
|
87
94
|
const MAX_HTML_BYTES = 2 * 1024 * 1024;
|
|
@@ -93,10 +100,12 @@ function stripHeavyTags(html) {
|
|
|
93
100
|
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, "")
|
|
94
101
|
.replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, "");
|
|
95
102
|
}
|
|
96
|
-
async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl) {
|
|
103
|
+
async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSignal) {
|
|
97
104
|
let currentUrl = rawUrl;
|
|
98
105
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
106
|
+
articleWorkPool().move(ownerSignal, sourceHost(currentUrl));
|
|
99
107
|
const safeUrl = await assertPublicHttpUrl(currentUrl);
|
|
108
|
+
signal.throwIfAborted();
|
|
100
109
|
const response = await fetchImpl(safeUrl, {
|
|
101
110
|
signal,
|
|
102
111
|
headers: {
|
|
@@ -111,19 +120,21 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl) {
|
|
|
111
120
|
});
|
|
112
121
|
if (response.status >= 300 && response.status < 400) {
|
|
113
122
|
const location = response.headers.get("location");
|
|
123
|
+
await response.body?.cancel();
|
|
114
124
|
if (!location)
|
|
115
125
|
return null;
|
|
116
|
-
await response.body?.cancel();
|
|
117
126
|
currentUrl = new URL(location, safeUrl).toString();
|
|
118
127
|
continue;
|
|
119
128
|
}
|
|
120
129
|
if (!response.ok) {
|
|
121
130
|
await response.body?.cancel();
|
|
122
|
-
throw new ArticleHttpError(response.status);
|
|
131
|
+
throw new ArticleHttpError(response.status, parseRetryAfter(response.headers.get("retry-after")), sourceHost(safeUrl.toString()));
|
|
123
132
|
}
|
|
124
133
|
const contentLength = Number(response.headers.get("content-length"));
|
|
125
|
-
if (contentLength > MAX_HTML_BYTES)
|
|
134
|
+
if (contentLength > MAX_HTML_BYTES) {
|
|
135
|
+
await response.body?.cancel();
|
|
126
136
|
return null;
|
|
137
|
+
}
|
|
127
138
|
const reader = response.body?.getReader();
|
|
128
139
|
if (!reader)
|
|
129
140
|
return null;
|
|
@@ -150,13 +161,22 @@ export async function extractArticleFromUrl(rawUrl, fetchImpl = defaultFetch) {
|
|
|
150
161
|
return result.status === "ok" ? result.article : null;
|
|
151
162
|
}
|
|
152
163
|
/** Like extractArticleFromUrl, but reports whether the site answered with a consent wall. */
|
|
153
|
-
export async function extractArticle(rawUrl, fetchImpl = defaultFetch) {
|
|
164
|
+
export async function extractArticle(rawUrl, fetchImpl = defaultFetch, priority = 5, authorized = () => true) {
|
|
165
|
+
return articleWorkPool().run(sourceHost(rawUrl), signal => {
|
|
166
|
+
if (!authorized())
|
|
167
|
+
throw new ArticleAccessDeniedError();
|
|
168
|
+
return extractArticleContent(rawUrl, fetchImpl, signal);
|
|
169
|
+
}, priority);
|
|
170
|
+
}
|
|
171
|
+
async function extractArticleContent(rawUrl, fetchImpl, signal) {
|
|
172
|
+
signal.throwIfAborted();
|
|
154
173
|
const safeUrl = await assertPublicHttpUrl(rawUrl);
|
|
174
|
+
signal.throwIfAborted();
|
|
155
175
|
const dispatcher = createPublicDispatcher();
|
|
156
176
|
const controller = new AbortController();
|
|
157
177
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
158
178
|
try {
|
|
159
|
-
const fetched = await fetchArticleHtml(safeUrl.toString(), controller.signal, dispatcher, fetchImpl);
|
|
179
|
+
const fetched = await fetchArticleHtml(safeUrl.toString(), AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
|
|
160
180
|
if (!fetched)
|
|
161
181
|
return { status: "failed" };
|
|
162
182
|
const cleanedHtml = stripHeavyTags(fetched.html);
|
|
@@ -190,13 +210,17 @@ export async function extractArticle(rawUrl, fetchImpl = defaultFetch) {
|
|
|
190
210
|
};
|
|
191
211
|
}
|
|
192
212
|
catch (error) {
|
|
213
|
+
if (error instanceof ArticleHostBusyError)
|
|
214
|
+
return { status: "throttled", host: error.host, retryAt: error.retryAt, deferred: true };
|
|
193
215
|
if (error instanceof SsrfBlockedError)
|
|
194
216
|
throw error;
|
|
195
|
-
if (controller.signal.aborted)
|
|
217
|
+
if (signal.aborted || controller.signal.aborted)
|
|
196
218
|
return { status: "timeout" };
|
|
197
219
|
if (error instanceof ArticleHttpError && [401, 402].includes(error.status))
|
|
198
220
|
return { status: "paywall" };
|
|
199
|
-
if (error instanceof ArticleHttpError && [
|
|
221
|
+
if (error instanceof ArticleHttpError && [429, 503].includes(error.status))
|
|
222
|
+
return { status: "throttled", retryAt: error.retryAt, host: error.host };
|
|
223
|
+
if (error instanceof ArticleHttpError && error.status === 403)
|
|
200
224
|
return { status: "bot_blocked" };
|
|
201
225
|
return { status: "failed" };
|
|
202
226
|
}
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
import { extractArticle, isCachedConsentSnippet } from "./extractor.js";
|
|
2
2
|
import { hasUserCapability } from "../auth/capabilities.js";
|
|
3
3
|
import { db } from "../db/index.js";
|
|
4
|
+
import { articleWorkPool, ArticleWorkBusyError, ArticleAccessDeniedError, ArticleHostBusyError } from "./articleWorkPool.js";
|
|
5
|
+
import { requestFullText, authorizedFullTextRequester } from "./fullTextQueue.js";
|
|
6
|
+
import { SsrfBlockedError } from "./ssrfGuard.js";
|
|
4
7
|
import { improvesContent } from "./articleContent.js";
|
|
5
8
|
import { canAccessItem, findItemById, findSubscriptionFullTextMode, updateItemFullContent } from "./repository.js";
|
|
6
9
|
// Kept for integrations importing the old constant; retries now belong to individual articles.
|
|
7
10
|
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
11
|
function recordAttempt(id, status) {
|
|
20
12
|
const blocked = ["paywall", "consent_wall"].includes(status);
|
|
21
13
|
const retry = status === "ready" || status === "feed_only" ? null :
|
|
@@ -24,7 +16,7 @@ function recordAttempt(id, status) {
|
|
|
24
16
|
.run(status, new Date().toISOString(), retry, id);
|
|
25
17
|
}
|
|
26
18
|
/** All callers share bounded host-aware extraction, cache quality and per-article retry policy. */
|
|
27
|
-
export
|
|
19
|
+
export function cachedFullText(userId, itemId, force = false) {
|
|
28
20
|
const item = findItemById(itemId);
|
|
29
21
|
const mode = item ? findSubscriptionFullTextMode(userId, item.feed_id) ?? (canAccessItem(userId, itemId) ? "auto" : undefined) : undefined;
|
|
30
22
|
if (!item || !mode)
|
|
@@ -34,58 +26,73 @@ export async function loadFullText(userId, itemId, options = {}) {
|
|
|
34
26
|
db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(itemId);
|
|
35
27
|
item.full_content_html = null;
|
|
36
28
|
}
|
|
37
|
-
if (!
|
|
29
|
+
if (!force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
|
|
38
30
|
return { ok: true, id: item.id, fullContentHtml: item.full_content_html, cached: true };
|
|
39
31
|
}
|
|
40
32
|
if (!item.link)
|
|
41
33
|
return { ok: false, error: "item_has_no_link" };
|
|
42
34
|
if (mode === "never")
|
|
43
35
|
return { ok: false, error: "full_text_disabled" };
|
|
44
|
-
if (!
|
|
36
|
+
if (!force && item.extraction_status === "feed_only")
|
|
45
37
|
return { ok: true, id: itemId, fullContentHtml: item.content_html ?? "", cached: true };
|
|
46
|
-
if (!
|
|
38
|
+
if (!force && item.extraction_retry_at && Date.parse(item.extraction_retry_at) > Date.now() && !db.prepare("SELECT 1 FROM full_text_jobs WHERE item_id=?").get(itemId)) {
|
|
47
39
|
const status = item.extraction_status;
|
|
48
40
|
return { ok: false, error: ["consent_wall", "paywall", "bot_blocked", "timeout", "partial"].includes(status) ? status : "could_not_extract_content" };
|
|
49
41
|
}
|
|
50
42
|
if (!hasUserCapability(userId, "fulltext"))
|
|
51
43
|
return { ok: false, error: "capability_not_available" };
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
title: result.article.title, byline: result.article.byline, cached: false };
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
export async function loadFullText(userId, itemId, options = {}) {
|
|
47
|
+
return cachedFullText(userId, itemId, options.force) ?? requestFullText(userId, itemId, options.force ?? false);
|
|
48
|
+
}
|
|
49
|
+
export async function executeFullText(userId, itemId, force, priority) {
|
|
50
|
+
const requester = authorizedFullTextRequester(itemId, userId);
|
|
51
|
+
if (requester === undefined)
|
|
52
|
+
return { ok: false, error: "capability_not_available" };
|
|
53
|
+
const cached = cachedFullText(requester, itemId, force);
|
|
54
|
+
if (cached)
|
|
55
|
+
return cached;
|
|
56
|
+
const item = findItemById(itemId);
|
|
57
|
+
try {
|
|
58
|
+
const result = await extractArticle(item.link, undefined, priority, () => authorizedFullTextRequester(itemId, userId) !== undefined);
|
|
59
|
+
if (articleWorkPool().isClosing)
|
|
60
|
+
return { ok: false, error: "queue_full" };
|
|
61
|
+
if (authorizedFullTextRequester(itemId, userId) === undefined)
|
|
62
|
+
return { ok: false, error: "capability_not_available" };
|
|
63
|
+
if (result.status === "throttled")
|
|
64
|
+
return { ok: false, error: "source_busy", retryable: true, retryAt: result.retryAt, host: result.host, deferred: result.deferred };
|
|
65
|
+
if (result.status !== "ok") {
|
|
66
|
+
recordAttempt(itemId, result.status);
|
|
67
|
+
return { ok: false, error: result.status === "failed" ? "could_not_extract_content" : result.status,
|
|
68
|
+
...(result.status === "failed" || result.status === "timeout" ? { retryable: true } : {}) };
|
|
78
69
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return { ok: false, error: "
|
|
70
|
+
const current = findItemById(itemId);
|
|
71
|
+
if (!current || current.link !== item.link)
|
|
72
|
+
return { ok: false, error: "item_not_found" };
|
|
73
|
+
if (improvesContent(current, result.article.contentHtml)) {
|
|
74
|
+
db.transaction(() => { updateItemFullContent(itemId, result.article.contentHtml); recordAttempt(itemId, "ready"); })();
|
|
82
75
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
76
|
+
else
|
|
77
|
+
recordAttempt(itemId, current.full_content_html ? "ready" : "feed_only");
|
|
78
|
+
return { ok: true, id: itemId, fullContentHtml: findItemById(itemId).full_content_html || current.content_html || "",
|
|
79
|
+
title: result.article.title, byline: result.article.byline, cached: false };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof SsrfBlockedError)
|
|
83
|
+
return { ok: false, error: "extraction_failed" };
|
|
84
|
+
if (error instanceof ArticleHostBusyError)
|
|
85
|
+
return { ok: false, error: "source_busy", retryable: true, deferred: true, host: error.host, retryAt: error.retryAt };
|
|
86
|
+
if (error instanceof ArticleAccessDeniedError)
|
|
87
|
+
return { ok: false, error: "capability_not_available" };
|
|
88
|
+
if (articleWorkPool().isClosing || error instanceof ArticleWorkBusyError)
|
|
89
|
+
return { ok: false, error: "queue_full", retryable: true };
|
|
90
|
+
if (error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name)) {
|
|
91
|
+
recordAttempt(itemId, "timeout");
|
|
92
|
+
return { ok: false, error: "timeout", retryable: true };
|
|
86
93
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
recordAttempt(itemId, "failed");
|
|
95
|
+
return { ok: false, error: "extraction_failed", retryable: true };
|
|
96
|
+
}
|
|
90
97
|
}
|
|
91
98
|
//# sourceMappingURL=fullText.js.map
|