@hraness/sponge 0.6.7 → 0.6.9

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 (40) hide show
  1. package/README.md +42 -0
  2. package/dist/capture/worker.js +154 -152
  3. package/dist/contracts/index.d.ts +2 -0
  4. package/dist/src/acquisition/capture.d.ts +4 -0
  5. package/dist/src/acquisition/capture.js +8 -2
  6. package/dist/src/acquisition/child-request.d.ts +5 -0
  7. package/dist/src/acquisition/normalize.js +3 -0
  8. package/dist/src/app/archive.d.ts +15 -0
  9. package/dist/src/app/archive.js +51 -0
  10. package/dist/src/app/article.d.ts +4 -0
  11. package/dist/src/app/article.js +24 -10
  12. package/dist/src/app/markdown.d.ts +2 -0
  13. package/dist/src/app/markdown.js +6 -0
  14. package/dist/src/browser/bridge.d.ts +48 -0
  15. package/dist/src/browser/bridge.js +90 -0
  16. package/dist/src/browser/host.d.ts +10 -0
  17. package/dist/src/browser/host.js +147 -0
  18. package/dist/src/browser/setup.d.ts +26 -0
  19. package/dist/src/browser/setup.js +103 -0
  20. package/dist/src/capture/network.d.ts +56 -0
  21. package/dist/src/capture/network.js +453 -0
  22. package/dist/src/capture/types.d.ts +26 -0
  23. package/dist/src/capture/types.js +1 -0
  24. package/dist/src/cli/commands.js +114 -12
  25. package/dist/src/cli/status.d.ts +7 -0
  26. package/dist/src/cli/status.js +33 -0
  27. package/dist/src/errors.d.ts +1 -0
  28. package/dist/src/errors.js +2 -0
  29. package/dist/src/pdf/handler.d.ts +2 -0
  30. package/dist/src/pdf/handler.js +7 -2
  31. package/dist/src/store/queue.d.ts +5 -1
  32. package/dist/src/store/queue.js +10 -4
  33. package/dist/src/store/types.d.ts +5 -1
  34. package/dist/src/worker/basic.js +15 -0
  35. package/extension/background.js +180 -0
  36. package/extension/manifest.json +25 -0
  37. package/extension/popup.html +85 -0
  38. package/extension/popup.js +39 -0
  39. package/native/runtime-manifest.json +5 -5
  40. package/package.json +2 -1
@@ -8,7 +8,7 @@ import { queueAction, queueText } from './queue.js';
8
8
  import { portabilityAction } from './portability.js';
9
9
  import { restoreLibrary } from '../portability/backup.js';
10
10
  import { workerAction, serveWorker, hasDueAcquisition, workerDoctor } from './worker.js';
11
- import { libraryStatus, statusText } from './status.js';
11
+ import { libraryStatus, shortCaptures, statusText } from './status.js';
12
12
  import { nativeSetupPlan, provisionNative, inspectNative, optionalNative } from '../models/runtime.js';
13
13
  import { digestModel, digestSetupPlan, inspectDigest, provisionDigest } from '../models/digest.js';
14
14
  import { withDigestServer } from '../digest/server.js';
@@ -24,10 +24,31 @@ import { createDigest, undigestible } from '../digest/pipeline.js';
24
24
  import { createQueryEmbedder, createTextEmbedder } from '../search/query.js';
25
25
  import { runtimeGcAction, runtimeGcText, setupRuntimeGc } from './runtimes.js';
26
26
  import { drainLibrary } from '../worker/basic.js';
27
+ import { browserStatus, removeBrowser, setupBrowser } from '../browser/setup.js';
27
28
  import { minAge, publishSite } from '../publish/index.js';
28
29
  import { SpongeError, codeOf, publicCode, exitFor, isSpongeCode } from '../errors.js';
29
30
  const abs = (v) => typeof v === 'string' && isAbsolute(v);
30
31
  const media = { language: 'value', 'force-local': 'boolean', 'allow-private-network': 'boolean' };
32
+ /** Only a public web article or PDF has an Internet Archive copy; a signed-in page's copy would not be the reader's. */
33
+ function archivable(kind, locator, connection) {
34
+ if (kind === 'media' || connection || !/^https?:/.test(locator))
35
+ throw new SpongeError('unsupported');
36
+ }
37
+ /** The extension captures web articles; PDFs and media keep their own fetchers. */
38
+ function browsable(kind, locator, connection) {
39
+ if (kind !== 'article' || connection || !/^https?:/.test(locator))
40
+ throw new SpongeError('unsupported');
41
+ }
42
+ /** `--archive` or `--browser`: at most one route. */
43
+ function route(flags, kind, locator, connection) {
44
+ if (flags.archive && flags.browser)
45
+ throw new SpongeError('invalid-input');
46
+ if (flags.archive)
47
+ return (archivable(kind, locator, connection), 'archive');
48
+ if (flags.browser)
49
+ return (browsable(kind, locator, connection), 'browser');
50
+ return undefined;
51
+ }
31
52
  const lines = (items) => items.map((item) => `${item.itemId}\t${item.title}`);
32
53
  /** The failure of a job that did not run here: its state, or its recorded reason. */
33
54
  function jobFailure(store, jobId) {
@@ -78,6 +99,10 @@ async function drain(c, jobId, connections) {
78
99
  }
79
100
  const failed = results.find((result) => !result.ok);
80
101
  if (failed && !failed.ok) {
102
+ // A narrower recorded reason, such as browser-offline, prints with its own hint.
103
+ const reason = jobId && store.queue.list().find((j) => j.id === jobId)?.reason;
104
+ if (isSpongeCode(reason) && reason !== failed.code && publicCode(reason) === failed.code)
105
+ throw jobFailure(store, jobId);
81
106
  process.stderr.write(JSON.stringify(failed) + '\n');
82
107
  return exitFor(failed.code);
83
108
  }
@@ -169,9 +194,13 @@ export const commands = [
169
194
  {
170
195
  name: 'add',
171
196
  summary: 'Save an article, PDF or media source',
172
- usage: [['add URL_OR_PDF [--kind article|pdf|media] [--connection ID] [--wait]', '']],
197
+ usage: [
198
+ ['add URL_OR_PDF [--kind article|pdf|media] [--connection ID] [--wait]', ''],
199
+ ['add URL --archive [--wait]', 'Capture the newest Internet Archive copy of a dead or paywalled page'],
200
+ ['add URL --browser [--wait]', 'Capture through your browser and its sign-ins (sponge browser setup)'],
201
+ ],
173
202
  positional: 1,
174
- flags: { connection: 'value', kind: 'value', wait: 'boolean', ...media },
203
+ flags: { connection: 'value', kind: 'value', wait: 'boolean', archive: 'boolean', browser: 'boolean', ...media },
175
204
  library: 'open',
176
205
  handler: async (c) => {
177
206
  const { store, args } = c, connections = await readConnections(store.root);
@@ -180,7 +209,13 @@ export const commands = [
180
209
  throw new SpongeError('invalid-input');
181
210
  const input = args.positional[0], kind = (args.flags.kind ?? inferKind(input));
182
211
  const source = (kind === 'pdf' ? pdfSource : kind === 'media' ? mediaSource : articleSource)(input, connection?.realm ?? 'public');
183
- const submission = store.queue.submit({ kind, ...source, idempotencyKey: 'save:' + source.sourceKey }), { jobId, itemId } = submission;
212
+ const via = route(args.flags, kind, source.locator, connection);
213
+ const submission = store.queue.submit({
214
+ kind,
215
+ ...source,
216
+ ...(via ? { intent: via } : {}),
217
+ idempotencyKey: `${via ?? 'save'}:${source.sourceKey}`,
218
+ }), { jobId, itemId } = submission;
184
219
  const job = store.queue.list().find((j) => j.id === jobId);
185
220
  if (!args.flags.wait) {
186
221
  c.emit({ ...submission, state: job?.state ?? 'queued' }, `${itemId}\t${job?.state ?? 'queued'}`);
@@ -242,11 +277,19 @@ export const commands = [
242
277
  {
243
278
  name: 'list',
244
279
  summary: 'List saved and queued items',
245
- usage: [['list', 'List saved and queued items']],
280
+ usage: [
281
+ ['list', 'List saved and queued items'],
282
+ ['list --short', 'Web articles saved with under 200 words: paywalls, error pages, script-only pages'],
283
+ ],
246
284
  positional: 0,
247
- flags: {},
285
+ flags: { short: 'boolean' },
248
286
  library: 'open',
249
287
  handler: (c) => {
288
+ if (c.args.flags.short) {
289
+ const items = shortCaptures(c.store);
290
+ c.emit({ items }, items.map((item) => `${item.itemId}\t${item.words}\t${item.title}`).join('\n'));
291
+ return 0;
292
+ }
250
293
  const items = c.store.list();
251
294
  c.emit({ items }, lines(items).join('\n'));
252
295
  return 0;
@@ -329,18 +372,24 @@ export const commands = [
329
372
  {
330
373
  name: 'refresh',
331
374
  summary: 'Capture a new revision',
332
- usage: [['refresh ITEM [--wait]', 'Capture a new revision']],
375
+ usage: [
376
+ ['refresh ITEM [--wait]', 'Capture a new revision'],
377
+ ['refresh ITEM --archive [--wait]', 'Capture the newest Internet Archive copy instead of the live page'],
378
+ ['refresh ITEM --browser [--wait]', 'Capture through your browser and its sign-ins'],
379
+ ],
333
380
  positional: 1,
334
- flags: { wait: 'boolean', ...media },
381
+ flags: { wait: 'boolean', archive: 'boolean', browser: 'boolean', ...media },
335
382
  library: 'open',
336
383
  handler: async (c) => {
337
384
  const { store, args } = c, connections = await readConnections(store.root), previous = store.queue.list().find((j) => j.item_id === args.positional[0]), source = previous && store.queue.source(String(previous.id));
338
385
  if (!source || !['article', 'pdf', 'media'].includes(source.kind))
339
386
  throw new SpongeError('unavailable');
387
+ const { via: _, ...rest } = source, kind = source.kind;
388
+ const via = route(args.flags, kind, source.locator, source.realm.startsWith('connection:') || undefined);
340
389
  const submission = store.queue.submit({
341
- ...source,
342
- kind: source.kind,
343
- intent: 'refresh',
390
+ ...rest,
391
+ kind,
392
+ intent: via ?? 'refresh',
344
393
  idempotencyKey: randomUUID(),
345
394
  });
346
395
  if (!args.flags.wait) {
@@ -433,10 +482,17 @@ export const commands = [
433
482
  catch (error) {
434
483
  digest = publicCode(error, 'runtime-missing');
435
484
  }
485
+ const browser = await browserStatus(store.root), browserState = browser.connected
486
+ ? browser.background
487
+ ? 'connected'
488
+ : 'connected, background captures off'
489
+ : browser.registered.length
490
+ ? 'not connected'
491
+ : 'not set up';
436
492
  const cleanup = store.queue.pendingCleanup(), worker = await workerDoctor(store, c.version), workerBroken = !['ready', 'not-installed', 'unsupported'].includes(worker.worker);
437
493
  // Each runtime that is not ready names the command that installs it.
438
494
  const line = (label, state, fix) => `${label}: ${state}${state === 'runtime-missing' ? ` (${fix})` : ''}`;
439
- c.emit({ library: 'ready', capture, pdf, ...states, digest, pendingCleanup: cleanup, worker }, [
495
+ c.emit({ library: 'ready', capture, pdf, ...states, digest, browser: browserState, pendingCleanup: cleanup, worker }, [
440
496
  'Library: ready',
441
497
  `Capture: ${capture}`,
442
498
  `PDF: ${pdf === 'ready' ? 'ready' : 'Poppler not found (brew install poppler)'}`,
@@ -444,6 +500,7 @@ export const commands = [
444
500
  line('Speech-to-text', states.asr, 'sponge setup --asr'),
445
501
  line('Embedding', states.embedding, 'sponge setup --embedding'),
446
502
  line('Digest model', digest, 'sponge setup --digest'),
503
+ `Browser: ${browserState}${browserState === 'not set up' ? ' (optional: sponge browser setup)' : ''}`,
447
504
  `Pending cleanup: ${cleanup}${cleanup ? ' (sponge queue recover)' : ''}`,
448
505
  `Worker: ${worker.worker}${Array.isArray(worker.drift) ? ` (${worker.drift.join(', ')})` : ''}${worker.repair ? `\n ${worker.repair}` : ''}`,
449
506
  ].join('\n'));
@@ -562,6 +619,51 @@ export const commands = [
562
619
  return 0;
563
620
  },
564
621
  },
622
+ {
623
+ name: 'browser',
624
+ summary: 'Capture through your own browser and its sign-ins',
625
+ usage: [
626
+ ['browser setup [--hosts-dir DIR]', 'Connect the Sponge extension in Chrome and other Chromium browsers'],
627
+ ['browser status', 'Show which browsers are set up and whether the extension is connected'],
628
+ ['browser remove', 'Disconnect Sponge from every browser'],
629
+ ],
630
+ positional: 1,
631
+ flags: { 'hosts-dir': 'value' },
632
+ library: 'open',
633
+ check: (p) => ['setup', 'status', 'remove'].includes(p[0]),
634
+ handler: async (c) => {
635
+ const { store, args } = c, hostsDir = args.flags['hosts-dir'];
636
+ if (hostsDir !== undefined && !isAbsolute(hostsDir))
637
+ throw new SpongeError('invalid-input');
638
+ const options = hostsDir ? { hostsDirs: [hostsDir] } : {};
639
+ if (args.positional[0] === 'setup') {
640
+ const result = await setupBrowser(store.root, options);
641
+ c.emit(result, [
642
+ result.registered.length
643
+ ? `Registered with: ${result.registered.join(', ')}`
644
+ : 'No Chromium browser found. Install Chrome, or pass --hosts-dir.',
645
+ 'Then load the extension once:',
646
+ ' 1. Open chrome://extensions and turn on Developer mode',
647
+ ` 2. Click "Load unpacked" and choose ${result.extension}`,
648
+ ' 3. Pin Sponge, then check `sponge browser status`',
649
+ ].join('\n'));
650
+ return result.registered.length ? 0 : 4;
651
+ }
652
+ if (args.positional[0] === 'remove') {
653
+ const result = await removeBrowser(options);
654
+ c.emit(result, result.removed.length ? `Removed from: ${result.removed.join(', ')}` : 'Nothing to remove');
655
+ return 0;
656
+ }
657
+ const status = await browserStatus(store.root, options);
658
+ c.emit(status, [
659
+ `Registered: ${status.registered.join(', ') || 'none (sponge browser setup)'}`,
660
+ `Library: ${status.bound ? 'this one' : 'another library (sponge browser setup to switch)'}`,
661
+ `Extension: ${status.connected ? `connected${status.extension ? ` (${status.extension})` : ''}` : 'not connected'}`,
662
+ ...(status.connected ? [`Background captures: ${status.background ? 'on' : 'off'}`] : []),
663
+ ].join('\n'));
664
+ return status.connected ? 0 : 4;
665
+ },
666
+ },
565
667
  {
566
668
  name: 'import',
567
669
  summary: 'Import existing captures',
@@ -5,6 +5,12 @@ import type { Row } from '../store/context.js';
5
5
  * newest job counts: a failure that a later capture of the item (or index job) fixed is history too.
6
6
  */
7
7
  export declare function openJobs(store: LibraryStore): Row[];
8
+ /** Saved web articles under `shortWords` prose words, other than social posts: candidates for `refresh --archive`. */
9
+ export declare function shortCaptures(store: LibraryStore): {
10
+ itemId: string;
11
+ title: string;
12
+ words: number;
13
+ }[];
8
14
  /** What the library holds, what is waiting or failed, and what to run next: one screen for `sponge status`. */
9
15
  export declare function libraryStatus(store: LibraryStore): {
10
16
  items: {
@@ -22,6 +28,7 @@ export declare function libraryStatus(store: LibraryStore): {
22
28
  draft: number;
23
29
  none: number;
24
30
  };
31
+ short: number;
25
32
  jobs: {
26
33
  operation: string;
27
34
  state: string;
@@ -1,3 +1,4 @@
1
+ import { proseWords } from '../app/markdown.js';
1
2
  /**
2
3
  * Jobs still waiting, running or needing the reader, oldest first. Settled jobs are history, and only each target's
3
4
  * newest job counts: a failure that a later capture of the item (or index job) fixed is history too.
@@ -8,6 +9,32 @@ export function openJobs(store) {
8
9
  newest.set(job.operation === 'index' ? 'index' : `${job.operation} ${job.item_id}`, job);
9
10
  return [...newest.values()].filter((job) => !['succeeded', 'cancelled'].includes(String(job.state)));
10
11
  }
12
+ /** Prose words under which a saved article is likely a paywall stub, an error page or a page that needed scripts. */
13
+ const shortWords = 200;
14
+ // Posts are short by nature; their length says nothing about whether the capture worked.
15
+ const postHosts = new Set(['x.com', 'twitter.com', 'mobile.twitter.com', 'bsky.app', 'threads.net']);
16
+ /** Saved web articles under `shortWords` prose words, other than social posts: candidates for `refresh --archive`. */
17
+ export function shortCaptures(store) {
18
+ const short = [];
19
+ for (const item of store.list()) {
20
+ if (item.kind !== 'article' || !item.revisionId)
21
+ continue;
22
+ const hosts = store.identity(item.itemId).uris.map(({ uri }) => {
23
+ try {
24
+ return new URL(uri).hostname.replace(/^www\./, '');
25
+ }
26
+ catch {
27
+ return '';
28
+ }
29
+ });
30
+ if (!hosts.some((host) => host) || hosts.some((host) => postHosts.has(host)))
31
+ continue;
32
+ const read = store.read(item.itemId), words = read ? proseWords(read.markdown) : 0;
33
+ if (read && words < shortWords)
34
+ short.push({ itemId: item.itemId, title: read.title, words });
35
+ }
36
+ return short;
37
+ }
11
38
  const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;
12
39
  /** What the library holds, what is waiting or failed, and what to run next: one screen for `sponge status`. */
13
40
  export function libraryStatus(store) {
@@ -29,6 +56,7 @@ export function libraryStatus(store) {
29
56
  ? { embedded: generation.completed, expected: generation.expected, pending: generation.pending }
30
57
  : null,
31
58
  digests: { ...digests, auto: store.digests.settings().auto },
59
+ short: shortCaptures(store).length,
32
60
  jobs,
33
61
  pendingCleanup: store.queue.pendingCleanup(),
34
62
  };
@@ -54,6 +82,11 @@ export function statusText(s, worker) {
54
82
  : ''}`,
55
83
  `Search index: ${s.index ? `${s.index.embedded} of ${s.index.expected} chunks and images embedded${s.index.pending ? `; run sponge index run or let the worker` : ''}` : 'none; run sponge setup --embedding, then sponge index create'}`,
56
84
  `Digests: ${drafted} of ${total} items (${d.accepted} accepted, ${d.rejected} rejected, ${d.draft} unreviewed); auto-digest ${d.auto ? 'on' : 'off'}`,
85
+ ...(s.short
86
+ ? [
87
+ `Short captures: ${plural(s.short, 'article')} under ${shortWords} words; see sponge list --short, then try sponge refresh ITEM --archive`,
88
+ ]
89
+ : []),
57
90
  `Queue: ${s.jobs.length
58
91
  ? Object.entries(counts)
59
92
  .map(([key, n]) => `${n} ${key}`)
@@ -23,6 +23,7 @@ declare const parents: {
23
23
  readonly 'parent-missing': "invalid-input";
24
24
  readonly 'target-exists': "invalid-input";
25
25
  readonly 'connection-retired': "unsupported";
26
+ readonly 'browser-offline': "unavailable";
26
27
  };
27
28
  export type DetailCode = keyof typeof parents;
28
29
  export type SpongeCode = ErrorCode | DetailCode;
@@ -37,6 +37,7 @@ const parents = {
37
37
  'parent-missing': 'invalid-input',
38
38
  'target-exists': 'invalid-input',
39
39
  'connection-retired': 'unsupported',
40
+ 'browser-offline': 'unavailable',
40
41
  };
41
42
  export const indexCodes = [
42
43
  'invalid-index',
@@ -88,6 +89,7 @@ export const detailHints = {
88
89
  'model-unavailable': 'The model server did not answer or returned an error. Check that it is still running and has free GPU memory (another loaded model can exhaust it), then retry.',
89
90
  'source-too-thin': 'The capture holds too little text to digest: under 20 words of prose, or only an archive’s metadata. Refresh the item when a fuller capture is possible.',
90
91
  'connection-retired': 'Saved GhostGet account connections were retired in Sponge 0.5.0. Attach a signed-in browser over CDP and add it to connections.json instead.',
92
+ 'browser-offline': 'No browser has the Sponge extension connected, or its background captures are off. Open Chrome with the extension on, check `sponge browser status`, then retry.',
91
93
  'model-busy': 'Another llama-server is running, and two loaded models can exhaust GPU memory. Wait for it to exit, then retry.',
92
94
  };
93
95
  /** The one error type. `message` is the code, so bare `String(error)` never carries detail, paths or causes. */
@@ -2,6 +2,7 @@ import type { CaptureResult, ErrorEnvelope, JobCheckpoint } from '../../contract
2
2
  import { type CaptureConnection } from '../acquisition/index.js';
3
3
  import { type PdfCaptureRequest as CaptureRequest, type CaptureOutcome } from './capture.js';
4
4
  import type { LibraryStore, AttemptToken } from '../store/index.js';
5
+ import { waybackSnapshot } from '../app/archive.js';
5
6
  interface PdfResult {
6
7
  ok: true;
7
8
  itemId: string;
@@ -18,6 +19,7 @@ export interface PdfOptions {
18
19
  signal?: AbortSignal;
19
20
  allowPrivateNetwork?: boolean;
20
21
  capture?: (request: CaptureRequest) => Promise<CaptureOutcome>;
22
+ snapshot?: typeof waybackSnapshot;
21
23
  }
22
24
  /** One attempt. Durable queue policy and process recovery live above this function. */
23
25
  export declare function ingestPdf(store: LibraryStore, token: AttemptToken, options: PdfOptions): Promise<PdfOutcome>;
@@ -7,6 +7,7 @@ import { bundleBytes, bundleText } from '../app/bundle-files.js';
7
7
  import { localizeMarkdown } from '../app/markdown.js';
8
8
  import { pdfTitle } from './title.js';
9
9
  import { SpongeError } from '../errors.js';
10
+ import { waybackSnapshot } from '../app/archive.js';
10
11
  const classify = classifyErrors([
11
12
  'integrity',
12
13
  'limit-exceeded',
@@ -21,10 +22,14 @@ export function ingestPdf(store, token, options) {
21
22
  const source = store.queue.source(token.jobId);
22
23
  if (!source || source.kind !== 'pdf')
23
24
  throw new SpongeError('invalid-input');
25
+ const snapshot = source.via === 'archive' ? await (options.snapshot ?? waybackSnapshot)(source.locator, token.attemptId) : null;
26
+ if (snapshot && 'ok' in snapshot)
27
+ return snapshot;
28
+ const fetched = snapshot?.url ?? source.locator;
24
29
  const result = await (options.capture ?? capturePdf)({
25
30
  runProcess: options.runProcess,
26
31
  stagingRoot: join(store.root, 'staging'),
27
- sourceUrl: source.locator,
32
+ sourceUrl: fetched,
28
33
  requestId: token.attemptId,
29
34
  connection: options.connection,
30
35
  signal: options.signal,
@@ -37,7 +42,7 @@ export function ingestPdf(store, token, options) {
37
42
  }
38
43
  attempt.hold(result);
39
44
  await options.checkpoint?.('extracting');
40
- const pdf = await storePdfCapture(store, token, result, source.locator, options.signal);
45
+ const pdf = await storePdfCapture(store, token, result, fetched, options.signal);
41
46
  // Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
42
47
  const bundleHash = sha256(JSON.stringify(['pdf-local-markdown-v1', pdf.capture.manifestSha256]));
43
48
  await options.checkpoint?.('promoting');
@@ -44,13 +44,17 @@ export declare class Queue {
44
44
  submit(input: SourceSubmission): Submission;
45
45
  /** Jobs of every kind, oldest first. */
46
46
  list(): Row[];
47
- /** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
47
+ /**
48
+ * Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the
49
+ * synced form. `via` names the route the job's newest archive or browser submission asked for.
50
+ */
48
51
  source(jobId: string): {
49
52
  itemId: string;
50
53
  kind: string;
51
54
  sourceKey: string;
52
55
  locator: string;
53
56
  realm: string;
57
+ via?: 'archive' | 'browser';
54
58
  } | null;
55
59
  claimNext(token: OwnerToken): AttemptToken | null;
56
60
  claim(token: OwnerToken, jobId: string): AttemptToken;
@@ -163,7 +163,7 @@ export class Queue {
163
163
  const realm = input.realm ?? '';
164
164
  if (realm)
165
165
  text(realm, 256);
166
- if (input.intent !== undefined && !['save', 'refresh'].includes(input.intent))
166
+ if (input.intent !== undefined && !['save', 'refresh', 'archive', 'browser'].includes(input.intent))
167
167
  throw new SpongeError('invalid-input');
168
168
  const db = this.c.db;
169
169
  return transaction(db, () => {
@@ -224,13 +224,19 @@ export class Queue {
224
224
  .flatMap((kind) => kind.list())
225
225
  .sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || String(a.id).localeCompare(String(b.id)));
226
226
  }
227
- /** Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the synced form. */
227
+ /**
228
+ * Acquisition data for a capture job. A local file's locator is its path on this device (`local_sources`), never the
229
+ * synced form. `via` names the route the job's newest archive or browser submission asked for.
230
+ */
228
231
  source(jobId) {
229
232
  text(jobId, 256);
230
233
  const row = this.c.db
231
- .prepare('SELECT i.id itemId,i.kind,r.source_key sourceKey,COALESCE(l.locator,r.locator) locator,r.realm_key realm FROM jobs j JOIN items i ON i.id=j.item_id JOIN source_refs r ON r.item_id=i.id LEFT JOIN local_sources l ON l.item_id=i.id WHERE j.id=? AND i.deleted_at IS NULL ORDER BY r.id LIMIT 1')
234
+ .prepare("SELECT i.id itemId,i.kind,r.source_key sourceKey,COALESCE(l.locator,r.locator) locator,r.realm_key realm,(SELECT s.intent FROM submissions s WHERE s.job_id=j.id AND s.intent IN ('archive','browser') ORDER BY s.created_at DESC,s.rowid DESC LIMIT 1) via FROM jobs j JOIN items i ON i.id=j.item_id JOIN source_refs r ON r.item_id=i.id LEFT JOIN local_sources l ON l.item_id=i.id WHERE j.id=? AND i.deleted_at IS NULL ORDER BY r.id LIMIT 1")
232
235
  .get(jobId);
233
- return row ?? null;
236
+ if (!row)
237
+ return null;
238
+ const { via, ...source } = row;
239
+ return via ? { ...source, via } : source;
234
240
  }
235
241
  claimNext(token) {
236
242
  checkOwner(this.c.db, token);
@@ -27,7 +27,11 @@ export interface SourceSubmission {
27
27
  realm?: string;
28
28
  kind: ItemKind;
29
29
  idempotencyKey: string;
30
- intent?: 'save' | 'refresh';
30
+ /**
31
+ * `archive` captures the source's newest Internet Archive copy instead of the live page; `browser` captures it
32
+ * through the reader's own browser and sign-ins.
33
+ */
34
+ intent?: 'save' | 'refresh' | 'archive' | 'browser';
31
35
  }
32
36
  export interface Submission {
33
37
  itemId: string;
@@ -4,8 +4,10 @@ import { ingestArticle } from '../app/article.js';
4
4
  import { ingestPdf } from '../pdf/index.js';
5
5
  import { ingestMedia } from '../media/handler.js';
6
6
  import { drainQueue } from './engine.js';
7
+ import { askBridge, bridgeSocket } from '../browser/bridge.js';
7
8
  /** Content handlers over the durable sequential worker and its process supervisor. */
8
9
  export async function drainLibrary(store, options) {
10
+ const browser = options.browser ?? bridgeSocket(store.root);
9
11
  const handler = async (context) => {
10
12
  const source = store.queue.source(context.attempt.jobId);
11
13
  let connection;
@@ -18,6 +20,18 @@ export async function drainLibrary(store, options) {
18
20
  store.queue.settle(context.attempt);
19
21
  return { ...failure(context.attempt.attemptId, publicCode(code), 'acquisition'), cleanup: 'settled' };
20
22
  }
23
+ if (source?.via === 'browser') {
24
+ // Asked once up front, so an absent browser is a clear reason rather than an empty capture.
25
+ const status = await askBridge(browser, { op: 'status' }, { timeoutMs: 5000, maxBytes: 4096 });
26
+ if (!status.ok || !('background' in status) || !status.background) {
27
+ store.queue.fail(context.attempt, 'browser-offline', { retryable: true });
28
+ store.queue.settle(context.attempt);
29
+ return {
30
+ ...failure(context.attempt.attemptId, 'unavailable', 'acquisition', true),
31
+ cleanup: 'settled',
32
+ };
33
+ }
34
+ }
21
35
  if (source?.kind === 'media') {
22
36
  const { capture: _articleCapture, ...mediaOptions } = options;
23
37
  return ingestMedia(store, context.attempt, {
@@ -30,6 +44,7 @@ export async function drainLibrary(store, options) {
30
44
  }
31
45
  return (source?.kind === 'pdf' ? ingestPdf : ingestArticle)(store, context.attempt, {
32
46
  ...options,
47
+ browser,
33
48
  connection,
34
49
  signal: context.signal,
35
50
  runProcess: context.runProcess,
@@ -0,0 +1,180 @@
1
+ // The Sponge extension. Sponge asks over native messaging for a page; this worker loads it with the reader's own
2
+ // sign-ins and answers with the page alone. Cookies and passwords never leave the browser.
3
+ const host = 'com.hraness.sponge';
4
+ const version = chrome.runtime.getManifest().version;
5
+ /** Sponge holds a page to 16 MiB of HTML. */
6
+ const maxHtml = 16 * 1024 * 1024;
7
+ let port = null;
8
+ let connectedAt = 0;
9
+ let retryMs = 1000;
10
+ const saves = new Map();
11
+
12
+ async function backgroundAllowed() {
13
+ const { background } = await chrome.storage.local.get('background');
14
+ return background !== false;
15
+ }
16
+
17
+ async function connect() {
18
+ if (port) return;
19
+ try {
20
+ port = chrome.runtime.connectNative(host);
21
+ } catch {
22
+ port = null;
23
+ return reconnectLater();
24
+ }
25
+ connectedAt = Date.now();
26
+ port.onMessage.addListener(onHostMessage);
27
+ port.onDisconnect.addListener(() => {
28
+ void chrome.runtime.lastError;
29
+ port = null;
30
+ for (const resolve of saves.values()) resolve({ ok: false, code: 'offline' });
31
+ saves.clear();
32
+ // A host that ran for a while left normally; one that never started (not set up) backs off to a minute.
33
+ if (Date.now() - connectedAt > 10_000) retryMs = 1000;
34
+ reconnectLater();
35
+ });
36
+ port.postMessage({ type: 'hello', version, background: await backgroundAllowed() });
37
+ }
38
+
39
+ function reconnectLater() {
40
+ setTimeout(connect, retryMs);
41
+ retryMs = Math.min(retryMs * 2, 60_000);
42
+ }
43
+
44
+ function onHostMessage(message) {
45
+ if (message?.type === 'saved') {
46
+ saves.get(message.requestId)?.(message);
47
+ saves.delete(message.requestId);
48
+ return;
49
+ }
50
+ if (typeof message?.id !== 'string') return;
51
+ const run = message.op === 'fetch' ? fetchPage : message.op === 'render' ? renderPage : null;
52
+ if (!run) return port?.postMessage({ id: message.id, ok: false, code: 'failed' });
53
+ backgroundAllowed()
54
+ .then((allowed) => (allowed ? run(message.url, message.timeoutMs) : { ok: false, code: 'disabled' }))
55
+ .catch(() => ({ ok: false, code: 'failed' }))
56
+ .then((answer) => port?.postMessage({ id: message.id, ...answer }));
57
+ }
58
+
59
+ /** The page's HTML as the reader's browser would fetch it, with their cookies. */
60
+ async function fetchPage(url, timeoutMs) {
61
+ const controller = new AbortController();
62
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
63
+ try {
64
+ const response = await fetch(url, { credentials: 'include', cache: 'no-store', signal: controller.signal });
65
+ if (!response.ok) return { ok: false, code: 'failed', status: response.status };
66
+ if (!/^(text\/html|application\/xhtml\+xml)\b/i.test(response.headers.get('content-type') ?? ''))
67
+ return { ok: false, code: 'not-html' };
68
+ const html = await response.text();
69
+ if (html.length > maxHtml) return { ok: false, code: 'too-large' };
70
+ return { ok: true, html, finalUrl: response.url };
71
+ } catch {
72
+ return { ok: false, code: controller.signal.aborted ? 'timeout' : 'failed' };
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ /** The page after its scripts run, in a muted background tab that closes when done. */
79
+ async function renderPage(url, timeoutMs) {
80
+ const deadline = Date.now() + timeoutMs;
81
+ let tab;
82
+ try {
83
+ tab = await chrome.tabs.create({ url, active: false });
84
+ } catch {
85
+ // No window to open a tab in: a minimized one serves instead.
86
+ const window = await chrome.windows.create({ url, focused: false, state: 'minimized' });
87
+ tab = window.tabs[0];
88
+ }
89
+ try {
90
+ await chrome.tabs.update(tab.id, { muted: true });
91
+ if (!(await loaded(tab.id, deadline))) return { ok: false, code: 'timeout' };
92
+ const [result] = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: snapshot });
93
+ const page = result?.result;
94
+ if (!page || typeof page.html !== 'string') return { ok: false, code: 'failed' };
95
+ if (page.html.length > maxHtml) return { ok: false, code: 'too-large' };
96
+ return { ok: true, html: page.html, finalUrl: page.url, title: page.title };
97
+ } catch {
98
+ return { ok: false, code: 'failed' };
99
+ } finally {
100
+ await chrome.tabs.remove(tab.id).catch(() => undefined);
101
+ }
102
+ }
103
+
104
+ /** A new tab first reports its blank start page as complete; only the requested page counts. */
105
+ const navigated = (tab) => tab.status === 'complete' && !tab.pendingUrl && /^https?:/.test(tab.url ?? '');
106
+
107
+ function loaded(tabId, deadline) {
108
+ return new Promise((resolve) => {
109
+ const done = (value) => {
110
+ clearTimeout(timer);
111
+ clearInterval(poll);
112
+ chrome.tabs.onUpdated.removeListener(listen);
113
+ resolve(value);
114
+ };
115
+ const check = () =>
116
+ chrome.tabs.get(tabId).then(
117
+ (tab) => navigated(tab) && done(true),
118
+ () => done(false),
119
+ );
120
+ const listen = (id, _change, tab) => id === tabId && navigated(tab) && done(true);
121
+ const timer = setTimeout(() => done(false), Math.max(0, deadline - Date.now()));
122
+ // Update events can be missed while a fresh browser settles, so the tab is also checked every half second.
123
+ const poll = setInterval(check, 500);
124
+ chrome.tabs.onUpdated.addListener(listen);
125
+ check();
126
+ });
127
+ }
128
+
129
+ /** Runs in the page: scrolls once through it so lazy text loads, then returns the document. */
130
+ async function snapshot() {
131
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
132
+ await pause(1000);
133
+ for (let step = 0; step < 10 && innerHeight + scrollY < document.body.scrollHeight; step++) {
134
+ scrollBy(0, innerHeight);
135
+ await pause(200);
136
+ }
137
+ scrollTo(0, 0);
138
+ return { html: '<!doctype html>\n' + document.documentElement.outerHTML, url: location.href, title: document.title };
139
+ }
140
+
141
+ /** Asks Sponge to save a page; the Sponge worker then captures it through this browser. */
142
+ function save(url) {
143
+ if (!port) return Promise.resolve({ ok: false, code: 'offline' });
144
+ const requestId = crypto.randomUUID();
145
+ return new Promise((resolve) => {
146
+ saves.set(requestId, resolve);
147
+ setTimeout(() => saves.delete(requestId) && resolve({ ok: false, code: 'timeout' }), 15_000);
148
+ port.postMessage({ type: 'save', requestId, url });
149
+ });
150
+ }
151
+
152
+ chrome.runtime.onMessage.addListener((message, _sender, reply) => {
153
+ if (message?.type === 'status') {
154
+ backgroundAllowed().then((background) => reply({ connected: port !== null, background }));
155
+ return true;
156
+ }
157
+ if (message?.type === 'set-background') {
158
+ chrome.storage.local
159
+ .set({ background: message.value === true })
160
+ .then(() => port?.postMessage({ type: 'settings', version, background: message.value === true }))
161
+ .then(() => reply({ ok: true }));
162
+ return true;
163
+ }
164
+ if (message?.type === 'save') {
165
+ save(message.url).then(reply);
166
+ return true;
167
+ }
168
+ return false;
169
+ });
170
+
171
+ chrome.runtime.onInstalled.addListener(() => {
172
+ chrome.contextMenus.create({ id: 'save-link', title: 'Save link to Sponge', contexts: ['link'] });
173
+ chrome.contextMenus.create({ id: 'save-page', title: 'Save page to Sponge', contexts: ['page'] });
174
+ });
175
+ chrome.contextMenus.onClicked.addListener((info, tab) => {
176
+ const url = info.menuItemId === 'save-link' ? info.linkUrl : tab?.url;
177
+ if (url) void save(url);
178
+ });
179
+ chrome.runtime.onStartup.addListener(connect);
180
+ void connect();