@khorsheed/dsh-reader 0.3.1 → 0.3.2

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/lib/index.js CHANGED
@@ -1240,6 +1240,104 @@ function arxivHtmlUrl(url) {
1240
1240
  if (link === void 0 || link.kind === "html") return void 0;
1241
1241
  return `https://arxiv.org/html/${link.id}`;
1242
1242
  }
1243
+ //#endregion
1244
+ //#region lib/types/document-fetch.js
1245
+ /**
1246
+ * The document fetcher: the reader's own instance of the official HTTP fetch
1247
+ * provider, so feeds and article pages arrive as the origin served them.
1248
+ *
1249
+ * Why not `ctx.web.fetch` alone: the seam runs whichever provider the host
1250
+ * selects, and from 0.2.1-rc.2 the web-search settings switch for the DeepSeek
1251
+ * Search/Fetch API moves `web.fetchProvider` too. That provider answers
1252
+ * Markdown (`body.kind: 'text'`), a constant 200, and the request URL as the
1253
+ * final URL — so `classifyPayload` reads every feed as a page, the extractor
1254
+ * gets no HTML, and the cross-origin redirect handling never fires. The
1255
+ * request carries only a URL; no caller can ask for the HTTP provider (seam
1256
+ * registry S22).
1257
+ *
1258
+ * So when the mounted `web` is the official service (it carries
1259
+ * `registerFetchProvider`), the reader fetches through its own
1260
+ * `HttpFetchProvider` — the same class the default `http` row registers, with
1261
+ * that row's default limits, so the policy (public addresses only, connection
1262
+ * pinned, same-origin redirects, charset decoding) and the errors
1263
+ * (`WEB_REDIRECT_BLOCKED` naming the target origin) are exactly today's.
1264
+ * `ctx.web` stays the permission: without it nothing is fetched. A host
1265
+ * without the provider package (or any other `web` shape, such as a test's
1266
+ * plain `{ fetch }`) is fetched through as before.
1267
+ *
1268
+ * Retire when the seam lets one request name its provider (S22).
1269
+ *
1270
+ * @module @khorsheed/dsh-reader/document-fetch
1271
+ */
1272
+ /**
1273
+ * The reader's own fetch limits: the `http` row's defaults
1274
+ * (`@deepseek-ai/dsh-web-fetch-http` Config, 0.1.5 through 0.2.1-rc.3) except
1275
+ * `maxBodyChars`. The row's 100 000 is sized for a page handed to a model; the
1276
+ * reader extracts the article itself from the raw HTML, and a modern page
1277
+ * carries several hundred KB of inline script before its last paragraph
1278
+ * (measured: claude.com's articles, 620 KB, cut mid-sentence at 100 000). 2 000 000
1279
+ * is what deployments ran the row at while the reader still fetched through
1280
+ * it, and the per-source storage budget (`MAX_BODY_CHARS_PER_SOURCE`) is
1281
+ * sized to match. A deployment changes these with the reader's own
1282
+ * `fetchLimits`: the row's config does not reach this instance.
1283
+ */
1284
+ const DOCUMENT_FETCH_DEFAULTS = {
1285
+ maxResponseBytes: 5e6,
1286
+ maxBodyChars: 2e6,
1287
+ timeoutMs: 3e4,
1288
+ maxRedirects: 5
1289
+ };
1290
+ /** Whether a mounted `web` is the official service rather than some other shape. */
1291
+ function isOfficialWebService(web) {
1292
+ return typeof web?.registerFetchProvider === "function";
1293
+ }
1294
+ const loadOfficialProvider = () => import("@deepseek-ai/dsh-web-fetch-http").then((module) => module, () => void 0);
1295
+ /**
1296
+ * Pick the fetcher for raw documents.
1297
+ *
1298
+ * Built once per service: the first fetch on an official web service loads
1299
+ * the provider package and keeps the instance; a failed load is remembered
1300
+ * too, and every later fetch goes through the seam.
1301
+ */
1302
+ var DocumentFetch = class {
1303
+ web;
1304
+ limits;
1305
+ load;
1306
+ provider;
1307
+ /**
1308
+ * @param web - the probed web seam, read on every call (it may come and go).
1309
+ * @param limits - the reader's configured fetch limits, over the defaults.
1310
+ * @param load - the provider package loader (tests inject one).
1311
+ */
1312
+ constructor(web, limits = {}, load = loadOfficialProvider) {
1313
+ this.web = web;
1314
+ this.limits = limits;
1315
+ this.load = load;
1316
+ }
1317
+ /**
1318
+ * The fetcher for this call, or undefined when no web capability is mounted.
1319
+ * @returns the reader's own provider on an official web service, else the seam itself.
1320
+ */
1321
+ async current() {
1322
+ const web = this.web();
1323
+ if (web === void 0 || web === null) return void 0;
1324
+ const seam = web;
1325
+ if (!isOfficialWebService(web)) return seam;
1326
+ this.provider ??= this.load().then((module) => {
1327
+ if (module === void 0 || typeof module.HttpFetchProvider !== "function") return void 0;
1328
+ try {
1329
+ return new module.HttpFetchProvider({
1330
+ ...DOCUMENT_FETCH_DEFAULTS,
1331
+ ...this.limits,
1332
+ userAgent: module.DEFAULT_USER_AGENT ?? "deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)"
1333
+ });
1334
+ } catch {
1335
+ return;
1336
+ }
1337
+ });
1338
+ return await this.provider ?? seam;
1339
+ }
1340
+ };
1243
1341
  /** An honest agent line — bot walls are reported, never impersonated around. */
1244
1342
  const IMAGE_FETCH_USER_AGENT = "dsh-reader image rescue (+https://github.com/Khorsheed/dsh-plugins)";
1245
1343
  /**
@@ -3457,12 +3555,15 @@ var ReaderService = class {
3457
3555
  memoryDoc;
3458
3556
  /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
3459
3557
  imageCache = new ImageFetchCache();
3558
+ /** Feeds and pages, as the origin served them (see `document-fetch.ts`). */
3559
+ documents;
3460
3560
  /**
3461
3561
  * @param ctx - owning context.
3462
3562
  * @param config - optional state-root override.
3463
3563
  */
3464
3564
  constructor(ctx, config = {}) {
3465
3565
  this.ctx = ctx;
3566
+ this.documents = new DocumentFetch(() => this.ctx.get("web"), config.fetchLimits);
3466
3567
  this.store = new ReaderStore(config);
3467
3568
  const stateRoot = resolveReaderStateRoot(config.stateRoot);
3468
3569
  this.media = new ReaderMediaHost(stateRoot, config.ytdlpPath);
@@ -4732,7 +4833,7 @@ var ReaderService = class {
4732
4833
  * known-unreadable verdict) still answers.
4733
4834
  */
4734
4835
  async resolvePaper(url) {
4735
- const web = this.web();
4836
+ const web = await this.documents.current();
4736
4837
  const fetch = async (target) => {
4737
4838
  if (web === void 0) throw new UnsupportedContent("no web capability is mounted");
4738
4839
  const result = await web.fetch({ url: target });
@@ -4747,10 +4848,6 @@ var ReaderService = class {
4747
4848
  return null;
4748
4849
  }
4749
4850
  }
4750
- /** The probed web seam (no host import, no boot-time dependency). */
4751
- web() {
4752
- return this.ctx.get("web");
4753
- }
4754
4851
  /** The probed side-chat service. */
4755
4852
  sideChat() {
4756
4853
  return this.ctx.get("sideChat");
@@ -4801,7 +4898,7 @@ var ReaderService = class {
4801
4898
  * @returns the final URL and its decoded payload.
4802
4899
  */
4803
4900
  async fetchFollowing(url) {
4804
- const web = this.web();
4901
+ const web = await this.documents.current();
4805
4902
  if (web === void 0) throw new UnsupportedContent("no web capability is mounted");
4806
4903
  let current = url;
4807
4904
  for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) try {
@@ -73,6 +73,31 @@ const DROP_TAGS = new Set([
73
73
  ]);
74
74
  /** The strip selector's exact-case members (SVG camelCase names in an HTML document). */
75
75
  const DROP_STRIP_EXTRA = [];
76
+ /**
77
+ * Page furniture that sits beside an article in the same container and that
78
+ * no tag or score tells apart from it. Named by id, per site, only where the
79
+ * id is the platform's own and stable.
80
+ *
81
+ * WeChat (mp.weixin.qq.com): `#content_bottom_area` holds the 腾讯公益
82
+ * donation card (一起捐, the donors' avatars), the like/share bar and the
83
+ * album modal; `#js_tags_preview_toast` is 「预览时标签不可点」. A rendered
84
+ * fetch runs the page's scripts, which fill both in, and the winning container
85
+ * (`.rich_media_area_primary_inner`) holds them next to `#js_content`.
86
+ */
87
+ const NOISE_SELECTORS = ['#content_bottom_area', '#js_tags_preview_toast'];
88
+ /**
89
+ * Article roots a platform names itself, taken as the body without scoring.
90
+ *
91
+ * WeChat's `#js_content` is exactly the article: the title, byline and the
92
+ * 「在小说阅读器读本章」 card sit beside it in `#img-content`, and a rendered
93
+ * fetch fills the page with modal layers (the font-size panel, the complaint
94
+ * dialog, the comment-identity popover, the a11y labels) that the scorer
95
+ * measured as one more container of text and let win. The pane renders the
96
+ * title itself, and the document `<title>` is the article's.
97
+ */
98
+ const ARTICLE_ROOTS = ['#js_content'];
99
+ /** Below this much text a named root is not trusted (a lazy page not yet filled in). */
100
+ const ARTICLE_ROOT_MIN_TEXT = 120;
76
101
  /** Inline elements that survive an inline-only pass. */
77
102
  const INLINE_TAGS = new Set(['strong', 'em', 'b', 'i', 'u', 's', 'code', 'a', 'sub', 'sup', 'br']);
78
103
  /**
@@ -186,12 +211,15 @@ export function extractArticle(pageHtml, baseUrl) {
186
211
  const docTitle = doc.title ?? '';
187
212
  // Strip the noise subtrees BEFORE scoring, so every candidate is measured on
188
213
  // the same terms (a sidebar full of links otherwise wins on raw text mass).
189
- for (const node of Array.from(doc.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA].join(',')))) {
214
+ for (const node of Array.from(doc.querySelectorAll([...DROP_TAGS, ...DROP_STRIP_EXTRA, ...NOISE_SELECTORS].join(',')))) {
190
215
  node.remove();
191
216
  }
192
217
  settleMathJaxTwins(doc.body);
193
218
  settleListLabels(doc.body);
194
- const scored = scoreCandidates(doc);
219
+ const named = ARTICLE_ROOTS
220
+ .map(selector => doc.querySelector(selector))
221
+ .find((element) => element !== null && textLengthOf(element) >= ARTICLE_ROOT_MIN_TEXT);
222
+ const scored = named === undefined ? scoreCandidates(doc) : { element: named, score: 0 };
195
223
  if (scored === undefined) {
196
224
  return { ok: false, error: 'no text-bearing block found' };
197
225
  }
@@ -649,7 +677,8 @@ function normalizeNode(node, baseUrl, inFigure = false) {
649
677
  const src = imageSrc(element, baseUrl);
650
678
  if (src === undefined)
651
679
  return '';
652
- return emitImg(src, element.getAttribute('alt'));
680
+ const width = displayWidth(element);
681
+ return emitImg(src, element.getAttribute('alt'), width === undefined ? {} : { width });
653
682
  }
654
683
  // LaTeXML embeds vector figures as image-typed objects: an image in every
655
684
  // sense that matters here. Any other object (a PDF, a movie) keeps the old
@@ -826,6 +855,29 @@ function emitImg(src, alt, dimensions = {}) {
826
855
  const height = dimensions.height === undefined ? '' : ` height="${dimensions.height}"`;
827
856
  return `<img src="${escapeAttribute(src)}"${alt === null ? '' : ` alt="${escapeAttribute(alt)}"`}${width}${height} referrerpolicy="no-referrer">`;
828
857
  }
858
+ /**
859
+ * The width the page shows an image at: its inline `width: Npx`.
860
+ *
861
+ * Without it every image renders at its natural size up to the column. A
862
+ * logo row is where that shows: claude.com ships a 132×128 PNG with
863
+ * `style="width:48.89px"`, and the reader drew it three times too large. Only
864
+ * a pixel width counts (`100%`, `auto` and `max-width` say nothing about the
865
+ * picture), and the column's `max-width: 100%` still caps it. The `width`
866
+ * attribute is not carried: it is usually the file's own size, which is what
867
+ * the image renders at anyway.
868
+ *
869
+ * @param element - the `<img>`.
870
+ * @returns the width as an integer string, or `undefined`.
871
+ */
872
+ function displayWidth(element) {
873
+ const styled = /(?:^|;)\s*width\s*:\s*(\d+(?:\.\d+)?)px/i.exec(element.getAttribute('style') ?? '');
874
+ if (styled !== null) {
875
+ const px = Math.round(Number(styled[1]));
876
+ if (px >= 1 && px <= 99_999)
877
+ return String(px);
878
+ }
879
+ return undefined;
880
+ }
829
881
  /** An integer dimension worth carrying onto the emitted image (nothing else is trusted). */
830
882
  function dimensionAttr(value) {
831
883
  if (value === null)
@@ -1346,18 +1398,21 @@ function bestSrcset(value, baseUrl) {
1346
1398
  function pictureImg(element, baseUrl) {
1347
1399
  const fallback = Array.from(element.children).find(child => child.localName === 'img');
1348
1400
  const alt = fallback?.getAttribute('alt') ?? null;
1401
+ // The picture's size is its <img>'s (claude.com's testimonial logos).
1402
+ const width = fallback === undefined ? undefined : displayWidth(fallback);
1403
+ const dimensions = width === undefined ? {} : { width };
1349
1404
  for (const source of Array.from(element.children)) {
1350
1405
  if (source.localName !== 'source')
1351
1406
  continue;
1352
1407
  const url = bestSrcset(source.getAttribute('srcset'), baseUrl)
1353
1408
  ?? bestSrcset(source.getAttribute('data-srcset'), baseUrl);
1354
1409
  if (url !== undefined)
1355
- return emitImg(url, alt);
1410
+ return emitImg(url, alt, dimensions);
1356
1411
  }
1357
1412
  if (fallback !== undefined) {
1358
1413
  const url = imageSrc(fallback, baseUrl);
1359
1414
  if (url !== undefined)
1360
- return emitImg(url, alt);
1415
+ return emitImg(url, alt, dimensions);
1361
1416
  }
1362
1417
  return '';
1363
1418
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The document fetcher: the reader's own instance of the official HTTP fetch
3
+ * provider, so feeds and article pages arrive as the origin served them.
4
+ *
5
+ * Why not `ctx.web.fetch` alone: the seam runs whichever provider the host
6
+ * selects, and from 0.2.1-rc.2 the web-search settings switch for the DeepSeek
7
+ * Search/Fetch API moves `web.fetchProvider` too. That provider answers
8
+ * Markdown (`body.kind: 'text'`), a constant 200, and the request URL as the
9
+ * final URL — so `classifyPayload` reads every feed as a page, the extractor
10
+ * gets no HTML, and the cross-origin redirect handling never fires. The
11
+ * request carries only a URL; no caller can ask for the HTTP provider (seam
12
+ * registry S22).
13
+ *
14
+ * So when the mounted `web` is the official service (it carries
15
+ * `registerFetchProvider`), the reader fetches through its own
16
+ * `HttpFetchProvider` — the same class the default `http` row registers, with
17
+ * that row's default limits, so the policy (public addresses only, connection
18
+ * pinned, same-origin redirects, charset decoding) and the errors
19
+ * (`WEB_REDIRECT_BLOCKED` naming the target origin) are exactly today's.
20
+ * `ctx.web` stays the permission: without it nothing is fetched. A host
21
+ * without the provider package (or any other `web` shape, such as a test's
22
+ * plain `{ fetch }`) is fetched through as before.
23
+ *
24
+ * Retire when the seam lets one request name its provider (S22).
25
+ *
26
+ * @module @khorsheed/dsh-reader/document-fetch
27
+ */
28
+ /** The shape of one fetch result, declared structurally (no host import). */
29
+ export interface DocumentFetchResult {
30
+ readonly url: string;
31
+ readonly statusCode: number;
32
+ readonly body: {
33
+ readonly kind: 'html' | 'text';
34
+ readonly content: string;
35
+ };
36
+ readonly truncated: boolean;
37
+ }
38
+ /** Anything that fetches one URL: the web seam or a provider. */
39
+ export interface DocumentFetcher {
40
+ fetch(request: {
41
+ readonly url: string;
42
+ }, signal?: AbortSignal): Promise<DocumentFetchResult>;
43
+ }
44
+ /** The limits the official provider takes (the `http` row's resolved config). */
45
+ interface HttpFetchLimits {
46
+ readonly maxResponseBytes: number;
47
+ readonly maxBodyChars: number;
48
+ readonly timeoutMs: number;
49
+ readonly maxRedirects: number;
50
+ readonly userAgent: string;
51
+ }
52
+ /** The provider package's runtime face, structurally. */
53
+ interface HttpProviderModule {
54
+ readonly HttpFetchProvider: new (limits: HttpFetchLimits) => DocumentFetcher;
55
+ readonly DEFAULT_USER_AGENT?: string;
56
+ }
57
+ /**
58
+ * The reader's own fetch limits: the `http` row's defaults
59
+ * (`@deepseek-ai/dsh-web-fetch-http` Config, 0.1.5 through 0.2.1-rc.3) except
60
+ * `maxBodyChars`. The row's 100 000 is sized for a page handed to a model; the
61
+ * reader extracts the article itself from the raw HTML, and a modern page
62
+ * carries several hundred KB of inline script before its last paragraph
63
+ * (measured: claude.com's articles, 620 KB, cut mid-sentence at 100 000). 2 000 000
64
+ * is what deployments ran the row at while the reader still fetched through
65
+ * it, and the per-source storage budget (`MAX_BODY_CHARS_PER_SOURCE`) is
66
+ * sized to match. A deployment changes these with the reader's own
67
+ * `fetchLimits`: the row's config does not reach this instance.
68
+ */
69
+ export declare const DOCUMENT_FETCH_DEFAULTS: {
70
+ readonly maxResponseBytes: 5000000;
71
+ readonly maxBodyChars: 2000000;
72
+ readonly timeoutMs: 30000;
73
+ readonly maxRedirects: 5;
74
+ };
75
+ /** The limits a deployment may override (the user agent stays the provider's). */
76
+ export type DocumentFetchLimits = Partial<Pick<HttpFetchLimits, 'maxResponseBytes' | 'maxBodyChars' | 'timeoutMs' | 'maxRedirects'>>;
77
+ /** Whether a mounted `web` is the official service rather than some other shape. */
78
+ export declare function isOfficialWebService(web: unknown): boolean;
79
+ /** Loads the provider package; injectable so tests never need the real one. */
80
+ export type HttpProviderLoader = () => Promise<HttpProviderModule | undefined>;
81
+ /**
82
+ * Pick the fetcher for raw documents.
83
+ *
84
+ * Built once per service: the first fetch on an official web service loads
85
+ * the provider package and keeps the instance; a failed load is remembered
86
+ * too, and every later fetch goes through the seam.
87
+ */
88
+ export declare class DocumentFetch {
89
+ private readonly web;
90
+ private readonly limits;
91
+ private readonly load;
92
+ private provider;
93
+ /**
94
+ * @param web - the probed web seam, read on every call (it may come and go).
95
+ * @param limits - the reader's configured fetch limits, over the defaults.
96
+ * @param load - the provider package loader (tests inject one).
97
+ */
98
+ constructor(web: () => unknown, limits?: DocumentFetchLimits, load?: HttpProviderLoader);
99
+ /**
100
+ * The fetcher for this call, or undefined when no web capability is mounted.
101
+ * @returns the reader's own provider on an official web service, else the seam itself.
102
+ */
103
+ current(): Promise<DocumentFetcher | undefined>;
104
+ }
105
+ export {};
106
+ //# sourceMappingURL=document-fetch.d.ts.map
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The document fetcher: the reader's own instance of the official HTTP fetch
3
+ * provider, so feeds and article pages arrive as the origin served them.
4
+ *
5
+ * Why not `ctx.web.fetch` alone: the seam runs whichever provider the host
6
+ * selects, and from 0.2.1-rc.2 the web-search settings switch for the DeepSeek
7
+ * Search/Fetch API moves `web.fetchProvider` too. That provider answers
8
+ * Markdown (`body.kind: 'text'`), a constant 200, and the request URL as the
9
+ * final URL — so `classifyPayload` reads every feed as a page, the extractor
10
+ * gets no HTML, and the cross-origin redirect handling never fires. The
11
+ * request carries only a URL; no caller can ask for the HTTP provider (seam
12
+ * registry S22).
13
+ *
14
+ * So when the mounted `web` is the official service (it carries
15
+ * `registerFetchProvider`), the reader fetches through its own
16
+ * `HttpFetchProvider` — the same class the default `http` row registers, with
17
+ * that row's default limits, so the policy (public addresses only, connection
18
+ * pinned, same-origin redirects, charset decoding) and the errors
19
+ * (`WEB_REDIRECT_BLOCKED` naming the target origin) are exactly today's.
20
+ * `ctx.web` stays the permission: without it nothing is fetched. A host
21
+ * without the provider package (or any other `web` shape, such as a test's
22
+ * plain `{ fetch }`) is fetched through as before.
23
+ *
24
+ * Retire when the seam lets one request name its provider (S22).
25
+ *
26
+ * @module @khorsheed/dsh-reader/document-fetch
27
+ */
28
+ /**
29
+ * The reader's own fetch limits: the `http` row's defaults
30
+ * (`@deepseek-ai/dsh-web-fetch-http` Config, 0.1.5 through 0.2.1-rc.3) except
31
+ * `maxBodyChars`. The row's 100 000 is sized for a page handed to a model; the
32
+ * reader extracts the article itself from the raw HTML, and a modern page
33
+ * carries several hundred KB of inline script before its last paragraph
34
+ * (measured: claude.com's articles, 620 KB, cut mid-sentence at 100 000). 2 000 000
35
+ * is what deployments ran the row at while the reader still fetched through
36
+ * it, and the per-source storage budget (`MAX_BODY_CHARS_PER_SOURCE`) is
37
+ * sized to match. A deployment changes these with the reader's own
38
+ * `fetchLimits`: the row's config does not reach this instance.
39
+ */
40
+ export const DOCUMENT_FETCH_DEFAULTS = {
41
+ maxResponseBytes: 5_000_000,
42
+ maxBodyChars: 2_000_000,
43
+ timeoutMs: 30_000,
44
+ maxRedirects: 5,
45
+ };
46
+ /** Whether a mounted `web` is the official service rather than some other shape. */
47
+ export function isOfficialWebService(web) {
48
+ return typeof web?.registerFetchProvider === 'function';
49
+ }
50
+ const loadOfficialProvider = () => import('@deepseek-ai/dsh-web-fetch-http').then(module => module, () => undefined);
51
+ /**
52
+ * Pick the fetcher for raw documents.
53
+ *
54
+ * Built once per service: the first fetch on an official web service loads
55
+ * the provider package and keeps the instance; a failed load is remembered
56
+ * too, and every later fetch goes through the seam.
57
+ */
58
+ export class DocumentFetch {
59
+ web;
60
+ limits;
61
+ load;
62
+ provider;
63
+ /**
64
+ * @param web - the probed web seam, read on every call (it may come and go).
65
+ * @param limits - the reader's configured fetch limits, over the defaults.
66
+ * @param load - the provider package loader (tests inject one).
67
+ */
68
+ constructor(web, limits = {}, load = loadOfficialProvider) {
69
+ this.web = web;
70
+ this.limits = limits;
71
+ this.load = load;
72
+ }
73
+ /**
74
+ * The fetcher for this call, or undefined when no web capability is mounted.
75
+ * @returns the reader's own provider on an official web service, else the seam itself.
76
+ */
77
+ async current() {
78
+ const web = this.web();
79
+ if (web === undefined || web === null)
80
+ return undefined;
81
+ const seam = web;
82
+ if (!isOfficialWebService(web))
83
+ return seam;
84
+ this.provider ??= this.load().then(module => {
85
+ if (module === undefined || typeof module.HttpFetchProvider !== 'function')
86
+ return undefined;
87
+ try {
88
+ return new module.HttpFetchProvider({
89
+ ...DOCUMENT_FETCH_DEFAULTS,
90
+ ...this.limits,
91
+ userAgent: module.DEFAULT_USER_AGENT ?? 'deepseek-harness/0.0.1 (+https://github.com/deepseek-ai)',
92
+ });
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ });
98
+ return (await this.provider) ?? seam;
99
+ }
100
+ }
@@ -12,6 +12,7 @@
12
12
  * @module @khorsheed/dsh-reader
13
13
  */
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
+ import type { DocumentFetchLimits } from './document-fetch.ts';
15
16
  import { ReaderService } from './service.ts';
16
17
  import { ReaderNotesStore } from './notes.ts';
17
18
  export { ReaderService, classifyPayload, normalizeUrl } from './service.ts';
@@ -38,6 +39,14 @@ export interface ReaderConfig {
38
39
  readonly stateRoot?: string;
39
40
  /** A yt-dlp binary to use instead of the reader's managed copy (video metadata and captions). */
40
41
  readonly ytdlpPath?: string;
42
+ /**
43
+ * Fetch limits for feeds and pages: `maxResponseBytes` (default 5 000 000),
44
+ * `maxBodyChars` (2 000 000 — whole pages, not the row's model-sized 100 000),
45
+ * `timeoutMs` (30 000), `maxRedirects` (5). The reader fetches through its own
46
+ * instance of the official `http` provider, so limits raised on that row do
47
+ * not reach it; set them here.
48
+ */
49
+ readonly fetchLimits?: DocumentFetchLimits;
41
50
  }
42
51
  /**
43
52
  * Plugin body: provide the service core, then mount the Remote data face.
@@ -5,9 +5,12 @@
5
5
  *
6
6
  * Two seams shape the whole module:
7
7
  *
8
- * - **Fetch goes through `ctx.web.fetch`** — the host's sanctioned outbound
9
- * HTTP face, which brings public-address resolution, connection pinning, a
10
- * byte cap and a timeout. Its provider caps the decoded body at 100 000
8
+ * - **Fetch rides the official HTTP provider** — `ctx.web` is the permission
9
+ * (nothing is fetched without it), and its default `http` provider brings
10
+ * public-address resolution, connection pinning, a byte cap and a timeout.
11
+ * On the official web service the reader runs its own instance of that
12
+ * provider (`document-fetch.ts`), because the host's selected provider may
13
+ * answer Markdown instead of the document (seam registry S22). Its provider caps the decoded body at 100 000
11
14
  * characters by default, so a payload that comes back `truncated` is stored
12
15
  * as-is and FLAGGED; nothing here pretends it is a whole document. A
13
16
  * truncated feed cannot be parsed and a truncated page yields half an
@@ -36,6 +39,7 @@
36
39
  */
37
40
  import type { Context } from '@deepseek-ai/cordis';
38
41
  import { type ReaderAddFailure, type ReaderAddOutcome, type ReaderAddRefusal, type ReaderAnnotationOutcome, type ReaderBackfillCandidate, type ReaderEntryBodyView, type ReaderBody, type ReaderCapabilities, type ReaderMutationOutcome, type ReaderEntryFetchState, type ReaderEntryTranslationView, type ReaderPreviewFailure, type ReaderPreviewFailureCode, type ReaderImageResult, type ReaderRecentEntry, type ReaderRefreshResult, type ReaderSentenceLearn, type ReaderSourceSummary, type ReaderStorageStats, type ReaderTag, type MediaCardStates } from './types.ts';
42
+ import { type DocumentFetchLimits } from './document-fetch.ts';
39
43
  import { LocalTranscriber } from './local-transcribe.ts';
40
44
  import { ReaderMediaHost } from './media-host.ts';
41
45
  /**
@@ -95,6 +99,8 @@ export declare class ReaderService {
95
99
  private memoryDoc;
96
100
  /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
97
101
  private readonly imageCache;
102
+ /** Feeds and pages, as the origin served them (see `document-fetch.ts`). */
103
+ private readonly documents;
98
104
  /**
99
105
  * @param ctx - owning context.
100
106
  * @param config - optional state-root override.
@@ -102,6 +108,7 @@ export declare class ReaderService {
102
108
  constructor(ctx: Context, config?: {
103
109
  stateRoot?: string;
104
110
  ytdlpPath?: string;
111
+ fetchLimits?: DocumentFetchLimits;
105
112
  });
106
113
  /** Capabilities the browser may rely on. */
107
114
  capabilities(): Promise<ReaderCapabilities>;
@@ -576,8 +583,6 @@ export declare class ReaderService {
576
583
  * known-unreadable verdict) still answers.
577
584
  */
578
585
  private resolvePaper;
579
- /** The probed web seam (no host import, no boot-time dependency). */
580
- private web;
581
586
  /** The probed side-chat service. */
582
587
  private sideChat;
583
588
  /**
@@ -2,6 +2,7 @@ import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, e
2
2
  import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, isCookieHandshake, } from "./types.js";
3
3
  import { delayUntilNext, isCatchUpDue } from "./schedule.js";
4
4
  import { arxivHtmlUrl } from "./arxiv.js";
5
+ import { DocumentFetch } from "./document-fetch.js";
5
6
  import { fetchImageBytes, ImageFetchCache } from "./image-fetch.js";
6
7
  import { resolveLink } from "./link-resolvers.js";
7
8
  import { join } from 'node:path';
@@ -102,12 +103,15 @@ export class ReaderService {
102
103
  memoryDoc;
103
104
  /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
104
105
  imageCache = new ImageFetchCache();
106
+ /** Feeds and pages, as the origin served them (see `document-fetch.ts`). */
107
+ documents;
105
108
  /**
106
109
  * @param ctx - owning context.
107
110
  * @param config - optional state-root override.
108
111
  */
109
112
  constructor(ctx, config = {}) {
110
113
  this.ctx = ctx;
114
+ this.documents = new DocumentFetch(() => this.ctx.get('web'), config.fetchLimits);
111
115
  this.store = new ReaderStore(config);
112
116
  const stateRoot = resolveReaderStateRoot(config.stateRoot);
113
117
  this.media = new ReaderMediaHost(stateRoot, config.ytdlpPath);
@@ -1390,7 +1394,7 @@ export class ReaderService {
1390
1394
  * known-unreadable verdict) still answers.
1391
1395
  */
1392
1396
  async resolvePaper(url) {
1393
- const web = this.web();
1397
+ const web = await this.documents.current();
1394
1398
  const fetch = async (target) => {
1395
1399
  if (web === undefined)
1396
1400
  throw new UnsupportedContent('no web capability is mounted');
@@ -1404,10 +1408,6 @@ export class ReaderService {
1404
1408
  return null;
1405
1409
  }
1406
1410
  }
1407
- /** The probed web seam (no host import, no boot-time dependency). */
1408
- web() {
1409
- return this.ctx.get('web');
1410
- }
1411
1411
  /** The probed side-chat service. */
1412
1412
  sideChat() {
1413
1413
  return this.ctx.get('sideChat');
@@ -1463,7 +1463,7 @@ export class ReaderService {
1463
1463
  * @returns the final URL and its decoded payload.
1464
1464
  */
1465
1465
  async fetchFollowing(url) {
1466
- const web = this.web();
1466
+ const web = await this.documents.current();
1467
1467
  if (web === undefined)
1468
1468
  throw new UnsupportedContent('no web capability is mounted');
1469
1469
  let current = url;
@@ -97,8 +97,9 @@ export interface ReaderSource {
97
97
  /** Human-readable failure of the last attempt, when `status` is `error`. */
98
98
  readonly error?: string;
99
99
  /**
100
- * True when the host's fetch seam cut the payload at its size limit
101
- * (`maxBodyChars`, 100 000 by default). A truncated XML document cannot be
100
+ * True when the fetch cut the payload at its size limit (`maxBodyChars`:
101
+ * the reader's own 2 000 000, or the seam's limit where the reader fetches
102
+ * through it). A truncated XML document cannot be
102
103
  * parsed and a truncated HTML page yields a partial article, so every
103
104
  * consumer treats this as "incomplete" and says so rather than guessing.
104
105
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@khorsheed/dsh-reader",
3
3
  "description": "Link reader tab: RSS/Atom subscriptions plus pasted article links, a card feed with a readable detail view, host-side fetch and daily refresh over a Typert Remote data face",
4
- "version": "0.3.1",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -64,6 +64,7 @@
64
64
  "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
65
65
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
66
66
  "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
67
+ "@deepseek-ai/dsh-web-fetch-http": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
67
68
  "react": "^18.2.0"
68
69
  },
69
70
  "peerDependenciesMeta": {
@@ -94,6 +95,9 @@
94
95
  "@deepseek-ai/dsh-tools": {
95
96
  "optional": true
96
97
  },
98
+ "@deepseek-ai/dsh-web-fetch-http": {
99
+ "optional": true
100
+ },
97
101
  "react": {
98
102
  "optional": true
99
103
  }
@@ -126,8 +130,8 @@
126
130
  },
127
131
  "compat": {
128
132
  "minHost": "0.1.5-rc.1",
129
- "notes": "A page-type right-Sidebar tab (minted kind `reader`) over a Typert Remote data face. Fetching runs on the HOST through the sanctioned `ctx.web` seam; state is written with plain `node:fs` (atomic temp+rename) under `$DSH_HOME/state/dsh-reader`, NOT through `ctx.fs` — that service is the session-fenced sandboxed filesystem, so a workspace-write session refuses a write to the state root (observed on the acceptance instance). Every optional capability is probed at apply time and degrades (no `web` → domain refusals; no `sideChat` → the side-chat gesture hides; no `quote` → selection quoting is unavailable while the entry-level quote button still works). KNOWN LIMIT: the host fetch seam caps a decoded body at 100,000 chars and truncates silently, so articles larger than that cannot be extracted — the detail view falls back to a 「受限篇幅,内容未完整呈现」 notice with a 「阅读原文」 button (measured: Anthropic research pages, theverge.com, newyorker.com). On-device translation (0.2.0) runs the page's own Translator API (Chrome 138+/Edge 148+, desktop): the model never leaves the device and no model quota is spent, while the derived sentence pairs persist on the host (a global memory file plus per-entry segment maps under the state root, one shared budget, no TTL) so a reload re-paints instead of re-translating. Where that API is absent or the pair is unavailable, the globe is simply not rendered — the pane keeps working exactly as before — except that a browser without the API (iOS, Safari, Firefox) still offers the globe on a body whose stored per-entry map matches its hash, to VIEW that stored translation (no model, no retranslate). Source bodies that are already Chinese are not offered translation at all. On host 0.1.6-alpha.2+ the spots that want a real browser (the script-figure notice, a bot-wall card's action) open the in-app Sidebar Browser tab, probed through the `browser` tab kind; on older hosts they are the ordinary external link. Pages that inline real figures as base64 data URIs are now kept whole (substantive payloads over a 512-char threshold; 1px placeholders still yield to lazy-loading attributes), so a figure-heavy paper on a cap-raised deployment becomes a multi-MB cached body by design — bodies are bounded by entry count + TTL, not by a char budget. Notes (unreleased): the body highlight uses the CSS Custom Highlight API (no highlight without it, notes still work); the quote-menu 「记笔记」 row needs quote applied first, otherwise the detail foot button is the way in; 「回到会话」 needs ui-workspace's openSession; the ./tool row's reader_read / reader_note need the host tools registry and its reader skill the skills registry. The right-Sidebar tab self-hides on the ./tool row (2026-10-19, the canvas's dual check through the official pluginInventory Remote, probed): a root-mounted reader-tool row shows it everywhere, otherwise only sessions whose preset grants it (the learning and writing mode packs); every unreadable path fails open. Video and audio (unreleased): captions and channel listings run the plugin's own standalone yt-dlp on the host (fetched with curl on first use into the state root's bin/, self-updated after two weeks; ytdlpPath overrides); the YouTube embed is a frame of the plugin's own (allow-scripts allow-same-origin allow-presentation) driven over postMessage; Bilibili's player reports no time (seek = reload with t); Bilibili uploader spaces and most captions need a login. Local transcription of caption-less videos and audio probes the optional `transcribe` service (@khorsheed/dsh-transcribe) per start and states its absence; the audio is fetched through yt-dlp or curl into the state root's tmp/ and deleted once transcribed.",
130
- "verifiedHost": "0.2.1-rc.1"
133
+ "notes": "A page-type right-Sidebar tab (minted kind `reader`) over a Typert Remote data face. Fetching runs on the HOST, gated on the `ctx.web` seam: on the official web service feeds and pages go through the reader's own instance of the official HTTP fetch provider (`@deepseek-ai/dsh-web-fetch-http`, optional peer), because from 0.2.1-rc.2 the web-search settings switch can move `ctx.web` fetching to the DeepSeek Fetch API, which answers Markdown instead of the document (seam registry S22); without that package fetches go through `ctx.web.fetch`; state is written with plain `node:fs` (atomic temp+rename) under `$DSH_HOME/state/dsh-reader`, NOT through `ctx.fs` — that service is the session-fenced sandboxed filesystem, so a workspace-write session refuses a write to the state root (observed on the acceptance instance). Every optional capability is probed at apply time and degrades (no `web` → domain refusals; no `sideChat` → the side-chat gesture hides; no `quote` → selection quoting is unavailable while the entry-level quote button still works). KNOWN LIMIT: the fetch caps a decoded body at 2,000,000 chars by default (100,000 before 2026-10-27; the reader row's `fetchLimits` changes it; limits on the `web-fetch-http` row no longer reach the reader) and truncates silently, so articles larger than that cannot be extracted — the detail view falls back to a 「受限篇幅,内容未完整呈现」 notice with a 「阅读原文」 button (measured: Anthropic research pages, theverge.com, newyorker.com). On-device translation (0.2.0) runs the page's own Translator API (Chrome 138+/Edge 148+, desktop): the model never leaves the device and no model quota is spent, while the derived sentence pairs persist on the host (a global memory file plus per-entry segment maps under the state root, one shared budget, no TTL) so a reload re-paints instead of re-translating. Where that API is absent or the pair is unavailable, the globe is simply not rendered — the pane keeps working exactly as before — except that a browser without the API (iOS, Safari, Firefox) still offers the globe on a body whose stored per-entry map matches its hash, to VIEW that stored translation (no model, no retranslate). Source bodies that are already Chinese are not offered translation at all. On host 0.1.6-alpha.2+ the spots that want a real browser (the script-figure notice, a bot-wall card's action) open the in-app Sidebar Browser tab, probed through the `browser` tab kind; on older hosts they are the ordinary external link. Pages that inline real figures as base64 data URIs are now kept whole (substantive payloads over a 512-char threshold; 1px placeholders still yield to lazy-loading attributes), so a figure-heavy paper on a cap-raised deployment becomes a multi-MB cached body by design — bodies are bounded by entry count + TTL, not by a char budget. Notes (unreleased): the body highlight uses the CSS Custom Highlight API (no highlight without it, notes still work); the quote-menu 「记笔记」 row needs quote applied first, otherwise the detail foot button is the way in; 「回到会话」 needs ui-workspace's openSession; the ./tool row's reader_read / reader_note need the host tools registry and its reader skill the skills registry. The right-Sidebar tab self-hides on the ./tool row (2026-10-19, the canvas's dual check through the official pluginInventory Remote, probed): a root-mounted reader-tool row shows it everywhere, otherwise only sessions whose preset grants it (the learning and writing mode packs); every unreadable path fails open. Video and audio (unreleased): captions and channel listings run the plugin's own standalone yt-dlp on the host (fetched with curl on first use into the state root's bin/, self-updated after two weeks; ytdlpPath overrides); the YouTube embed is a frame of the plugin's own (allow-scripts allow-same-origin allow-presentation) driven over postMessage; Bilibili's player reports no time (seek = reload with t); Bilibili uploader spaces and most captions need a login. Local transcription of caption-less videos and audio probes the optional `transcribe` service (@khorsheed/dsh-transcribe) per start and states its absence; the audio is fetched through yt-dlp or curl into the state root's tmp/ and deleted once transcribed.",
134
+ "verifiedHost": "0.2.1-rc.3"
131
135
  },
132
136
  "references": [
133
137
  "@khorsheed/dsh-quote",