@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/CHANGELOG.md +7 -0
- package/README.en.md +1 -1
- package/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/lib/client.js +309 -252
- package/lib/index.js +103 -6
- package/lib/types/client/extract-article.js +60 -5
- package/lib/types/document-fetch.d.ts +106 -0
- package/lib/types/document-fetch.js +100 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/service.d.ts +10 -5
- package/lib/types/service.js +6 -6
- package/lib/types/types.d.ts +3 -2
- package/package.json +7 -3
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
package/lib/types/service.d.ts
CHANGED
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Two seams shape the whole module:
|
|
7
7
|
*
|
|
8
|
-
* - **Fetch
|
|
9
|
-
*
|
|
10
|
-
* byte cap and a timeout.
|
|
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
|
/**
|
package/lib/types/service.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -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
|
|
101
|
-
*
|
|
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.
|
|
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
|
|
130
|
-
"verifiedHost": "0.2.1-rc.
|
|
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",
|