@khorsheed/dsh-reader 0.2.1 → 0.3.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +30580 -6892
  7. package/lib/index.js +4189 -621
  8. package/lib/typert.host.js +2265 -319
  9. package/lib/typert.remote-client.d.ts +99 -3
  10. package/lib/typert.remote-client.js +1618 -240
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +905 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +138 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +82 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/image-fetch.d.ts +81 -0
  54. package/lib/types/image-fetch.js +385 -0
  55. package/lib/types/index.d.ts +6 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +127 -1
  70. package/lib/types/remote.js +162 -1
  71. package/lib/types/service.d.ts +100 -1
  72. package/lib/types/service.js +320 -59
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +106 -0
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +29 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. package/lib/types/invariant.js +0 -38
@@ -1,8 +1,14 @@
1
- import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, emptyStateDoc, memoryTableChars, TRANSLATION_MEMORY_FILE, } from "./store.js";
2
- import { defaultSourceLabel, errorMessage, isRetryablePreviewFailure, linkEntryId, DEFAULT_CACHE_POLICY, DEFAULT_TRANSLATION_BUDGET_CHARS, INLINE_BODY_MAX_CHARS, TRANSLATION_STORAGE_VERSION, } from "./types.js";
1
+ import { MAX_RECENT_ENTRIES, ReaderStore, ReaderStoreError, boundTranslations, emptyStateDoc, memoryTableChars, resolveReaderStateRoot, TRANSLATION_MEMORY_FILE, } from "./store.js";
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 { fetchImageBytes, ImageFetchCache } from "./image-fetch.js";
5
6
  import { resolveLink } from "./link-resolvers.js";
7
+ import { join } from 'node:path';
8
+ import { LocalTranscriber } from "./local-transcribe.js";
9
+ import { channelRss, episodePage, ReaderMediaHost, resolveShortLink, syntheticLabel, videoPage } from "./media-host.js";
10
+ import { canonicalMediaUrl, isShortMediaLink, mediaLink } from "./media.js";
11
+ import { podcastPageToRss, readEpisodePage } from "./xiaoyuzhou.js";
6
12
  /**
7
13
  * How many entries one automatic backfill run may fetch.
8
14
  *
@@ -13,6 +19,15 @@ import { resolveLink } from "./link-resolvers.js";
13
19
  export const BACKFILL_MAX_PER_RUN = 8;
14
20
  /** How long a FAILED fetch is left alone before it may be retried (6 hours). */
15
21
  export const BACKFILL_RETRY_MS = 6 * 60 * 60 * 1000;
22
+ /**
23
+ * How many sources one refresh fetches at once.
24
+ *
25
+ * A strictly sequential loop made one dead source (a full 30 s timeout) stall
26
+ * every source behind it — the "refresh takes minutes" half of the slow-load
27
+ * report. Three at a time is the politeness ceiling this package applies to
28
+ * other people's servers everywhere (the backfill runs at two).
29
+ */
30
+ export const REFRESH_CONCURRENCY = 3;
16
31
  /** Cross-origin redirect hops the service will follow before giving up. */
17
32
  const MAX_REDIRECT_HOPS = 3;
18
33
  /** Upper bound on summaries handed to the browser in one call. */
@@ -76,11 +91,17 @@ export function classifyPayload(raw) {
76
91
  /** The reader service. */
77
92
  export class ReaderService {
78
93
  store;
94
+ /** Video and audio entries: synthetic pages, channel feeds, transcripts. */
95
+ media;
96
+ /** Local transcription of entries without captions (the optional `transcribe` engine). */
97
+ local;
79
98
  ctx;
80
99
  timer;
81
100
  disposed = false;
82
101
  /** The document of record when no filesystem is mounted. */
83
102
  memoryDoc;
103
+ /** Rescued images, by URL — a pane remount re-fails every blocked image, and this is what keeps that a memory read. */
104
+ imageCache = new ImageFetchCache();
84
105
  /**
85
106
  * @param ctx - owning context.
86
107
  * @param config - optional state-root override.
@@ -88,8 +109,30 @@ export class ReaderService {
88
109
  constructor(ctx, config = {}) {
89
110
  this.ctx = ctx;
90
111
  this.store = new ReaderStore(config);
112
+ const stateRoot = resolveReaderStateRoot(config.stateRoot);
113
+ this.media = new ReaderMediaHost(stateRoot, config.ytdlpPath);
114
+ this.local = new LocalTranscriber({
115
+ engine: () => ctx.get('transcribe'),
116
+ tmpRoot: join(stateRoot, 'tmp'),
117
+ videoAudio: (url, dir, options) => this.media.ytdlp.audio(url, dir, options),
118
+ store: async (entryId, url, html, language, speakers) => {
119
+ await this.storeEntryBody({ entryId, url, html, keep: true });
120
+ const before = this.media.captions(entryId);
121
+ // Names survive a re-transcription that finds the same number of voices.
122
+ const names = before?.names !== undefined && (speakers === undefined || speakers === before.speakers) ? before.names : undefined;
123
+ const count = speakers ?? before?.speakers;
124
+ this.media.remember(entryId, {
125
+ state: 'local',
126
+ ...(language === undefined ? {} : { language }),
127
+ checkedAt: new Date().toISOString(),
128
+ ...(count === undefined ? {} : { speakers: count }),
129
+ ...(names === undefined ? {} : { names }),
130
+ });
131
+ },
132
+ });
91
133
  ctx.effect(() => () => {
92
134
  this.disposed = true;
135
+ this.local.dispose();
93
136
  if (this.timer !== undefined)
94
137
  clearTimeout(this.timer);
95
138
  }, 'reader: daily refresh');
@@ -158,7 +201,7 @@ export class ReaderService {
158
201
  return 'duplicate';
159
202
  let fetched;
160
203
  try {
161
- fetched = await this.fetchFollowing(upgrade ?? url);
204
+ fetched = await this.fetchSource(upgrade ?? url);
162
205
  }
163
206
  catch (error) {
164
207
  if (upgrade !== undefined && isNoHtmlVersion(error)) {
@@ -177,7 +220,7 @@ export class ReaderService {
177
220
  }
178
221
  }
179
222
  const feed = classifyPayload(fetched.raw) === 'feed';
180
- if (!feed) {
223
+ if (!feed && fetched.media !== true) {
181
224
  // A page that fetched fine can still have nothing to read: a bot
182
225
  // challenge, a login interstitial, an empty shell. Classify BEFORE the
183
226
  // payload is stored, so a challenge page never becomes the card's body —
@@ -186,13 +229,20 @@ export class ReaderService {
186
229
  const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
187
230
  if (problem !== undefined) {
188
231
  return await this.saveLinkOnly({
189
- url: fetched.url,
232
+ // The address the reader gave, not where the refusal landed: a
233
+ // challenge redirect (mp.weixin.qq.com → /wappoc_appmsgcaptcha)
234
+ // would otherwise become the card's title and the URL every later
235
+ // fetch — the rendered one included — asks for.
236
+ url,
190
237
  ...(request.label === undefined ? {} : { label: request.label }),
191
238
  ...(resolvedFrom === undefined ? {} : { resolvedFrom }),
192
239
  }, { code: problem.code, message: problem.message, at: now }, now);
193
240
  }
194
241
  }
195
- const label = request.label ?? (resolution?.kind === 'arxiv' ? resolution.title : undefined) ?? defaultSourceLabel(fetched.url);
242
+ const label = request.label
243
+ ?? (resolution?.kind === 'arxiv' ? resolution.title : undefined)
244
+ ?? (fetched.media === true ? syntheticLabel(fetched.raw) : undefined)
245
+ ?? defaultSourceLabel(fetched.url);
196
246
  const source = {
197
247
  id: `${feed ? 'rss' : 'link'}-${hash(fetched.url)}`,
198
248
  kind: feed ? 'rss' : 'link',
@@ -206,7 +256,7 @@ export class ReaderService {
206
256
  ...(resolvedFrom === undefined ? {} : { resolvedFrom }),
207
257
  raw: fetched.raw,
208
258
  };
209
- await this.commit(current => ({ ...current, sources: [source, ...current.sources] }));
259
+ await this.commit(current => withNewSource(current, source));
210
260
  return { outcome: feed ? 'subscribed' : 'saved-link', kind: source.kind, id: source.id, label };
211
261
  }
212
262
  /**
@@ -242,7 +292,7 @@ export class ReaderService {
242
292
  failure,
243
293
  ...(request.resolvedFrom === undefined ? {} : { resolvedFrom: request.resolvedFrom }),
244
294
  };
245
- await this.commit(current => ({ ...current, sources: [source, ...current.sources] }));
295
+ await this.commit(current => withNewSource(current, source));
246
296
  await this.recordFetchFailure(linkEntryId(source.id), failure.message);
247
297
  return {
248
298
  outcome: 'saved-link',
@@ -342,63 +392,80 @@ export class ReaderService {
342
392
  const results = [];
343
393
  const patches = new Map();
344
394
  const base = new Map(doc.sources.map(source => [source.id, source]));
345
- for (const source of wanted) {
346
- const fetchedAt = new Date().toISOString();
347
- try {
348
- const fetched = await this.fetchFollowing(source.url);
349
- // The same classification the add performs: a refresh that comes back
350
- // with a challenge page or a PDF must not replace a good body with it,
351
- // and must record why there is nothing to read.
352
- const problem = source.kind === 'link' ? inspectPreview(fetched.raw, fetched.url, source.url) : undefined;
353
- if (problem !== undefined) {
354
- const failure = { code: problem.code, message: problem.message, at: fetchedAt };
395
+ /**
396
+ * Failures to record AFTER the pool: the workers below run concurrently,
397
+ * but every document write (a failure record is its own commit) stays
398
+ * single-filed here — the store's commit is a read-modify-write, and two
399
+ * in flight at once is how a lost update happens.
400
+ */
401
+ const failures = [];
402
+ const queue = [...wanted];
403
+ const workers = Array.from({ length: Math.min(REFRESH_CONCURRENCY, queue.length) }, async () => {
404
+ for (;;) {
405
+ const source = queue.shift();
406
+ if (source === undefined)
407
+ return;
408
+ const fetchedAt = new Date().toISOString();
409
+ try {
410
+ const fetched = await this.fetchSource(source.url);
411
+ // The same classification the add performs: a refresh that comes back
412
+ // with a challenge page or a PDF must not replace a good body with it,
413
+ // and must record why there is nothing to read.
414
+ const problem = source.kind === 'link' && fetched.media !== true ? inspectPreview(fetched.raw, fetched.url, source.url) : undefined;
415
+ if (problem !== undefined) {
416
+ const failure = { code: problem.code, message: problem.message, at: fetchedAt };
417
+ patches.set(source.id, {
418
+ // Keep the last-known-good payload: a site that starts refusing us
419
+ // must not erase the article the reader already had.
420
+ // …and keep its address: the refusal's landing page is not it.
421
+ ...withoutFailure(source),
422
+ fetchedAt,
423
+ status: 'error',
424
+ error: failure.message,
425
+ failure,
426
+ });
427
+ failures.push({ entryId: linkEntryId(source.id), message: failure.message });
428
+ results.push({ id: source.id, status: 'unavailable', message: failure.message });
429
+ continue;
430
+ }
431
+ patches.set(source.id, {
432
+ ...withoutFailure(source),
433
+ url: fetched.url,
434
+ raw: fetched.raw,
435
+ fetchedAt,
436
+ status: 'ok',
437
+ truncated: fetched.truncated,
438
+ });
439
+ results.push({
440
+ id: source.id,
441
+ status: fetched.truncated ? 'truncated' : 'ok',
442
+ ...(fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}),
443
+ });
444
+ }
445
+ catch (error) {
446
+ const failure = classifyFetchFailure(error, fetchedAt);
447
+ // A failed refresh keeps the previous payload (last-known-good) and
448
+ // records why: a transient network error must not erase the reader's
449
+ // copy of an article.
450
+ const previous = base.get(source.id);
355
451
  patches.set(source.id, {
356
- // Keep the last-known-good payload: a site that starts refusing us
357
- // must not erase the article the reader already had.
358
- ...withoutFailure({ ...source, url: fetched.url, ...(source.raw === undefined ? {} : { raw: source.raw }) }),
452
+ ...(previous ?? source),
359
453
  fetchedAt,
360
454
  status: 'error',
361
455
  error: failure.message,
362
456
  failure,
363
457
  });
364
- await this.recordFetchFailure(linkEntryId(source.id), failure.message);
365
- results.push({ id: source.id, status: 'unavailable', message: failure.message });
366
- continue;
458
+ results.push({
459
+ id: source.id,
460
+ status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
461
+ message: failure.message,
462
+ });
367
463
  }
368
- patches.set(source.id, {
369
- ...withoutFailure(source),
370
- url: fetched.url,
371
- raw: fetched.raw,
372
- fetchedAt,
373
- status: 'ok',
374
- truncated: fetched.truncated,
375
- });
376
- results.push({
377
- id: source.id,
378
- status: fetched.truncated ? 'truncated' : 'ok',
379
- ...(fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}),
380
- });
381
464
  }
382
- catch (error) {
383
- const failure = classifyFetchFailure(error, fetchedAt);
384
- // A failed refresh keeps the previous payload (last-known-good) and
385
- // records why: a transient network error must not erase the reader's
386
- // copy of an article.
387
- const previous = base.get(source.id);
388
- patches.set(source.id, {
389
- ...(previous ?? source),
390
- fetchedAt,
391
- status: 'error',
392
- error: failure.message,
393
- failure,
394
- });
395
- results.push({
396
- id: source.id,
397
- status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
398
- message: failure.message,
399
- });
400
- }
401
- }
465
+ });
466
+ await Promise.all(workers);
467
+ for (const failure of failures)
468
+ await this.recordFetchFailure(failure.entryId, failure.message);
402
469
  if (patches.size > 0) {
403
470
  await this.commit(current => ({
404
471
  ...current,
@@ -531,6 +598,15 @@ export class ReaderService {
531
598
  await this.markFetching(request.entryId);
532
599
  try {
533
600
  const fetched = await this.fetchFollowing(url);
601
+ // A bot challenge answers 200 with its own little page (mp.weixin.qq.com's
602
+ // 「环境异常」, for one): extracted, it would be stored as the article.
603
+ // Record it as the refusal it is, so the browser half can escalate to a
604
+ // rendered fetch instead of showing the challenge text as the body.
605
+ const wall = inspectPreview(fetched.raw, fetched.url, url);
606
+ if (wall?.code === 'blocked') {
607
+ await this.recordFetchFailure(request.entryId, wall.message, wall.code);
608
+ return { entryId: request.entryId, error: wall.message };
609
+ }
534
610
  // The raw payload is written to disk BEFORE the caller gets it, so a
535
611
  // browser that goes away mid-request does not cost the reader the
536
612
  // download: the next visit finds the stored payload and extracts it.
@@ -549,6 +625,43 @@ export class ReaderService {
549
625
  return { entryId: request.entryId, error: failure.message };
550
626
  }
551
627
  }
628
+ /**
629
+ * One image the browser could not load cross-origin, fetched host-side.
630
+ *
631
+ * The pane calls this only for an `<img>` that already FAILED: the page's
632
+ * bytes arrived fine (a direct fetch gets a 200) but the origin answers them
633
+ * with `Cross-Origin-Resource-Policy: same-origin`, so the browser withholds
634
+ * them from this page no matter what the tag asks — measured on claude.dev's
635
+ * `/media/*` figures (2026-09-29), which broke with the alt text showing.
636
+ * CORP is an embedder-side check, so a host-process fetch is the way through.
637
+ *
638
+ * This deliberately does NOT ride `ctx.web.fetch`: the seam's decoded body is
639
+ * a closed html|text union and its provider refuses binary content types
640
+ * outright. The image fetch re-implements the seam's hardening at image
641
+ * scope instead (public-address validation with the connection pinned to the
642
+ * validated answers, a byte cap, a timeout, per-hop re-validation, and an
643
+ * image/* content-type gate) — see `image-fetch.ts`.
644
+ *
645
+ * Rescued bytes are kept in an in-memory LRU under a byte budget: the
646
+ * sidebar unmounts wholesale when another panel takes over, and a remount
647
+ * re-fails every blocked image before this cache can answer it.
648
+ *
649
+ * @param request - the image URL the pane failed on.
650
+ * @returns the bytes and their declared type, or why there are none.
651
+ */
652
+ async fetchImage(request) {
653
+ const url = normalizeUrl(request.url);
654
+ if (url === undefined)
655
+ return { url: request.url, error: 'invalid-url' };
656
+ const cached = this.imageCache.get(url);
657
+ if (cached !== undefined)
658
+ return { url, mime: cached.mime, base64: cached.base64 };
659
+ const fetched = await fetchImageBytes(url);
660
+ if (!fetched.ok)
661
+ return { url, error: fetched.error };
662
+ this.imageCache.set(url, fetched.mime, fetched.base64);
663
+ return { url, mime: fetched.mime, base64: fetched.base64 };
664
+ }
552
665
  /**
553
666
  * Which entries have a body, a stored raw payload, or a recorded failure.
554
667
  *
@@ -680,7 +793,7 @@ export class ReaderService {
680
793
  fetchedAt: now.toISOString(),
681
794
  // 0 = keep until the budget evicts it; a far-future date keeps the
682
795
  // freshness check a single comparison instead of a special case.
683
- expiresAt: ttlHours === 0
796
+ expiresAt: ttlHours === 0 || request.keep === true
684
797
  ? new Date(now.getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString()
685
798
  : new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(),
686
799
  url: request.url,
@@ -760,6 +873,9 @@ export class ReaderService {
760
873
  continue;
761
874
  if (entry.hasBody)
762
875
  continue;
876
+ // A video or podcast page is not an article: its text is the transcript.
877
+ if (mediaLink(entry.url) !== undefined)
878
+ continue;
763
879
  // A reason that will answer the same way forever — a bot wall, a login
764
880
  // wall, a PDF, a 404 — is not retried on a timer: that is a crawler with
765
881
  // a grudge, not a reader. The manual refresh in the subscription page
@@ -1370,6 +1486,128 @@ export class ReaderService {
1370
1486
  }
1371
1487
  throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
1372
1488
  }
1489
+ /**
1490
+ * Fetch a source's payload, media-aware (media-host.ts).
1491
+ *
1492
+ * A YouTube channel is fetched as YouTube's own channel feed; a Xiaoyuzhou
1493
+ * podcast page as the RSS document it is turned into; a single video or
1494
+ * Xiaoyuzhou episode as a synthetic page carrying its media description.
1495
+ * Everything else is the ordinary {@link fetchFollowing}.
1496
+ *
1497
+ * @param url - the source URL.
1498
+ */
1499
+ async fetchSource(url) {
1500
+ // A share short link (b23.tv) is a cross-site redirect the ordinary fetch
1501
+ // refuses by design: resolve it first, then treat the page it names.
1502
+ if (isShortMediaLink(url)) {
1503
+ const target = await resolveShortLink(url);
1504
+ if (target === '' || isShortMediaLink(target))
1505
+ throw new UnsupportedContent('the short link leads nowhere');
1506
+ return this.fetchSource(canonicalMediaUrl(target) ?? target);
1507
+ }
1508
+ const link = mediaLink(url);
1509
+ if (link === undefined)
1510
+ return this.fetchFollowing(url);
1511
+ if (link.provider === 'youtube' && link.kind === 'channel') {
1512
+ return { url, raw: channelRss(await this.media.ytdlp.channel(url), url), truncated: false };
1513
+ }
1514
+ if (link.provider === 'xiaoyuzhou') {
1515
+ const page = await this.fetchFollowing(url);
1516
+ if (link.kind === 'podcast') {
1517
+ const rss = podcastPageToRss(page.raw, url);
1518
+ if (rss === undefined)
1519
+ throw new UnsupportedContent('the podcast page carries no episodes');
1520
+ return { url, raw: rss, truncated: false };
1521
+ }
1522
+ const episode = readEpisodePage(page.raw);
1523
+ if (episode === undefined)
1524
+ throw new UnsupportedContent('the episode page carries no audio');
1525
+ return { url, raw: episodePage(episode), truncated: false, media: true };
1526
+ }
1527
+ if (link.kind === 'video' && (link.provider === 'youtube' || link.provider === 'bilibili')) {
1528
+ const clean = canonicalMediaUrl(url) ?? url;
1529
+ const info = await this.media.ytdlp.info(clean);
1530
+ return { url: clean, raw: videoPage(info, link.provider, link.id), truncated: false, media: true };
1531
+ }
1532
+ return this.fetchFollowing(url);
1533
+ }
1534
+ /**
1535
+ * A video entry's transcript: the stored one, else the platform captions
1536
+ * fetched now and kept (no TTL — it costs a yt-dlp run). A video known to
1537
+ * have no captions answers so without running yt-dlp again.
1538
+ *
1539
+ * @param request - the entry and its video page URL.
1540
+ * @returns the transcript markup, `none`, or why it failed.
1541
+ */
1542
+ async mediaTranscript(request) {
1543
+ const known = this.media.captions(request.entryId);
1544
+ // `local`: the platform had none — the stored body is the local transcript.
1545
+ if (known?.state === 'none' || known?.state === 'local')
1546
+ return { entryId: request.entryId, captions: 'none' };
1547
+ const link = mediaLink(request.url);
1548
+ if (link?.kind !== 'video' || (link.provider !== 'youtube' && link.provider !== 'bilibili')) {
1549
+ return { entryId: request.entryId, captions: 'none' };
1550
+ }
1551
+ try {
1552
+ const transcript = await this.media.transcript(request.url);
1553
+ if (transcript === 'none') {
1554
+ this.media.remember(request.entryId, { state: 'none', checkedAt: new Date().toISOString() });
1555
+ return { entryId: request.entryId, captions: 'none' };
1556
+ }
1557
+ const stored = await this.storeEntryBody({ entryId: request.entryId, url: request.url, html: transcript.html, keep: true });
1558
+ this.media.remember(request.entryId, { state: 'platform', language: transcript.language, checkedAt: new Date().toISOString() });
1559
+ return { entryId: request.entryId, captions: 'platform', language: transcript.language, ...(stored.html === undefined ? {} : { html: stored.html }) };
1560
+ }
1561
+ catch (error) {
1562
+ return { entryId: request.entryId, captions: 'failed', error: error instanceof Error ? error.message : String(error) };
1563
+ }
1564
+ }
1565
+ /** The reader's names for an entry's speakers, by index. */
1566
+ speakerNames(request) {
1567
+ return { ...(this.media.captions(request.entryId)?.names ?? {}) };
1568
+ }
1569
+ /**
1570
+ * Name one speaker of an entry's transcript (an empty name restores the default).
1571
+ *
1572
+ * @param request - the entry, the speaker's index, the name.
1573
+ */
1574
+ nameSpeaker(request) {
1575
+ const record = this.media.captions(request.entryId);
1576
+ if (record === undefined)
1577
+ return {};
1578
+ const names = { ...(record.names ?? {}) };
1579
+ const name = request.name.trim().slice(0, 40);
1580
+ if (name === '')
1581
+ delete names[String(request.speaker)];
1582
+ else
1583
+ names[String(request.speaker)] = name;
1584
+ this.media.remember(request.entryId, { ...record, names });
1585
+ return names;
1586
+ }
1587
+ /**
1588
+ * The wall's media cards: a live local job first, else what the captions
1589
+ * record says (platform captions, a stored local transcript, none).
1590
+ *
1591
+ * @param request - the card ids on screen.
1592
+ */
1593
+ mediaStates(request) {
1594
+ const states = {};
1595
+ for (const entryId of request.entryIds) {
1596
+ const job = this.local.status(entryId);
1597
+ if (job.state === 'fetching' || job.state === 'queued' || job.state === 'transcribing') {
1598
+ states[entryId] = { state: job.state, ...(job.progress === undefined ? {} : { progress: job.progress }), ...(job.ahead === undefined ? {} : { ahead: job.ahead }) };
1599
+ continue;
1600
+ }
1601
+ if (job.state === 'failed') {
1602
+ states[entryId] = { state: 'failed' };
1603
+ continue;
1604
+ }
1605
+ const record = this.media.captions(entryId);
1606
+ if (record !== undefined)
1607
+ states[entryId] = { state: record.state };
1608
+ }
1609
+ return { engine: this.local.engineAvailable(), states };
1610
+ }
1373
1611
  /** Arm (or re-arm) the daily refresh timer against the current document. */
1374
1612
  async armSchedule() {
1375
1613
  if (this.disposed)
@@ -1410,6 +1648,25 @@ export function normalizeUrl(input) {
1410
1648
  }
1411
1649
  }
1412
1650
  /** Whether two URLs point at the same target, ignoring a trailing slash. */
1651
+ /**
1652
+ * The document with `source` added, unless an add that raced this one already
1653
+ * filed it.
1654
+ *
1655
+ * `addSource` checks for a duplicate BEFORE its fetch, which takes long enough
1656
+ * (hundreds of ms to seconds) for a second click on 「添加」 to pass the same
1657
+ * check; both then committed, and the reader saw the card twice under one id
1658
+ * (measured on 3080, 2026-10-04: two `link-1v6imqy` rows 0.7 s apart). The
1659
+ * re-check inside the commit is what makes the add idempotent.
1660
+ *
1661
+ * @param doc - the current document.
1662
+ * @param source - the source to file.
1663
+ * @returns the document, with the source prepended when it is new.
1664
+ */
1665
+ function withNewSource(doc, source) {
1666
+ if (doc.sources.some(existing => existing.id === source.id || sameTarget(existing.url, source.url)))
1667
+ return doc;
1668
+ return { ...doc, sources: [source, ...doc.sources] };
1669
+ }
1413
1670
  function sameTarget(a, b) {
1414
1671
  const strip = (value) => value.replace(/\/+$/, '');
1415
1672
  return strip(a) === strip(b);
@@ -1474,7 +1731,7 @@ function errorCode(error) {
1474
1731
  return typeof code === 'string' ? code : undefined;
1475
1732
  }
1476
1733
  /** Marker words a bot wall leaves on an otherwise empty page. */
1477
- const BOT_WALL_PATTERN = /just a moment|checking your browser|cf-chl|cf_chl|attention required|unusual traffic|captcha|verifying you are human|enable javascript and cookies|access denied|are you a robot|验证码|人机验证|访问验证|安全验证/;
1734
+ const BOT_WALL_PATTERN = /just a moment|checking your browser|cf-chl|cf_chl|attention required|unusual traffic|captcha|verifying you are human|enable javascript and cookies|access denied|are you a robot|验证码|人机验证|访问验证|安全验证|环境异常|完成验证后/;
1478
1735
  /** Marker words an authentication interstitial leaves behind. */
1479
1736
  const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
1480
1737
  /** Hosts whose NAME alone says the hop is an authentication wall. */
@@ -1535,6 +1792,10 @@ export function classifyFetchFailure(error, at) {
1535
1792
  return { code: 'unsupported-type', message, at };
1536
1793
  }
1537
1794
  if (code === 'WEB_REDIRECT_BLOCKED' || /cross-origin redirect/i.test(message)) {
1795
+ // A cookie handshake (Springer Nature's idp bounce) is a wall a browser
1796
+ // walks through, not a login: `blocked` lets the pane render it.
1797
+ if (isCookieHandshake(message))
1798
+ return { code: 'blocked', message, at };
1538
1799
  // A hop to a host whose name says "login" is a login wall, not merely a
1539
1800
  // move: the reader's next action differs (sign in elsewhere vs paste the
1540
1801
  // destination URL).
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The `reader` skill: how to discuss an article from the user's reader, when
3
+ * to write into its notes, and how a 「让Agent考考我」 round runs. The tool
4
+ * descriptions stay one or two lines; this is loaded on demand (the canvas
5
+ * skill's pattern).
6
+ *
7
+ * The 考考我 contract is the 2026-10-19 design (proposals/active/
8
+ * 2026-10-19-reader-notes-feynman.md, D5–D7): the model plays a student, asks
9
+ * one batch of explain-it questions, and after the user's single answer writes
10
+ * down only what was not yet right — never a score.
11
+ *
12
+ * @module @khorsheed/dsh-reader/skill
13
+ */
14
+ /** The skill's catalog name. */
15
+ export declare const READER_SKILL_NAME = "reader";
16
+ /** The one catalog line the model sees before loading it. */
17
+ export declare const READER_SKILL_DESCRIPTION: string;
18
+ /** The skill body. */
19
+ export declare const READER_SKILL_CONTENT = "# reader \u2014 discussing an article and keeping what it came to\n\nThe user reads articles in the reader (\u7075\u611F\u7A7A\u95F4), keeps excerpts with comments (notes n1, n2\u2026), and brings them into\nthe conversation. A draft from the reader quotes notes as `n2 \u00B63\u300C\u2026\u300D` and names the article id.\n\n## Reading \u2014 reader_read\n\n- No arguments: the article this conversation discussed, else the one the user opened last. `entry` takes an id,\n a link or part of the title. Long articles page: continue with `from`.\n- The text is \u00B6-numbered; cite paragraphs as \u00B63 so the user can jump back. The notes and earlier discussion notes\n follow the text \u2014 use them, do not ask the user to paste.\n- An article the user never opened has no text on the host; say so and ask them to open it once.\n\n## Writing notes \u2014 reader_note\n\nWrite only when the user asks (\u300C\u8BB0\u5230\u7B14\u8BB0\u300D\u300C\u628A\u7ED3\u8BBA\u5B58\u4E0B\u6765\u300D\u2026) or a \u8003\u8003\u6211 round ends. Never on your own initiative,\nnever every turn. You cannot edit or delete what the user wrote; they can edit what you wrote.\n\n- A conclusion about ONE note: `op=conclusion`, `note=n2`, `text` = the conclusion in one or two sentences,\n in the user's language, as plain as possible.\n- Conclusions spanning several notes, or a whole discussion: `op=discussion`, `text` = one point per line; put\n `\u00B63` in a line to cite the paragraph it rests on.\n- If nested or optional arguments may not arrive, the text alone works: first lines `\u7B14\u8BB0\uFF1An2` or `\u7C7B\u578B\uFF1A\u8003\u8003\u6211`,\n then the body.\n\n## \u8BA9Agent\u8003\u8003\u6211 \u2014 a Feynman check, not an exam\n\nThe user finished the article and wants to see what they really understood by explaining it.\n\n1. Read the article and the notes with reader_read.\n2. Play a student who has NOT read it. Ask 3\u20135 questions in ONE message, numbered. Every question asks the user to\n explain in their own words \u2014 \u300C\u7ED9\u6211\u8BB2\u8BB2\u2026\u300D\u300C\u4E3A\u4EC0\u4E48\u2026\u300D\u300C\u6253\u4E2A\u6BD4\u65B9\uFF1F\u300D. No multiple choice, no fill-in-the-blank, no\n recall of numbers or names for their own sake.\n3. Pick questions from two places: what the user noted (do they really get it?) and the article's key points they\n did NOT note (likely skimmed).\n4. Wait for the user to answer all of them in one message. Do not judge, hint or correct while asking.\n5. Then, against the article: say in one line which answers were fine, and for each one that was wrong, missing a\n step, or vague (\u300C\u6548\u679C\u597D\u300D is vague \u2014 say what the actual reason is), give the specific explanation and the \u00B6 it\n rests on. Do not ask follow-up questions first; the user can keep talking if they want more.\n6. Write it down: `reader_note` with `op=discussion`, `kind=check`, one point per line \u2014 ONLY what was not yet\n right, each with its explanation and \u00B6; a last line \u300C\u5176\u4F59 N \u9898\u8BB2\u5230\u4F4D\u4E86\u300D when some were fine.\n\nNo score, no grade, no \"retake\": another round is simply another discussion note.\n\n## \u8BA9Agent\u6DA6\u8272\u8BD1\u6587 \u2014 reader_translate\n\nThe user asked for a better Chinese translation of the whole article than the browser's machine translation.\n\n1. Read the original with reader_read, a batch at a time (`from`). Translate from the ORIGINAL, whole paragraphs,\n with the paragraphs around them in mind: faithful, natural Chinese, not word for word. Keep names, terms and how\n each speaker is rendered consistent across batches. Do not add notes or explanations inside the translation.\n A transcript paragraph starts with `[mm:ss] \u540D\u5B57\uFF1A` \u2014 translate only the speech after it.\n2. Send each batch with reader_translate: `text` = lines `\u00B612\uFF1A\u8BD1\u6587`, one paragraph per line, the \u00B6 numbers\n reader_read showed. Its answer says how many are done and where the next batch starts.\n3. Keep going batch after batch in the same turn until it says every paragraph is done. Do not stop to ask. Follow\n any wish the user added to the request (\u300C\u53E3\u8BED\u4E00\u70B9\u300D\u2026).\n4. Finish with one line to the user: done, and anything you could not render well. Do not paste the translation\n into the conversation \u2014 it is in the reader.\n";
20
+ //# sourceMappingURL=skill.d.ts.map
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `reader` skill: how to discuss an article from the user's reader, when
3
+ * to write into its notes, and how a 「让Agent考考我」 round runs. The tool
4
+ * descriptions stay one or two lines; this is loaded on demand (the canvas
5
+ * skill's pattern).
6
+ *
7
+ * The 考考我 contract is the 2026-10-19 design (proposals/active/
8
+ * 2026-10-19-reader-notes-feynman.md, D5–D7): the model plays a student, asks
9
+ * one batch of explain-it questions, and after the user's single answer writes
10
+ * down only what was not yet right — never a score.
11
+ *
12
+ * @module @khorsheed/dsh-reader/skill
13
+ */
14
+ /** The skill's catalog name. */
15
+ export const READER_SKILL_NAME = 'reader';
16
+ /** The one catalog line the model sees before loading it. */
17
+ export const READER_SKILL_DESCRIPTION = 'How to work with the user\'s reader (灵感空间) articles and notes: reading with reader_read, when and how to write '
18
+ + 'conclusions and discussion notes with reader_note, how a 「让Agent考考我」 (quiz me) round runs, and how to 润色 (re-translate) an article with reader_translate. Load before reader_note or reader_translate.';
19
+ /** The skill body. */
20
+ export const READER_SKILL_CONTENT = `# reader — discussing an article and keeping what it came to
21
+
22
+ The user reads articles in the reader (灵感空间), keeps excerpts with comments (notes n1, n2…), and brings them into
23
+ the conversation. A draft from the reader quotes notes as \`n2 ¶3「…」\` and names the article id.
24
+
25
+ ## Reading — reader_read
26
+
27
+ - No arguments: the article this conversation discussed, else the one the user opened last. \`entry\` takes an id,
28
+ a link or part of the title. Long articles page: continue with \`from\`.
29
+ - The text is ¶-numbered; cite paragraphs as ¶3 so the user can jump back. The notes and earlier discussion notes
30
+ follow the text — use them, do not ask the user to paste.
31
+ - An article the user never opened has no text on the host; say so and ask them to open it once.
32
+
33
+ ## Writing notes — reader_note
34
+
35
+ Write only when the user asks (「记到笔记」「把结论存下来」…) or a 考考我 round ends. Never on your own initiative,
36
+ never every turn. You cannot edit or delete what the user wrote; they can edit what you wrote.
37
+
38
+ - A conclusion about ONE note: \`op=conclusion\`, \`note=n2\`, \`text\` = the conclusion in one or two sentences,
39
+ in the user's language, as plain as possible.
40
+ - Conclusions spanning several notes, or a whole discussion: \`op=discussion\`, \`text\` = one point per line; put
41
+ \`¶3\` in a line to cite the paragraph it rests on.
42
+ - If nested or optional arguments may not arrive, the text alone works: first lines \`笔记:n2\` or \`类型:考考我\`,
43
+ then the body.
44
+
45
+ ## 让Agent考考我 — a Feynman check, not an exam
46
+
47
+ The user finished the article and wants to see what they really understood by explaining it.
48
+
49
+ 1. Read the article and the notes with reader_read.
50
+ 2. Play a student who has NOT read it. Ask 3–5 questions in ONE message, numbered. Every question asks the user to
51
+ explain in their own words — 「给我讲讲…」「为什么…」「打个比方?」. No multiple choice, no fill-in-the-blank, no
52
+ recall of numbers or names for their own sake.
53
+ 3. Pick questions from two places: what the user noted (do they really get it?) and the article's key points they
54
+ did NOT note (likely skimmed).
55
+ 4. Wait for the user to answer all of them in one message. Do not judge, hint or correct while asking.
56
+ 5. Then, against the article: say in one line which answers were fine, and for each one that was wrong, missing a
57
+ step, or vague (「效果好」 is vague — say what the actual reason is), give the specific explanation and the ¶ it
58
+ rests on. Do not ask follow-up questions first; the user can keep talking if they want more.
59
+ 6. Write it down: \`reader_note\` with \`op=discussion\`, \`kind=check\`, one point per line — ONLY what was not yet
60
+ right, each with its explanation and ¶; a last line 「其余 N 题讲到位了」 when some were fine.
61
+
62
+ No score, no grade, no "retake": another round is simply another discussion note.
63
+
64
+ ## 让Agent润色译文 — reader_translate
65
+
66
+ The user asked for a better Chinese translation of the whole article than the browser's machine translation.
67
+
68
+ 1. Read the original with reader_read, a batch at a time (\`from\`). Translate from the ORIGINAL, whole paragraphs,
69
+ with the paragraphs around them in mind: faithful, natural Chinese, not word for word. Keep names, terms and how
70
+ each speaker is rendered consistent across batches. Do not add notes or explanations inside the translation.
71
+ A transcript paragraph starts with \`[mm:ss] 名字:\` — translate only the speech after it.
72
+ 2. Send each batch with reader_translate: \`text\` = lines \`¶12:译文\`, one paragraph per line, the ¶ numbers
73
+ reader_read showed. Its answer says how many are done and where the next batch starts.
74
+ 3. Keep going batch after batch in the same turn until it says every paragraph is done. Do not stop to ask. Follow
75
+ any wish the user added to the request (「口语一点」…).
76
+ 4. Finish with one line to the user: done, and anything you could not render well. Do not paste the translation
77
+ into the conversation — it is in the reader.
78
+ `;