@khorsheed/dsh-reader 0.3.0 → 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 (87) hide show
  1. package/CHANGELOG.md +22 -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 +29054 -6698
  7. package/lib/index.js +3416 -334
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  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 +860 -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 +130 -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 +81 -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/index.d.ts +6 -0
  54. package/lib/types/index.js +3 -0
  55. package/lib/types/local-transcribe.d.ts +171 -0
  56. package/lib/types/local-transcribe.js +473 -0
  57. package/lib/types/media-host.d.ts +91 -0
  58. package/lib/types/media-host.js +230 -0
  59. package/lib/types/media.d.ts +100 -0
  60. package/lib/types/media.js +207 -0
  61. package/lib/types/notes-types.d.ts +158 -0
  62. package/lib/types/notes-types.js +8 -0
  63. package/lib/types/notes.d.ts +213 -0
  64. package/lib/types/notes.js +718 -0
  65. package/lib/types/polish.d.ts +41 -0
  66. package/lib/types/polish.js +136 -0
  67. package/lib/types/remote.d.ts +118 -0
  68. package/lib/types/remote.js +151 -1
  69. package/lib/types/service.d.ts +71 -1
  70. package/lib/types/service.js +280 -59
  71. package/lib/types/skill.d.ts +20 -0
  72. package/lib/types/skill.js +78 -0
  73. package/lib/types/store.js +30 -8
  74. package/lib/types/tool.d.ts +23 -0
  75. package/lib/types/tool.js +40 -0
  76. package/lib/types/tools.d.ts +69 -0
  77. package/lib/types/tools.js +400 -0
  78. package/lib/types/types.d.ts +87 -0
  79. package/lib/types/types.js +66 -0
  80. package/lib/types/xiaoyuzhou.d.ts +43 -0
  81. package/lib/types/xiaoyuzhou.js +134 -0
  82. package/lib/types/ytdlp.d.ts +111 -0
  83. package/lib/types/ytdlp.js +269 -0
  84. package/package.json +29 -20
  85. package/lib/invariant.js +0 -38
  86. package/lib/types/invariant.d.ts +0 -29
  87. package/lib/types/invariant.js +0 -38
@@ -1,9 +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
5
  import { fetchImageBytes, ImageFetchCache } from "./image-fetch.js";
6
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";
7
12
  /**
8
13
  * How many entries one automatic backfill run may fetch.
9
14
  *
@@ -14,6 +19,15 @@ import { resolveLink } from "./link-resolvers.js";
14
19
  export const BACKFILL_MAX_PER_RUN = 8;
15
20
  /** How long a FAILED fetch is left alone before it may be retried (6 hours). */
16
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;
17
31
  /** Cross-origin redirect hops the service will follow before giving up. */
18
32
  const MAX_REDIRECT_HOPS = 3;
19
33
  /** Upper bound on summaries handed to the browser in one call. */
@@ -77,6 +91,10 @@ export function classifyPayload(raw) {
77
91
  /** The reader service. */
78
92
  export class ReaderService {
79
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;
80
98
  ctx;
81
99
  timer;
82
100
  disposed = false;
@@ -91,8 +109,30 @@ export class ReaderService {
91
109
  constructor(ctx, config = {}) {
92
110
  this.ctx = ctx;
93
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
+ });
94
133
  ctx.effect(() => () => {
95
134
  this.disposed = true;
135
+ this.local.dispose();
96
136
  if (this.timer !== undefined)
97
137
  clearTimeout(this.timer);
98
138
  }, 'reader: daily refresh');
@@ -161,7 +201,7 @@ export class ReaderService {
161
201
  return 'duplicate';
162
202
  let fetched;
163
203
  try {
164
- fetched = await this.fetchFollowing(upgrade ?? url);
204
+ fetched = await this.fetchSource(upgrade ?? url);
165
205
  }
166
206
  catch (error) {
167
207
  if (upgrade !== undefined && isNoHtmlVersion(error)) {
@@ -180,7 +220,7 @@ export class ReaderService {
180
220
  }
181
221
  }
182
222
  const feed = classifyPayload(fetched.raw) === 'feed';
183
- if (!feed) {
223
+ if (!feed && fetched.media !== true) {
184
224
  // A page that fetched fine can still have nothing to read: a bot
185
225
  // challenge, a login interstitial, an empty shell. Classify BEFORE the
186
226
  // payload is stored, so a challenge page never becomes the card's body —
@@ -189,13 +229,20 @@ export class ReaderService {
189
229
  const problem = inspectPreview(fetched.raw, fetched.url, upgrade ?? url);
190
230
  if (problem !== undefined) {
191
231
  return await this.saveLinkOnly({
192
- 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,
193
237
  ...(request.label === undefined ? {} : { label: request.label }),
194
238
  ...(resolvedFrom === undefined ? {} : { resolvedFrom }),
195
239
  }, { code: problem.code, message: problem.message, at: now }, now);
196
240
  }
197
241
  }
198
- 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);
199
246
  const source = {
200
247
  id: `${feed ? 'rss' : 'link'}-${hash(fetched.url)}`,
201
248
  kind: feed ? 'rss' : 'link',
@@ -209,7 +256,7 @@ export class ReaderService {
209
256
  ...(resolvedFrom === undefined ? {} : { resolvedFrom }),
210
257
  raw: fetched.raw,
211
258
  };
212
- await this.commit(current => ({ ...current, sources: [source, ...current.sources] }));
259
+ await this.commit(current => withNewSource(current, source));
213
260
  return { outcome: feed ? 'subscribed' : 'saved-link', kind: source.kind, id: source.id, label };
214
261
  }
215
262
  /**
@@ -245,7 +292,7 @@ export class ReaderService {
245
292
  failure,
246
293
  ...(request.resolvedFrom === undefined ? {} : { resolvedFrom: request.resolvedFrom }),
247
294
  };
248
- await this.commit(current => ({ ...current, sources: [source, ...current.sources] }));
295
+ await this.commit(current => withNewSource(current, source));
249
296
  await this.recordFetchFailure(linkEntryId(source.id), failure.message);
250
297
  return {
251
298
  outcome: 'saved-link',
@@ -345,63 +392,80 @@ export class ReaderService {
345
392
  const results = [];
346
393
  const patches = new Map();
347
394
  const base = new Map(doc.sources.map(source => [source.id, source]));
348
- for (const source of wanted) {
349
- const fetchedAt = new Date().toISOString();
350
- try {
351
- const fetched = await this.fetchFollowing(source.url);
352
- // The same classification the add performs: a refresh that comes back
353
- // with a challenge page or a PDF must not replace a good body with it,
354
- // and must record why there is nothing to read.
355
- const problem = source.kind === 'link' ? inspectPreview(fetched.raw, fetched.url, source.url) : undefined;
356
- if (problem !== undefined) {
357
- 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
+ }
358
431
  patches.set(source.id, {
359
- // Keep the last-known-good payload: a site that starts refusing us
360
- // must not erase the article the reader already had.
361
- ...withoutFailure({ ...source, url: fetched.url, ...(source.raw === undefined ? {} : { raw: source.raw }) }),
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);
451
+ patches.set(source.id, {
452
+ ...(previous ?? source),
362
453
  fetchedAt,
363
454
  status: 'error',
364
455
  error: failure.message,
365
456
  failure,
366
457
  });
367
- await this.recordFetchFailure(linkEntryId(source.id), failure.message);
368
- results.push({ id: source.id, status: 'unavailable', message: failure.message });
369
- continue;
458
+ results.push({
459
+ id: source.id,
460
+ status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
461
+ message: failure.message,
462
+ });
370
463
  }
371
- patches.set(source.id, {
372
- ...withoutFailure(source),
373
- url: fetched.url,
374
- raw: fetched.raw,
375
- fetchedAt,
376
- status: 'ok',
377
- truncated: fetched.truncated,
378
- });
379
- results.push({
380
- id: source.id,
381
- status: fetched.truncated ? 'truncated' : 'ok',
382
- ...(fetched.truncated ? { message: "payload hit the fetch seam's size cap" } : {}),
383
- });
384
464
  }
385
- catch (error) {
386
- const failure = classifyFetchFailure(error, fetchedAt);
387
- // A failed refresh keeps the previous payload (last-known-good) and
388
- // records why: a transient network error must not erase the reader's
389
- // copy of an article.
390
- const previous = base.get(source.id);
391
- patches.set(source.id, {
392
- ...(previous ?? source),
393
- fetchedAt,
394
- status: 'error',
395
- error: failure.message,
396
- failure,
397
- });
398
- results.push({
399
- id: source.id,
400
- status: error instanceof UnsupportedContent ? 'unavailable' : 'fetch-failed',
401
- message: failure.message,
402
- });
403
- }
404
- }
465
+ });
466
+ await Promise.all(workers);
467
+ for (const failure of failures)
468
+ await this.recordFetchFailure(failure.entryId, failure.message);
405
469
  if (patches.size > 0) {
406
470
  await this.commit(current => ({
407
471
  ...current,
@@ -534,6 +598,15 @@ export class ReaderService {
534
598
  await this.markFetching(request.entryId);
535
599
  try {
536
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
+ }
537
610
  // The raw payload is written to disk BEFORE the caller gets it, so a
538
611
  // browser that goes away mid-request does not cost the reader the
539
612
  // download: the next visit finds the stored payload and extracts it.
@@ -720,7 +793,7 @@ export class ReaderService {
720
793
  fetchedAt: now.toISOString(),
721
794
  // 0 = keep until the budget evicts it; a far-future date keeps the
722
795
  // freshness check a single comparison instead of a special case.
723
- expiresAt: ttlHours === 0
796
+ expiresAt: ttlHours === 0 || request.keep === true
724
797
  ? new Date(now.getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toISOString()
725
798
  : new Date(now.getTime() + ttlHours * 60 * 60 * 1000).toISOString(),
726
799
  url: request.url,
@@ -800,6 +873,9 @@ export class ReaderService {
800
873
  continue;
801
874
  if (entry.hasBody)
802
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;
803
879
  // A reason that will answer the same way forever — a bot wall, a login
804
880
  // wall, a PDF, a 404 — is not retried on a timer: that is a crawler with
805
881
  // a grudge, not a reader. The manual refresh in the subscription page
@@ -1410,6 +1486,128 @@ export class ReaderService {
1410
1486
  }
1411
1487
  throw new UnsupportedContent(`more than ${MAX_REDIRECT_HOPS} cross-origin redirects`);
1412
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
+ }
1413
1611
  /** Arm (or re-arm) the daily refresh timer against the current document. */
1414
1612
  async armSchedule() {
1415
1613
  if (this.disposed)
@@ -1450,6 +1648,25 @@ export function normalizeUrl(input) {
1450
1648
  }
1451
1649
  }
1452
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
+ }
1453
1670
  function sameTarget(a, b) {
1454
1671
  const strip = (value) => value.replace(/\/+$/, '');
1455
1672
  return strip(a) === strip(b);
@@ -1514,7 +1731,7 @@ function errorCode(error) {
1514
1731
  return typeof code === 'string' ? code : undefined;
1515
1732
  }
1516
1733
  /** Marker words a bot wall leaves on an otherwise empty page. */
1517
- 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|验证码|人机验证|访问验证|安全验证|环境异常|完成验证后/;
1518
1735
  /** Marker words an authentication interstitial leaves behind. */
1519
1736
  const LOGIN_WALL_PATTERN = /cookie required|requires? cookies|sign in|sign-in|log in|login|please log|single sign|sso|qurl=|ezproxy|shibboleth|访问权限|请登录|登录后/;
1520
1737
  /** Hosts whose NAME alone says the hop is an authentication wall. */
@@ -1575,6 +1792,10 @@ export function classifyFetchFailure(error, at) {
1575
1792
  return { code: 'unsupported-type', message, at };
1576
1793
  }
1577
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 };
1578
1799
  // A hop to a host whose name says "login" is a login wall, not merely a
1579
1800
  // move: the reader's next action differs (sign in elsewhere vs paste the
1580
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
+ `;