@hraness/sponge 0.6.6 → 0.6.8

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 CHANGED
@@ -41,6 +41,22 @@ websites.
41
41
  `doctor` reports runtime integrity and pending cleanup. Private-network capture
42
42
  requires `--allow-private-network` with `--wait` or `run --drain`.
43
43
 
44
+ ### Dead, blocked and paywalled pages
45
+
46
+ `sponge status` counts saved web articles under 200 words (social posts aside),
47
+ and `list --short` names them: usually a paywall stub, an error page or a page
48
+ that needed scripts. `refresh ITEM --archive` (or `add URL --archive`) captures
49
+ the newest Internet Archive copy instead, found through the Wayback Machine's CDX
50
+ index, and fetches the raw saved bytes (`id_`) without the replay toolbar. The
51
+ item keeps its original URL; the capture's `source_url` names the snapshot. Only
52
+ `--archive` sends the URL to archive.org. No copy fails with `unavailable` and
53
+ leaves the saved revision alone.
54
+
55
+ archive.today (archive.ph, archive.is) has no API, answers automated clients with
56
+ a CAPTCHA, and gives wrong DNS answers to some public resolvers, so Sponge does not
57
+ fetch from it. To use one of its copies, open it in a browser and save it as a PDF;
58
+ then `sponge add FILE.pdf` keeps it.
59
+
44
60
  Images use `sponge-asset:SHA256` references. `asset ITEM SHA256 > image.png`
45
61
  retrieves verified bytes offline; `asset ITEM SHA256 --json` returns its metadata.
46
62
  Unresolved remote images become unavailable markers, and captures retain partial
@@ -0,0 +1,15 @@
1
+ import type { ErrorEnvelope } from '../../contracts/index.js';
2
+ import { type SafeFetchOptions, type SafeFetchResult } from '../capture/network.js';
3
+ type Fetch = (url: URL, options: SafeFetchOptions) => Promise<SafeFetchResult>;
4
+ /** A saved copy of a page: the raw bytes the archive stored (`id_`), without its replay toolbar or rewritten links. */
5
+ export interface Snapshot {
6
+ url: string;
7
+ timestamp: string;
8
+ }
9
+ /**
10
+ * The newest Internet Archive copy of `url` that the archive saw answer 200, found through its CDX index. This
11
+ * sends the URL to archive.org, so only an explicit `--archive` asks. No copy is `unavailable`; a busy index is
12
+ * `rate-limited` and retryable.
13
+ */
14
+ export declare function waybackSnapshot(url: string, requestId: string, fetch?: Fetch): Promise<Snapshot | ErrorEnvelope>;
15
+ export {};
@@ -0,0 +1,51 @@
1
+ import { failure } from '../acquisition/errors.js';
2
+ import { FetchFailure, safeFetch } from '../capture/network.js';
3
+ import { DEFAULT_USER_AGENT } from '../capture/types.js';
4
+ /**
5
+ * The newest Internet Archive copy of `url` that the archive saw answer 200, found through its CDX index. This
6
+ * sends the URL to archive.org, so only an explicit `--archive` asks. No copy is `unavailable`; a busy index is
7
+ * `rate-limited` and retryable.
8
+ */
9
+ export async function waybackSnapshot(url, requestId, fetch = safeFetch) {
10
+ const query = new URL('https://web.archive.org/cdx/search/cdx');
11
+ for (const [key, value] of Object.entries({
12
+ url,
13
+ output: 'json',
14
+ filter: 'statuscode:200',
15
+ limit: '-1',
16
+ fl: 'timestamp,original',
17
+ }))
18
+ query.searchParams.set(key, value);
19
+ let rows;
20
+ try {
21
+ const response = await fetch(query, {
22
+ timeoutMs: 30_000,
23
+ maxBytes: 64 * 1024,
24
+ allowPrivateNetwork: false,
25
+ userAgent: DEFAULT_USER_AGENT,
26
+ accept: 'application/json',
27
+ retries: 2,
28
+ });
29
+ const text = new TextDecoder().decode(response.bytes).trim();
30
+ rows = text ? JSON.parse(text) : [];
31
+ }
32
+ catch (error) {
33
+ if (error instanceof FetchFailure && error.status === 429)
34
+ return failure(requestId, 'rate-limited', 'acquisition', true);
35
+ return failure(requestId, 'unavailable', 'acquisition', true);
36
+ }
37
+ // The first row names the fields; the last is the newest capture.
38
+ const row = Array.isArray(rows) && rows.length > 1 ? rows.at(-1) : undefined;
39
+ const [timestamp, original] = Array.isArray(row) ? row : [];
40
+ if (typeof timestamp !== 'string' || !/^\d{14}$/.test(timestamp) || typeof original !== 'string')
41
+ return failure(requestId, 'unavailable');
42
+ try {
43
+ const page = new URL(original);
44
+ if (!['http:', 'https:'].includes(page.protocol))
45
+ return failure(requestId, 'unavailable');
46
+ return { url: `https://web.archive.org/web/${timestamp}id_/${page.href}`, timestamp };
47
+ }
48
+ catch {
49
+ return failure(requestId, 'unavailable');
50
+ }
51
+ }
@@ -2,6 +2,7 @@ import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
2
2
  import { type CaptureRequest, type CaptureOutcome, type CaptureConnection } from '../acquisition/index.js';
3
3
  import type { LibraryStore, AttemptToken } from '../store/index.js';
4
4
  import { type PdfCaptureRequest, type CaptureOutcome as PdfCaptureOutcome } from '../pdf/capture.js';
5
+ import { waybackSnapshot } from './archive.js';
5
6
  interface ArticleResult {
6
7
  ok: true;
7
8
  itemId: string;
@@ -19,6 +20,7 @@ export interface ArticleOptions {
19
20
  allowPrivateNetwork?: boolean;
20
21
  capture?: (request: CaptureRequest) => Promise<CaptureOutcome>;
21
22
  capturePdf?: (request: PdfCaptureRequest) => Promise<PdfCaptureOutcome>;
23
+ snapshot?: typeof waybackSnapshot;
22
24
  }
23
25
  /** One attempt. Durable queue policy and process recovery live above this function. */
24
26
  export declare function ingestArticle(store: LibraryStore, token: AttemptToken, options: ArticleOptions): Promise<ArticleOutcome>;
@@ -5,10 +5,11 @@ import { runAttempt, classifyErrors } from '../ingest/attempt.js';
5
5
  import { sha256 } from '../util/hash.js';
6
6
  import { bundleBytes, bundleText } from './bundle-files.js';
7
7
  import { selectedMetadata } from '../metadata/article.js';
8
- import { localizeMarkdown } from './markdown.js';
8
+ import { localizeMarkdown, proseWords } from './markdown.js';
9
9
  import { SpongeError } from '../errors.js';
10
10
  import { capturePdf } from '../pdf/capture.js';
11
11
  import { storePdfCapture } from '../pdf/handler.js';
12
+ import { waybackSnapshot } from './archive.js';
12
13
  const classify = classifyErrors([
13
14
  'integrity',
14
15
  'limit-exceeded',
@@ -23,12 +24,19 @@ export function ingestArticle(store, token, options) {
23
24
  const source = store.queue.source(token.jobId);
24
25
  if (!source || source.kind !== 'article')
25
26
  throw new SpongeError('invalid-input');
27
+ // An archived copy is public: it is fetched directly, never through a signed-in browser.
28
+ const snapshot = source.archive
29
+ ? await (options.snapshot ?? waybackSnapshot)(source.locator, token.attemptId)
30
+ : null;
31
+ if (snapshot && 'ok' in snapshot)
32
+ return snapshot;
33
+ const fetched = snapshot?.url ?? source.locator;
26
34
  const result = await (options.capture ?? captureArticle)({
27
35
  runProcess: options.runProcess,
28
36
  stagingRoot: join(store.root, 'staging'),
29
- sourceUrl: source.locator,
37
+ sourceUrl: fetched,
30
38
  requestId: token.attemptId,
31
- connection: options.connection,
39
+ connection: snapshot ? undefined : options.connection,
32
40
  signal: options.signal,
33
41
  allowPrivateNetwork: options.allowPrivateNetwork,
34
42
  });
@@ -41,12 +49,12 @@ export function ingestArticle(store, token, options) {
41
49
  await options.checkpoint?.('extracting');
42
50
  const capture = result.capture;
43
51
  validateCapture(capture);
44
- if (capture.kind !== 'article' || capture.requestId !== token.attemptId || capture.sourceUrl !== source.locator)
52
+ if (capture.kind !== 'article' || capture.requestId !== token.attemptId || capture.sourceUrl !== fetched)
45
53
  throw new SpongeError('integrity');
46
54
  const original = await bundleText(result.bundleRoot, capture.markdown);
47
55
  const selectedTitle = selectedMetadata(capture.metadata, 'title')?.value;
48
56
  const title = typeof selectedTitle === 'string' ? selectedTitle : undefined;
49
- const paperUrl = proseWords(original) < landingPageWords ? fullTextPdf(capture.metadata, source.locator) : undefined;
57
+ const paperUrl = proseWords(original) < landingPageWords ? fullTextPdf(capture.metadata, fetched) : undefined;
50
58
  if (paperUrl) {
51
59
  const paper = await (options.capturePdf ?? capturePdf)({
52
60
  runProcess: options.runProcess,
@@ -115,11 +123,6 @@ export function ingestArticle(store, token, options) {
115
123
  }
116
124
  /** Prose words under which a page that names its paper's PDF is only a landing page: a title, authors and abstract. */
117
125
  const landingPageWords = 1000;
118
- function proseWords(markdown) {
119
- const body = markdown.startsWith('---\n') ? markdown.slice(Math.max(0, markdown.indexOf('\n---', 4))) : markdown;
120
- const prose = body.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
121
- return (prose.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? []).length;
122
- }
123
126
  /**
124
127
  * The full text a scholarly landing page declares in `citation_pdf_url` (arXiv, NBER, OSF and most journals), when
125
128
  * it is served from the page's own site or a subdomain relation of it; other sites are never fetched on its word.
@@ -4,3 +4,5 @@ export declare function localizeMarkdown(markdown: string, assets: Asset[]): {
4
4
  markdown: string;
5
5
  missingImages: boolean;
6
6
  };
7
+ /** Words with letters past the frontmatter, images dropped and link text kept: how much a capture says. */
8
+ export declare function proseWords(markdown: string): number;
@@ -100,3 +100,9 @@ export function localizeMarkdown(markdown, assets) {
100
100
  markdown = markdown.slice(0, patch.start) + patch.value + markdown.slice(patch.end);
101
101
  return { markdown, missingImages };
102
102
  }
103
+ /** Words with letters past the frontmatter, images dropped and link text kept: how much a capture says. */
104
+ export function proseWords(markdown) {
105
+ const body = markdown.startsWith('---\n') ? markdown.slice(Math.max(0, markdown.indexOf('\n---', 4))) : markdown;
106
+ const prose = body.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
107
+ return (prose.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? []).length;
108
+ }
@@ -0,0 +1,56 @@
1
+ export type FetchFailureCode = 'invalid-url' | 'private-network' | 'dns' | 'timeout' | 'http' | 'redirect' | 'too-large' | 'network';
2
+ export declare class FetchFailure extends Error {
3
+ readonly code: FetchFailureCode;
4
+ readonly status: number | null;
5
+ constructor(code: FetchFailureCode, message: string, options?: ErrorOptions & {
6
+ status?: number;
7
+ });
8
+ }
9
+ export interface SafeFetchOptions {
10
+ timeoutMs: number;
11
+ maxBytes: number;
12
+ allowPrivateNetwork: boolean;
13
+ userAgent: string;
14
+ referer?: string;
15
+ accept?: string;
16
+ retries?: number;
17
+ maxRedirects?: number;
18
+ }
19
+ export interface SafeFetchResult {
20
+ bytes: Uint8Array;
21
+ finalUrl: URL;
22
+ status: number;
23
+ contentType: string | null;
24
+ }
25
+ interface ResolvedAddress {
26
+ address: string;
27
+ family: 4 | 6;
28
+ }
29
+ export interface PinnedRequest {
30
+ url: URL;
31
+ address: ResolvedAddress;
32
+ headers: Headers;
33
+ signal: AbortSignal;
34
+ }
35
+ export interface PinnedResponse {
36
+ status: number;
37
+ headers: Headers;
38
+ body: AsyncIterable<unknown> | null;
39
+ cancel: () => void;
40
+ }
41
+ export interface SafeFetchDependencies {
42
+ resolveHostname: (hostname: string) => Promise<readonly ResolvedAddress[]>;
43
+ transport: (request: PinnedRequest) => Promise<PinnedResponse>;
44
+ /** Test seam. Production callers use the system interface snapshot. */
45
+ localAddresses: () => readonly string[];
46
+ }
47
+ /** True for loopback, link-local, private, reserved, multicast and unspecified addresses. */
48
+ export declare function isPrivateAddress(address: string): boolean;
49
+ export declare function isPrivateHostname(hostname: string): boolean;
50
+ /** Refuse a private, reserved or host-assigned target unless explicitly allowed. */
51
+ export declare function assertPublicUrl(url: URL, allowPrivateNetwork: boolean, timeoutMs?: number): Promise<void>;
52
+ /** A bounded fetcher: every hop is resolved, validated and pinned; redirects are re-checked; bodies are capped. */
53
+ export declare function createSafeFetch(dependencies?: Partial<SafeFetchDependencies>): (url: URL, options: SafeFetchOptions) => Promise<SafeFetchResult>;
54
+ export declare const safeFetch: (url: URL, options: SafeFetchOptions) => Promise<SafeFetchResult>;
55
+ export declare function decodeBytes(bytes: Uint8Array, contentType: string | null): string;
56
+ export {};
@@ -0,0 +1,453 @@
1
+ // Ported from Wordcell's clip/network.ts (MIT, see native/NOTICES-capture.txt). Cookie forwarding, connection
2
+ // pooling and manual redirects are dropped: Sponge captures only public pages over a pinned, bounded fetch.
3
+ import { lookup } from 'node:dns/promises';
4
+ import { request as requestHttp } from 'node:http';
5
+ import { request as requestHttps } from 'node:https';
6
+ import { isIP } from 'node:net';
7
+ import { networkInterfaces } from 'node:os';
8
+ import { setTimeout as sleep } from 'node:timers/promises';
9
+ export class FetchFailure extends Error {
10
+ code;
11
+ status;
12
+ constructor(code, message, options) {
13
+ super(message, options);
14
+ this.name = 'FetchFailure';
15
+ this.code = code;
16
+ this.status = options?.status ?? null;
17
+ }
18
+ }
19
+ const privateSuffixes = ['.localhost', '.local', '.internal', '.home', '.lan'];
20
+ const retryableStatuses = new Set([408, 425, 429, 500, 502, 503, 504]);
21
+ const normalizeHostname = (hostname) => (hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname)
22
+ .toLowerCase()
23
+ .replace(/\.$/, '');
24
+ function parseIpv4(address) {
25
+ const pieces = address.split('.');
26
+ if (pieces.length !== 4)
27
+ return null;
28
+ const numbers = pieces.map(Number);
29
+ return numbers.every((piece) => Number.isInteger(piece) && piece >= 0 && piece <= 255) ? numbers : null;
30
+ }
31
+ function parseIpv6(address) {
32
+ let value = address;
33
+ const separator = value.lastIndexOf(':');
34
+ const tail = separator >= 0 ? value.slice(separator + 1) : value;
35
+ if (tail.includes('.')) {
36
+ const ipv4 = parseIpv4(tail);
37
+ if (ipv4 === null || separator < 0)
38
+ return null;
39
+ value = `${value.slice(0, separator)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
40
+ }
41
+ const compression = value.indexOf('::');
42
+ if (compression !== -1 && compression !== value.lastIndexOf('::'))
43
+ return null;
44
+ const leftText = compression === -1 ? value : value.slice(0, compression);
45
+ const rightText = compression === -1 ? '' : value.slice(compression + 2);
46
+ const left = leftText === '' ? [] : leftText.split(':');
47
+ const right = rightText === '' ? [] : rightText.split(':');
48
+ if ([...left, ...right].some((part) => !/^[0-9a-f]{1,4}$/i.test(part)))
49
+ return null;
50
+ const missing = 8 - left.length - right.length;
51
+ if ((compression === -1 && missing !== 0) || (compression !== -1 && missing < 1))
52
+ return null;
53
+ return [
54
+ ...left.map((part) => Number.parseInt(part, 16)),
55
+ ...Array.from({ length: missing }, () => 0),
56
+ ...right.map((part) => Number.parseInt(part, 16)),
57
+ ];
58
+ }
59
+ function withoutScope(address) {
60
+ const normalized = normalizeHostname(address);
61
+ const scope = normalized.indexOf('%');
62
+ return scope === -1 ? normalized : normalized.slice(0, scope);
63
+ }
64
+ /** The IPv4 address an IPv4-mapped or IPv4-compatible IPv6 address reaches, if any. */
65
+ function embeddedIpv4(groups) {
66
+ const compatible = groups.slice(0, 6).every((group) => group === 0);
67
+ const mapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
68
+ if (!compatible && !mapped)
69
+ return null;
70
+ const high = groups[6], low = groups[7];
71
+ return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
72
+ }
73
+ /** Syntax-independent keys for one IP literal; an embedded IPv4 address also gets its IPv4 key. */
74
+ function addressKeys(address) {
75
+ const normalized = withoutScope(address);
76
+ const version = isIP(normalized);
77
+ if (version === 4) {
78
+ const pieces = parseIpv4(normalized);
79
+ return pieces === null ? [] : [`4:${pieces.join('.')}`];
80
+ }
81
+ if (version !== 6)
82
+ return [];
83
+ const groups = parseIpv6(normalized);
84
+ if (groups === null)
85
+ return [];
86
+ const keys = [`6:${groups.map((group) => group.toString(16).padStart(4, '0')).join(':')}`];
87
+ const ipv4 = embeddedIpv4(groups);
88
+ if (ipv4 !== null)
89
+ keys.push(`4:${ipv4}`);
90
+ return keys;
91
+ }
92
+ function systemLocalAddresses() {
93
+ try {
94
+ return Object.values(networkInterfaces()).flatMap((records) => (records ?? []).map((record) => record.address));
95
+ }
96
+ catch (error) {
97
+ throw new FetchFailure('network', 'could not inspect local network interfaces', { cause: error });
98
+ }
99
+ }
100
+ function localKeys(provider) {
101
+ let addresses;
102
+ try {
103
+ addresses = provider();
104
+ }
105
+ catch (error) {
106
+ if (error instanceof FetchFailure)
107
+ throw error;
108
+ throw new FetchFailure('network', 'could not inspect local network interfaces', { cause: error });
109
+ }
110
+ return new Set(addresses.flatMap(addressKeys));
111
+ }
112
+ const isLocalAddress = (address, keys) => addressKeys(address).some((key) => keys.has(key));
113
+ /** True for loopback, link-local, private, reserved, multicast and unspecified addresses. */
114
+ export function isPrivateAddress(address) {
115
+ const normalized = withoutScope(address);
116
+ const version = isIP(normalized);
117
+ if (version === 4) {
118
+ const pieces = parseIpv4(normalized);
119
+ if (pieces === null)
120
+ return true;
121
+ const [a, b, c] = pieces;
122
+ return (a === 0 ||
123
+ a === 10 ||
124
+ a === 127 ||
125
+ (a === 100 && b >= 64 && b <= 127) ||
126
+ (a === 169 && b === 254) ||
127
+ (a === 172 && b >= 16 && b <= 31) ||
128
+ (a === 192 && (b === 0 || b === 168)) ||
129
+ (a === 198 && (b === 18 || b === 19)) ||
130
+ (a === 198 && b === 51 && c === 100) ||
131
+ (a === 203 && b === 0 && c === 113) ||
132
+ a >= 224);
133
+ }
134
+ if (version === 6) {
135
+ const groups = parseIpv6(normalized);
136
+ if (groups === null)
137
+ return true;
138
+ const ipv4 = embeddedIpv4(groups);
139
+ if (ipv4 !== null)
140
+ return isPrivateAddress(ipv4);
141
+ const [first, second] = groups;
142
+ return ((first & 0xfe00) === 0xfc00 ||
143
+ (first & 0xffc0) === 0xfe80 ||
144
+ (first & 0xffc0) === 0xfec0 ||
145
+ (first & 0xff00) === 0xff00 ||
146
+ first === 0x0064 ||
147
+ first === 0x0100 ||
148
+ (first === 0x2001 && second <= 0x01ff) ||
149
+ (first === 0x2001 && second === 0x0db8) ||
150
+ first === 0x2002 ||
151
+ first === 0x3ffe ||
152
+ (first === 0x3fff && (second & 0xf000) === 0) ||
153
+ first === 0x5f00);
154
+ }
155
+ return true;
156
+ }
157
+ export function isPrivateHostname(hostname) {
158
+ const normalized = normalizeHostname(hostname);
159
+ if (normalized === 'localhost' || normalized === 'localhost.localdomain')
160
+ return true;
161
+ if (privateSuffixes.some((suffix) => normalized.endsWith(suffix)))
162
+ return true;
163
+ return isIP(normalized) !== 0 && isPrivateAddress(normalized);
164
+ }
165
+ async function systemResolve(hostname) {
166
+ const answers = await lookup(hostname, { all: true, verbatim: true });
167
+ return answers.flatMap((answer) => answer.family === 4 || answer.family === 6 ? [{ address: answer.address, family: answer.family }] : []);
168
+ }
169
+ async function beforeDeadline(promise, deadline, message) {
170
+ const remaining = deadline - Date.now();
171
+ if (remaining <= 0)
172
+ throw new FetchFailure('timeout', message);
173
+ let timer;
174
+ try {
175
+ return await Promise.race([
176
+ promise,
177
+ new Promise((_, reject) => {
178
+ timer = setTimeout(() => reject(new FetchFailure('timeout', message)), remaining);
179
+ }),
180
+ ]);
181
+ }
182
+ finally {
183
+ clearTimeout(timer);
184
+ }
185
+ }
186
+ async function resolveTarget(url, allowPrivateNetwork, dependencies, deadline, timeoutMs) {
187
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
188
+ throw new FetchFailure('invalid-url', `unsupported URL protocol: ${url.protocol}`);
189
+ if (url.username !== '' || url.password !== '')
190
+ throw new FetchFailure('invalid-url', 'credential-bearing URLs are not accepted');
191
+ const hostname = normalizeHostname(url.hostname);
192
+ if (!allowPrivateNetwork && isPrivateHostname(hostname))
193
+ throw new FetchFailure('private-network', `private-network URL: ${url.origin}`);
194
+ const local = allowPrivateNetwork ? new Set() : localKeys(dependencies.localAddresses);
195
+ const literal = isIP(hostname);
196
+ if (literal === 4 || literal === 6) {
197
+ if (isLocalAddress(hostname, local))
198
+ throw new FetchFailure('private-network', `address ${hostname} is assigned to a local interface`);
199
+ return [{ address: hostname, family: literal }];
200
+ }
201
+ let answers;
202
+ try {
203
+ answers = await beforeDeadline(dependencies.resolveHostname(hostname), deadline, `request timed out after ${timeoutMs}ms while resolving ${hostname}`);
204
+ }
205
+ catch (error) {
206
+ if (error instanceof FetchFailure)
207
+ throw error;
208
+ throw new FetchFailure('dns', `could not resolve ${hostname}`, { cause: error });
209
+ }
210
+ if (answers.length === 0)
211
+ throw new FetchFailure('dns', `could not resolve ${hostname}`);
212
+ // DNS can take most of the deadline; an interface that appeared meanwhile must not become a local hop.
213
+ if (!allowPrivateNetwork)
214
+ for (const key of localKeys(dependencies.localAddresses))
215
+ local.add(key);
216
+ const unique = new Map();
217
+ for (const answer of answers) {
218
+ const address = normalizeHostname(answer.address);
219
+ const family = isIP(address);
220
+ if ((family !== 4 && family !== 6) || family !== answer.family)
221
+ throw new FetchFailure('dns', `${hostname} returned an invalid DNS answer`);
222
+ if (!allowPrivateNetwork && (isPrivateAddress(address) || isLocalAddress(address, local)))
223
+ throw new FetchFailure('private-network', `${hostname} resolves to a private or local address`);
224
+ unique.set(`${family}:${address}`, { address, family });
225
+ }
226
+ return [...unique.values()];
227
+ }
228
+ /** Refuse a private, reserved or host-assigned target unless explicitly allowed. */
229
+ export async function assertPublicUrl(url, allowPrivateNetwork, timeoutMs = 30_000) {
230
+ await resolveTarget(url, allowPrivateNetwork, { resolveHostname: systemResolve, localAddresses: systemLocalAddresses }, Date.now() + timeoutMs, timeoutMs);
231
+ }
232
+ /** A lookup callback that can only answer with the address already validated. */
233
+ const pinnedLookup = ({ address, family }) => (_hostname, options, callback) => queueMicrotask(() => options.all === true ? callback(null, [{ address, family }]) : callback(null, address, family));
234
+ function responseHeaders(headers) {
235
+ const result = new Headers();
236
+ for (const [name, value] of Object.entries(headers)) {
237
+ if (typeof value === 'string')
238
+ result.append(name, value);
239
+ else if (Array.isArray(value))
240
+ for (const item of value)
241
+ result.append(name, item);
242
+ }
243
+ return result;
244
+ }
245
+ /** One HTTP(S) request through one validated address. No DNS, redirects, retries or body accumulation. */
246
+ function requestPinned(request) {
247
+ const hostname = normalizeHostname(request.url.hostname);
248
+ if (isIP(request.address.address) !== request.address.family)
249
+ return Promise.reject(new Error('pinned address does not match its family'));
250
+ const literal = isIP(hostname);
251
+ if (literal !== 0 &&
252
+ (literal !== request.address.family || addressKeys(hostname)[0] !== addressKeys(request.address.address)[0]))
253
+ return Promise.reject(new Error('IP-literal hostname does not match the pinned address'));
254
+ const headers = {};
255
+ request.headers.forEach((value, name) => (headers[name] = value));
256
+ const options = {
257
+ protocol: request.url.protocol,
258
+ hostname,
259
+ method: 'GET',
260
+ path: `${request.url.pathname}${request.url.search}`,
261
+ headers,
262
+ lookup: pinnedLookup(request.address),
263
+ family: request.address.family,
264
+ signal: request.signal,
265
+ agent: false,
266
+ ...(request.url.port === '' ? {} : { port: request.url.port }),
267
+ };
268
+ return new Promise((resolve, reject) => {
269
+ const onResponse = (response) => {
270
+ if (response.statusCode === undefined) {
271
+ response.destroy();
272
+ reject(new Error('HTTP response omitted a status code'));
273
+ return;
274
+ }
275
+ resolve({
276
+ status: response.statusCode,
277
+ headers: responseHeaders(response.headers),
278
+ body: response,
279
+ cancel: () => response.destroy(),
280
+ });
281
+ };
282
+ const client = request.url.protocol === 'https:'
283
+ ? requestHttps({ ...options, ...(literal === 0 ? { servername: hostname } : {}) }, onResponse)
284
+ : requestHttp(options, onResponse);
285
+ // A handshake error can be followed by a socket teardown error; keep the listener for the request lifetime.
286
+ client.on('error', reject);
287
+ client.end();
288
+ });
289
+ }
290
+ function retryDelay(response, attempt) {
291
+ const header = response?.headers.get('retry-after')?.trim();
292
+ if (header) {
293
+ const seconds = Number(header);
294
+ if (Number.isFinite(seconds) && seconds >= 0)
295
+ return Math.min(seconds * 1000, 5000);
296
+ const date = Date.parse(header);
297
+ if (!Number.isNaN(date))
298
+ return Math.min(Math.max(date - Date.now(), 0), 5000);
299
+ }
300
+ return Math.min(250 * 2 ** attempt, 2000);
301
+ }
302
+ async function readBounded(response, maxBytes) {
303
+ const declared = Number(response.headers.get('content-length') ?? NaN);
304
+ if (Number.isFinite(declared) && declared > maxBytes) {
305
+ response.cancel();
306
+ throw new FetchFailure('too-large', `response declares ${declared} bytes; limit is ${maxBytes}`);
307
+ }
308
+ if (response.body === null)
309
+ return new Uint8Array();
310
+ const chunks = [];
311
+ let total = 0;
312
+ for await (const chunk of response.body) {
313
+ if (!(chunk instanceof Uint8Array)) {
314
+ response.cancel();
315
+ throw new FetchFailure('network', 'response body yielded a non-byte chunk');
316
+ }
317
+ total += chunk.byteLength;
318
+ if (total > maxBytes) {
319
+ response.cancel();
320
+ throw new FetchFailure('too-large', `response exceeded ${maxBytes} bytes`);
321
+ }
322
+ chunks.push(chunk);
323
+ }
324
+ return Buffer.concat(chunks, total);
325
+ }
326
+ function requestHeaders(current, options) {
327
+ const headers = new Headers({
328
+ Accept: options.accept ?? 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
329
+ 'Accept-Encoding': 'identity',
330
+ 'User-Agent': options.userAgent,
331
+ });
332
+ if (options.referer !== undefined) {
333
+ try {
334
+ const referer = new URL(options.referer);
335
+ headers.set('Referer', referer.origin === current.origin ? referer.href : `${referer.origin}/`);
336
+ }
337
+ catch {
338
+ // An invalid optional referrer is dropped rather than forwarded.
339
+ }
340
+ }
341
+ return headers;
342
+ }
343
+ /** A bounded fetcher: every hop is resolved, validated and pinned; redirects are re-checked; bodies are capped. */
344
+ export function createSafeFetch(dependencies = {}) {
345
+ const resolve = {
346
+ resolveHostname: dependencies.resolveHostname ?? systemResolve,
347
+ localAddresses: dependencies.localAddresses ?? systemLocalAddresses,
348
+ };
349
+ const transport = dependencies.transport ?? requestPinned;
350
+ return async (url, options) => {
351
+ const maxRedirects = options.maxRedirects ?? 8, retries = options.retries ?? 2, deadline = Date.now() + options.timeoutMs;
352
+ let current = new URL(url);
353
+ for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
354
+ const timedOut = `request timed out after ${options.timeoutMs}ms: ${current.href}`;
355
+ const addresses = await resolveTarget(current, options.allowPrivateNetwork, resolve, deadline, options.timeoutMs);
356
+ let response = null, lastError, controller = null, timer;
357
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
358
+ const remaining = deadline - Date.now();
359
+ if (remaining <= 0)
360
+ throw new FetchFailure('timeout', timedOut);
361
+ const attemptController = new AbortController();
362
+ const attemptTimer = setTimeout(() => attemptController.abort(), remaining);
363
+ try {
364
+ response = await beforeDeadline(transport({
365
+ url: new URL(current),
366
+ address: addresses[attempt % addresses.length],
367
+ headers: requestHeaders(current, options),
368
+ signal: attemptController.signal,
369
+ }), deadline, timedOut);
370
+ if (!retryableStatuses.has(response.status) || attempt === retries) {
371
+ controller = attemptController;
372
+ timer = attemptTimer;
373
+ break;
374
+ }
375
+ attemptController.abort();
376
+ response.cancel();
377
+ clearTimeout(attemptTimer);
378
+ }
379
+ catch (error) {
380
+ lastError = error;
381
+ // The deadline race and the abort timer are separate callbacks; abort so a pending transport frees its socket.
382
+ attemptController.abort();
383
+ clearTimeout(attemptTimer);
384
+ if (error instanceof FetchFailure && error.code === 'timeout')
385
+ throw error;
386
+ if (attempt === retries) {
387
+ if (attemptController.signal.aborted && Date.now() >= deadline)
388
+ throw new FetchFailure('timeout', timedOut, { cause: error });
389
+ if (error instanceof FetchFailure)
390
+ throw error;
391
+ throw new FetchFailure('network', `request failed: ${current.href}`, { cause: error });
392
+ }
393
+ }
394
+ const delay = Math.min(retryDelay(response, attempt), Math.max(0, deadline - Date.now()));
395
+ if (delay > 0)
396
+ await sleep(delay);
397
+ }
398
+ if (response === null)
399
+ throw new FetchFailure('network', `request failed: ${current.href}`, { cause: lastError });
400
+ if (response.status >= 300 && response.status < 400) {
401
+ try {
402
+ const location = response.headers.get('location');
403
+ if (location === null)
404
+ throw new FetchFailure('redirect', `HTTP ${response.status} omitted Location`);
405
+ if (redirects === maxRedirects)
406
+ throw new FetchFailure('redirect', `more than ${maxRedirects} redirects`);
407
+ try {
408
+ current = new URL(location, current);
409
+ }
410
+ catch (error) {
411
+ throw new FetchFailure('redirect', 'invalid redirect target', { cause: error });
412
+ }
413
+ continue;
414
+ }
415
+ finally {
416
+ controller?.abort();
417
+ response.cancel();
418
+ clearTimeout(timer);
419
+ }
420
+ }
421
+ if (response.status < 200 || response.status >= 300) {
422
+ clearTimeout(timer);
423
+ controller?.abort();
424
+ response.cancel();
425
+ throw new FetchFailure('http', `HTTP ${response.status} for ${current.href}`, { status: response.status });
426
+ }
427
+ const bodyTimedOut = `response body timed out after ${options.timeoutMs}ms: ${current.href}`;
428
+ try {
429
+ const bytes = await beforeDeadline(readBounded(response, options.maxBytes), deadline, bodyTimedOut);
430
+ return { bytes, finalUrl: current, status: response.status, contentType: response.headers.get('content-type') };
431
+ }
432
+ catch (error) {
433
+ if (controller?.signal.aborted || (error instanceof FetchFailure && error.code === 'timeout')) {
434
+ response.cancel();
435
+ throw new FetchFailure('timeout', bodyTimedOut, { cause: error });
436
+ }
437
+ if (error instanceof FetchFailure)
438
+ throw error;
439
+ throw new FetchFailure('network', `response body failed: ${current.href}`, { cause: error });
440
+ }
441
+ finally {
442
+ clearTimeout(timer);
443
+ }
444
+ }
445
+ throw new FetchFailure('redirect', `more than ${maxRedirects} redirects`);
446
+ };
447
+ }
448
+ export const safeFetch = createSafeFetch();
449
+ export function decodeBytes(bytes, contentType) {
450
+ const charset = /charset\s*=\s*["']?([^;"'\s]+)/i.exec(contentType ?? '')?.[1]?.toLowerCase();
451
+ const encoding = charset === 'iso-8859-1' || charset === 'windows-1252' ? 'windows-1252' : 'utf-8';
452
+ return new TextDecoder(encoding, { fatal: false }).decode(bytes);
453
+ }
@@ -0,0 +1,26 @@
1
+ export type CaptureScope = 'auto' | 'page' | 'thread' | 'comments';
2
+ export type AcquisitionMethod = 'http' | 'hacker-news-api' | 'bluesky-api' | 'reddit-json' | 'browser-cdp';
3
+ export type AcquiredPage = {
4
+ readonly body: string;
5
+ readonly contentType: string | null;
6
+ readonly finalUrl: URL;
7
+ readonly method: AcquisitionMethod;
8
+ readonly warnings: readonly string[];
9
+ readonly browserTitle?: string;
10
+ readonly contentTruncated?: boolean;
11
+ readonly renderedText?: string;
12
+ readonly renderedTextTruncated?: boolean;
13
+ readonly renderedTextByteLimit?: number;
14
+ };
15
+ /** The bounded knobs one capture runs under; every lane reads the same values. */
16
+ export type CaptureOptions = {
17
+ readonly url: URL;
18
+ readonly scope: CaptureScope;
19
+ readonly timeoutMs: number;
20
+ readonly maxItems: number;
21
+ readonly maxDepth: number;
22
+ readonly maxHtmlBytes: number;
23
+ readonly allowPrivateNetwork: boolean;
24
+ readonly userAgent: string;
25
+ };
26
+ export declare const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
@@ -0,0 +1 @@
1
+ export const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
@@ -8,8 +8,10 @@ const globals = {
8
8
  help: 'boolean',
9
9
  };
10
10
  /** A usage error names its command so the CLI can print that command's synopsis. */
11
- const usage = (command) => {
12
- throw new SpongeError('invalid-input', { detail: command ? { command } : {} });
11
+ const usage = (command, unknown) => {
12
+ throw new SpongeError('invalid-input', {
13
+ detail: { usage: true, ...(command ? { command } : {}), ...(unknown ? { unknown } : {}) },
14
+ });
13
15
  };
14
16
  /** No shell grammar, implicit option coercion, duplicate flags, or unbounded argv. Commands and their rules live in the registry. */
15
17
  export function parseArguments(argv) {
@@ -56,7 +58,7 @@ export function parseArguments(argv) {
56
58
  if (!command && flags.help)
57
59
  return { command: 'help', positional: [], flags: {}, json: false, debug: flags.debug === true };
58
60
  if (!command || !spec)
59
- return usage();
61
+ return usage(undefined, command);
60
62
  if (flags.help)
61
63
  return { command: 'help', positional: [spec.name], flags: {}, json: false, debug: flags.debug === true };
62
64
  if (typeof spec.positional === 'number' ? positional.length !== spec.positional : !spec.positional(positional))
@@ -4,11 +4,11 @@ import { connectionFor, readConnections } from './config.js';
4
4
  import { pdfSource } from '../pdf/index.js';
5
5
  import { articleSource, inferKind, mediaSource } from '../app/source.js';
6
6
  import { pdfToolsAvailable } from '../pdf/tools.js';
7
- import { queueAction } from './queue.js';
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';
@@ -28,6 +28,11 @@ import { minAge, publishSite } from '../publish/index.js';
28
28
  import { SpongeError, codeOf, publicCode, exitFor, isSpongeCode } from '../errors.js';
29
29
  const abs = (v) => typeof v === 'string' && isAbsolute(v);
30
30
  const media = { language: 'value', 'force-local': 'boolean', 'allow-private-network': 'boolean' };
31
+ /** Only a public web article or PDF has an Internet Archive copy; a signed-in page's copy would not be the reader's. */
32
+ function archivable(kind, locator, connection) {
33
+ if (kind === 'media' || connection || !/^https?:/.test(locator))
34
+ throw new SpongeError('unsupported');
35
+ }
31
36
  const lines = (items) => items.map((item) => `${item.itemId}\t${item.title}`);
32
37
  /** The failure of a job that did not run here: its state, or its recorded reason. */
33
38
  function jobFailure(store, jobId) {
@@ -169,9 +174,12 @@ export const commands = [
169
174
  {
170
175
  name: 'add',
171
176
  summary: 'Save an article, PDF or media source',
172
- usage: [['add URL_OR_PDF [--kind article|pdf|media] [--connection ID] [--wait]', '']],
177
+ usage: [
178
+ ['add URL_OR_PDF [--kind article|pdf|media] [--connection ID] [--wait]', ''],
179
+ ['add URL --archive [--wait]', 'Capture the newest Internet Archive copy of a dead or paywalled page'],
180
+ ],
173
181
  positional: 1,
174
- flags: { connection: 'value', kind: 'value', wait: 'boolean', ...media },
182
+ flags: { connection: 'value', kind: 'value', wait: 'boolean', archive: 'boolean', ...media },
175
183
  library: 'open',
176
184
  handler: async (c) => {
177
185
  const { store, args } = c, connections = await readConnections(store.root);
@@ -180,7 +188,15 @@ export const commands = [
180
188
  throw new SpongeError('invalid-input');
181
189
  const input = args.positional[0], kind = (args.flags.kind ?? inferKind(input));
182
190
  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;
191
+ const archive = args.flags.archive === true;
192
+ if (archive)
193
+ archivable(kind, source.locator, connection);
194
+ const submission = store.queue.submit({
195
+ kind,
196
+ ...source,
197
+ ...(archive ? { intent: 'archive' } : {}),
198
+ idempotencyKey: (archive ? 'archive:' : 'save:') + source.sourceKey,
199
+ }), { jobId, itemId } = submission;
184
200
  const job = store.queue.list().find((j) => j.id === jobId);
185
201
  if (!args.flags.wait) {
186
202
  c.emit({ ...submission, state: job?.state ?? 'queued' }, `${itemId}\t${job?.state ?? 'queued'}`);
@@ -234,18 +250,27 @@ export const commands = [
234
250
  flags: {},
235
251
  library: 'open',
236
252
  handler: async (c) => {
237
- c.emit(await queueAction(c.store, c.args.positional[0], c.args.positional[1]));
253
+ const action = c.args.positional[0], result = await queueAction(c.store, action, c.args.positional[1]);
254
+ c.emit(result, queueText(c.store, action, result));
238
255
  return 0;
239
256
  },
240
257
  },
241
258
  {
242
259
  name: 'list',
243
260
  summary: 'List saved and queued items',
244
- usage: [['list', 'List saved and queued items']],
261
+ usage: [
262
+ ['list', 'List saved and queued items'],
263
+ ['list --short', 'Web articles saved with under 200 words: paywalls, error pages, script-only pages'],
264
+ ],
245
265
  positional: 0,
246
- flags: {},
266
+ flags: { short: 'boolean' },
247
267
  library: 'open',
248
268
  handler: (c) => {
269
+ if (c.args.flags.short) {
270
+ const items = shortCaptures(c.store);
271
+ c.emit({ items }, items.map((item) => `${item.itemId}\t${item.words}\t${item.title}`).join('\n'));
272
+ return 0;
273
+ }
249
274
  const items = c.store.list();
250
275
  c.emit({ items }, lines(items).join('\n'));
251
276
  return 0;
@@ -328,18 +353,24 @@ export const commands = [
328
353
  {
329
354
  name: 'refresh',
330
355
  summary: 'Capture a new revision',
331
- usage: [['refresh ITEM [--wait]', 'Capture a new revision']],
356
+ usage: [
357
+ ['refresh ITEM [--wait]', 'Capture a new revision'],
358
+ ['refresh ITEM --archive [--wait]', 'Capture the newest Internet Archive copy instead of the live page'],
359
+ ],
332
360
  positional: 1,
333
- flags: { wait: 'boolean', ...media },
361
+ flags: { wait: 'boolean', archive: 'boolean', ...media },
334
362
  library: 'open',
335
363
  handler: async (c) => {
336
364
  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));
337
365
  if (!source || !['article', 'pdf', 'media'].includes(source.kind))
338
366
  throw new SpongeError('unavailable');
367
+ const { archive: _, ...rest } = source, kind = source.kind;
368
+ if (args.flags.archive)
369
+ archivable(kind, source.locator);
339
370
  const submission = store.queue.submit({
340
- ...source,
341
- kind: source.kind,
342
- intent: 'refresh',
371
+ ...rest,
372
+ kind,
373
+ intent: args.flags.archive ? 'archive' : 'refresh',
343
374
  idempotencyKey: randomUUID(),
344
375
  });
345
376
  if (!args.flags.wait) {
@@ -4,20 +4,43 @@ import { lstat } from 'node:fs/promises';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { terminalSafe } from './terminal.js';
6
6
  import { parseArguments } from './arguments.js';
7
- import { command, usageOf } from './commands.js';
7
+ import { command, commands, usageOf } from './commands.js';
8
8
  import { openLibrary } from '../store/index.js';
9
9
  import { failure } from '../acquisition/errors.js';
10
10
  import { SpongeError, codeOf, publicCode, exitFor, hints, detailHints, debugChain, } from '../errors.js';
11
+ /** Names the registered command closest to a mistyped one, never the typed text, which may hold a URL or a secret. */
12
+ function unknownCommandHint(typed) {
13
+ const distance = (a, b) => {
14
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j);
15
+ for (let i = 1; i <= a.length; i++) {
16
+ const next = [i];
17
+ for (let j = 1; j <= b.length; j++)
18
+ next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
19
+ row = next;
20
+ }
21
+ return row[b.length];
22
+ };
23
+ const [best] = commands
24
+ .map((c) => ({ name: c.name, d: distance(typed.toLowerCase().slice(0, 32), c.name) }))
25
+ .filter(({ d }) => d <= 2)
26
+ .sort((a, b) => a.d - b.d);
27
+ return `Unknown command.${best ? ` Did you mean \`sponge ${best.name}\`?` : ''} Run \`sponge help\` for the list.`;
28
+ }
11
29
  /** Failures print one categorical JSON line: the public code, its specific `reason` when narrower, and a fixed hint.
12
30
  * Causes, details, paths and upstream messages appear only with --debug or SPONGE_DEBUG=1. */
13
31
  function report(error, requestId, debug) {
14
- const reason = codeOf(error), code = publicCode(reason), issues = error?.issues, usage = error?.detail?.command;
32
+ const reason = codeOf(error), code = publicCode(reason), issues = error?.issues, detail = error?.detail, usage = detail?.command, unknown = detail?.unknown;
15
33
  if (Array.isArray(issues))
16
34
  process.stderr.write(JSON.stringify({ issues }) + '\n');
17
35
  const hint = code === 'invalid-input' && typeof usage === 'string' && command(usage)
18
36
  ? `Usage: ${usageOf(usage).replace(/\n/g, ' | ')}`
19
- : (detailHints[reason] ?? hints[code]);
20
- process.stderr.write(JSON.stringify({ ...failure(requestId, code), ...(reason !== code ? { reason } : {}), hint }) + '\n');
37
+ : code === 'invalid-input' && typeof unknown === 'string'
38
+ ? unknownCommandHint(unknown)
39
+ : (detailHints[reason] ?? hints[code]);
40
+ // A usage error happens before any acquisition starts, so it names no phase.
41
+ const { phase, ...envelope } = failure(requestId, code);
42
+ process.stderr.write(JSON.stringify({ ...envelope, ...(detail?.usage ? {} : { phase }), ...(reason !== code ? { reason } : {}), hint }) +
43
+ '\n');
21
44
  if (debug)
22
45
  process.stderr.write(debugChain(error));
23
46
  return exitFor(code);
@@ -1,2 +1,4 @@
1
1
  import type { LibraryStore } from '../store/index.js';
2
2
  export declare function queueAction(store: LibraryStore, action: 'status' | 'cancel' | 'retry' | 'recover', jobId?: string): Promise<Record<string, unknown>>;
3
+ /** `sponge queue` without --json: open jobs with the IDs that cancel and retry take, then what was done. */
4
+ export declare function queueText(store: LibraryStore, action: 'status' | 'cancel' | 'retry' | 'recover', result: Record<string, unknown>): string;
@@ -1,5 +1,6 @@
1
1
  import { recoverWorker } from '../worker/custody.js';
2
2
  import { SpongeError } from '../errors.js';
3
+ import { openJobs } from './status.js';
3
4
  export async function queueAction(store, action, jobId) {
4
5
  if (action === 'status') {
5
6
  if (jobId)
@@ -25,3 +26,24 @@ export async function queueAction(store, action, jobId) {
25
26
  throw new SpongeError('invalid-input');
26
27
  return { jobId, action };
27
28
  }
29
+ /** `sponge queue` without --json: open jobs with the IDs that cancel and retry take, then what was done. */
30
+ export function queueText(store, action, result) {
31
+ if (action === 'cancel')
32
+ return `Cancelled ${String(result.jobId)}`;
33
+ if (action === 'retry')
34
+ return `Queued ${String(result.jobId)} to run again`;
35
+ if (action === 'recover')
36
+ return result.recovered
37
+ ? `Recovered the stopped worker; ${String(result.attempts)} unfinished attempts released`
38
+ : 'Nothing to recover';
39
+ const jobs = result.jobs, open = openJobs(store), titles = new Map(store.list().map((item) => [item.itemId, item.title])), lines = open.length ? open.map((job) => jobLine(job, titles.get(job.item_id))) : ['Nothing waiting'];
40
+ if (jobs.length > open.length)
41
+ lines.push(`${jobs.length - open.length} settled or replaced jobs hidden; --json lists every job`);
42
+ if (result.pendingCleanup)
43
+ lines.push(`Pending cleanup: ${String(result.pendingCleanup)}; run sponge queue recover`);
44
+ return lines.join('\n');
45
+ }
46
+ function jobLine(job, title) {
47
+ const reason = job.reason && job.reason !== job.state ? ` (${String(job.reason)})` : '';
48
+ return `${String(job.id)} ${String(job.operation)} ${String(job.state)}${reason} ${String(job.created_at).slice(0, 10)}${title ? ` ${title}` : ''}`;
49
+ }
@@ -1,4 +1,16 @@
1
1
  import type { LibraryStore } from '../store/index.js';
2
+ import type { Row } from '../store/context.js';
3
+ /**
4
+ * Jobs still waiting, running or needing the reader, oldest first. Settled jobs are history, and only each target's
5
+ * newest job counts: a failure that a later capture of the item (or index job) fixed is history too.
6
+ */
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
+ }[];
2
14
  /** What the library holds, what is waiting or failed, and what to run next: one screen for `sponge status`. */
3
15
  export declare function libraryStatus(store: LibraryStore): {
4
16
  items: {
@@ -16,10 +28,11 @@ export declare function libraryStatus(store: LibraryStore): {
16
28
  draft: number;
17
29
  none: number;
18
30
  };
31
+ short: number;
19
32
  jobs: {
20
33
  operation: string;
21
34
  state: string;
22
- reason: string | null;
35
+ reason: string;
23
36
  }[];
24
37
  pendingCleanup: number;
25
38
  };
@@ -1,3 +1,40 @@
1
+ import { proseWords } from '../app/markdown.js';
2
+ /**
3
+ * Jobs still waiting, running or needing the reader, oldest first. Settled jobs are history, and only each target's
4
+ * newest job counts: a failure that a later capture of the item (or index job) fixed is history too.
5
+ */
6
+ export function openJobs(store) {
7
+ const newest = new Map();
8
+ for (const job of store.queue.list())
9
+ newest.set(job.operation === 'index' ? 'index' : `${job.operation} ${job.item_id}`, job);
10
+ return [...newest.values()].filter((job) => !['succeeded', 'cancelled'].includes(String(job.state)));
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
+ }
1
38
  const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;
2
39
  /** What the library holds, what is waiting or failed, and what to run next: one screen for `sponge status`. */
3
40
  export function libraryStatus(store) {
@@ -7,14 +44,7 @@ export function libraryStatus(store) {
7
44
  const digest = store.derivations.current('digest', item.itemId), gist = digest?.review.gist;
8
45
  digests[!digest ? 'none' : gist === 'accepted' || gist === 'rejected' ? gist : 'draft']++;
9
46
  }
10
- // Settled jobs are history; what is left is waiting, running or needs the reader.
11
- // Only each target's newest job counts: a failure that a later capture of the item (or index job) fixed is history.
12
- const newest = new Map();
13
- for (const job of [...store.queue.list()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))))
14
- newest.set(job.operation === 'index' ? 'index' : `${job.operation} ${job.item_id}`, job);
15
- const jobs = [...newest.values()]
16
- .filter((job) => !['succeeded', 'cancelled'].includes(String(job.state)))
17
- .map((job) => ({
47
+ const jobs = openJobs(store).map((job) => ({
18
48
  operation: String(job.operation),
19
49
  state: String(job.state),
20
50
  reason: job.reason ?? null,
@@ -26,6 +56,7 @@ export function libraryStatus(store) {
26
56
  ? { embedded: generation.completed, expected: generation.expected, pending: generation.pending }
27
57
  : null,
28
58
  digests: { ...digests, auto: store.digests.settings().auto },
59
+ short: shortCaptures(store).length,
29
60
  jobs,
30
61
  pendingCleanup: store.queue.pendingCleanup(),
31
62
  };
@@ -51,6 +82,11 @@ export function statusText(s, worker) {
51
82
  : ''}`,
52
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'}`,
53
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
+ : []),
54
90
  `Queue: ${s.jobs.length
55
91
  ? Object.entries(counts)
56
92
  .map(([key, n]) => `${n} ${key}`)
@@ -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,16 @@ 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.archive
26
+ ? await (options.snapshot ?? waybackSnapshot)(source.locator, token.attemptId)
27
+ : null;
28
+ if (snapshot && 'ok' in snapshot)
29
+ return snapshot;
30
+ const fetched = snapshot?.url ?? source.locator;
24
31
  const result = await (options.capture ?? capturePdf)({
25
32
  runProcess: options.runProcess,
26
33
  stagingRoot: join(store.root, 'staging'),
27
- sourceUrl: source.locator,
34
+ sourceUrl: fetched,
28
35
  requestId: token.attemptId,
29
36
  connection: options.connection,
30
37
  signal: options.signal,
@@ -37,7 +44,7 @@ export function ingestPdf(store, token, options) {
37
44
  }
38
45
  attempt.hold(result);
39
46
  await options.checkpoint?.('extracting');
40
- const pdf = await storePdfCapture(store, token, result, source.locator, options.signal);
47
+ const pdf = await storePdfCapture(store, token, result, fetched, options.signal);
41
48
  // Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
42
49
  const bundleHash = sha256(JSON.stringify(['pdf-local-markdown-v1', pdf.capture.manifestSha256]));
43
50
  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. `archive` is set when any submission the job serves asked for the Internet Archive copy.
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
+ archive: boolean;
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'].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,16 @@ 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. `archive` is set when any submission the job serves asked for the Internet Archive copy.
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,EXISTS(SELECT 1 FROM submissions s WHERE s.job_id=j.id AND s.intent='archive') archive 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
+ return row ? { ...row, archive: row.archive === 1 } : null;
234
237
  }
235
238
  claimNext(token) {
236
239
  checkOwner(this.c.db, token);
@@ -27,7 +27,8 @@ export interface SourceSubmission {
27
27
  realm?: string;
28
28
  kind: ItemKind;
29
29
  idempotencyKey: string;
30
- intent?: 'save' | 'refresh';
30
+ /** `archive` captures the source's newest Internet Archive copy instead of the live page. */
31
+ intent?: 'save' | 'refresh' | 'archive';
31
32
  }
32
33
  export interface Submission {
33
34
  itemId: string;
@@ -33,16 +33,16 @@
33
33
  }
34
34
  ],
35
35
  "build": {
36
- "asrSourceSha256": "94c7527e57b87337bb90f4c3a36a305eae6ddb16d558226f40f17c16bb31587f",
36
+ "asrSourceSha256": "995c66f8bf9b9c724f44266e686b561171bdfe5aff063e758d2f2f5bc9523789",
37
37
  "clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
38
- "embeddingSourceSha256": "762fc9f018b12a8fb95e2d7349b2756622b1a40c8bd4cf2986fc290370a058a4",
39
- "mediaSourceSha256": "3945d6a5bf8606c91270a2f837e805aa911ddf40baf701f3b5bb98852c4627fd",
38
+ "embeddingSourceSha256": "8c8719739c48a174ea1bdc77862245534c6b963a4a824e82f9f5e27d7b24705c",
39
+ "mediaSourceSha256": "9d30fb7e66b79e6b584f564b7dc14c822a7a955068d9084ca3606f9d62a49c9a",
40
40
  "rustcVersion": "rustc 1.97.1 (8bab26f4f 2026-07-14)",
41
41
  "swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
42
42
  },
43
43
  "minimumMacOS": "26.0",
44
- "packageVersion": "0.6.6",
44
+ "packageVersion": "0.6.8",
45
45
  "platform": "darwin",
46
46
  "schemaVersion": 1,
47
- "sourceCommit": "516ab78f62c7bec19e6fe8e44bcf23ca8ebd8fb3"
47
+ "sourceCommit": "7003c66883fa24e04ec3b961b13207a813919025"
48
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/sponge",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "A local library for captured articles and verified offline assets.",
5
5
  "repository": {
6
6
  "type": "git",