feedkeeper 0.23.1 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.23.1",
3
+ "version": "0.24.1",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -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
@@ -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
- res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
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
- res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
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
- constructor(status) {
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 && [403, 429, 503].includes(error.status))
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 async function loadFullText(userId, itemId, options = {}) {
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 (!options.force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
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 (!options.force && item.extraction_status === "feed_only")
36
+ if (!force && item.extraction_status === "feed_only")
45
37
  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()) {
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
- 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 };
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
- catch {
80
- recordAttempt(itemId, "failed");
81
- return { ok: false, error: "extraction_failed" };
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
- finally {
84
- release?.();
85
- inFlight.delete(itemId);
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
- inFlight.set(itemId, task);
89
- return task;
94
+ recordAttempt(itemId, "failed");
95
+ return { ok: false, error: "extraction_failed", retryable: true };
96
+ }
90
97
  }
91
98
  //# sourceMappingURL=fullText.js.map