@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.
- package/README.md +42 -0
- package/dist/capture/worker.js +154 -152
- package/dist/contracts/index.d.ts +2 -0
- package/dist/src/acquisition/capture.d.ts +4 -0
- package/dist/src/acquisition/capture.js +8 -2
- package/dist/src/acquisition/child-request.d.ts +5 -0
- package/dist/src/acquisition/normalize.js +3 -0
- package/dist/src/app/archive.d.ts +15 -0
- package/dist/src/app/archive.js +51 -0
- package/dist/src/app/article.d.ts +4 -0
- package/dist/src/app/article.js +24 -10
- package/dist/src/app/markdown.d.ts +2 -0
- package/dist/src/app/markdown.js +6 -0
- package/dist/src/browser/bridge.d.ts +48 -0
- package/dist/src/browser/bridge.js +90 -0
- package/dist/src/browser/host.d.ts +10 -0
- package/dist/src/browser/host.js +147 -0
- package/dist/src/browser/setup.d.ts +26 -0
- package/dist/src/browser/setup.js +103 -0
- package/dist/src/capture/network.d.ts +56 -0
- package/dist/src/capture/network.js +453 -0
- package/dist/src/capture/types.d.ts +26 -0
- package/dist/src/capture/types.js +1 -0
- package/dist/src/cli/commands.js +114 -12
- package/dist/src/cli/status.d.ts +7 -0
- package/dist/src/cli/status.js +33 -0
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/pdf/handler.d.ts +2 -0
- package/dist/src/pdf/handler.js +7 -2
- package/dist/src/store/queue.d.ts +5 -1
- package/dist/src/store/queue.js +10 -4
- package/dist/src/store/types.d.ts +5 -1
- package/dist/src/worker/basic.js +15 -0
- package/extension/background.js +180 -0
- package/extension/manifest.json +25 -0
- package/extension/popup.html +85 -0
- package/extension/popup.js +39 -0
- package/native/runtime-manifest.json +5 -5
- package/package.json +2 -1
package/dist/src/cli/commands.js
CHANGED
|
@@ -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: [
|
|
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
|
|
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: [
|
|
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: [
|
|
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
|
-
...
|
|
342
|
-
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',
|
package/dist/src/cli/status.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/cli/status.js
CHANGED
|
@@ -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}`)
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/errors.js
CHANGED
|
@@ -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>;
|
package/dist/src/pdf/handler.js
CHANGED
|
@@ -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:
|
|
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,
|
|
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
|
-
/**
|
|
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;
|
package/dist/src/store/queue.js
CHANGED
|
@@ -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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/worker/basic.js
CHANGED
|
@@ -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();
|