anyfreebook-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +458 -2
- package/bin/anyfreebook.js +8 -0
- package/package.json +49 -4
- package/src/api.js +189 -0
- package/src/cli.js +241 -0
- package/src/commands/download.js +409 -0
- package/src/commands/health.js +24 -0
- package/src/commands/resolve.js +79 -0
- package/src/commands/search.js +128 -0
- package/src/config.js +113 -0
- package/src/errors.js +57 -0
- package/src/filename.js +75 -0
- package/src/format.js +167 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `anyfreebook download` — search, then write verified public-domain books to disk.
|
|
3
|
+
*
|
|
4
|
+
* Only results whose tier is `download` are ever written. `borrow` and
|
|
5
|
+
* `unverified` results are refused with an explanation, never silently skipped.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_DOWNLOAD_LIMIT,
|
|
13
|
+
DEFAULT_OUT_DIR,
|
|
14
|
+
FORMAT_PREFERENCE,
|
|
15
|
+
MAX_LIMIT,
|
|
16
|
+
MAX_QUERY_LENGTH,
|
|
17
|
+
USER_AGENT,
|
|
18
|
+
clampInt,
|
|
19
|
+
validateFormat,
|
|
20
|
+
validateSource,
|
|
21
|
+
} from '../config.js';
|
|
22
|
+
import { CliError, usageError } from '../errors.js';
|
|
23
|
+
import { fileNameFor, resolveWithin } from '../filename.js';
|
|
24
|
+
import { humanBytes, partialWarning } from '../format.js';
|
|
25
|
+
import { positionalText } from './search.js';
|
|
26
|
+
|
|
27
|
+
/** Pick the file to download for a result. */
|
|
28
|
+
export function pickFile(files, requestedFormat) {
|
|
29
|
+
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
|
30
|
+
if (!list.length) return null;
|
|
31
|
+
if (requestedFormat) {
|
|
32
|
+
return list.find((file) => String(file.format).toLowerCase() === requestedFormat) || null;
|
|
33
|
+
}
|
|
34
|
+
for (const format of FORMAT_PREFERENCE) {
|
|
35
|
+
const match = list.find((file) => String(file.format).toLowerCase() === format);
|
|
36
|
+
if (match) return match;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Explain, in plain words, why a non-download result cannot be downloaded. */
|
|
42
|
+
export function refusalMessage(result) {
|
|
43
|
+
const tier = String(result?.tier ?? '').toLowerCase();
|
|
44
|
+
const title = result?.title || result?.id || 'This title';
|
|
45
|
+
if (tier === 'borrow') {
|
|
46
|
+
return (
|
|
47
|
+
`"${title}" is borrow-only: a real Internet Archive copy exists but it is lending-restricted. ` +
|
|
48
|
+
'Reading it needs a free Internet Archive account and a loan, so there is no file to download.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (tier === 'unverified') {
|
|
52
|
+
return (
|
|
53
|
+
`"${title}" is unverified: no free, legal copy of this exact title could be confirmed, ` +
|
|
54
|
+
'so there is nothing to download.'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return `"${title}" has tier "${result?.tier ?? 'unknown'}" — only tier=download results may be downloaded.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lowercase, alphanumeric-only, single-spaced. Used to compare titles. */
|
|
61
|
+
function normaliseTitle(value) {
|
|
62
|
+
return String(value || '')
|
|
63
|
+
.toLowerCase()
|
|
64
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True when a result's own title is the title that was asked for.
|
|
70
|
+
*
|
|
71
|
+
* This matters because the search fans out to sources that match free text.
|
|
72
|
+
* "Pride and Prejudice" finds real academic papers about prejudice — one
|
|
73
|
+
* titled "assessing the societal and cultural factors influencing males
|
|
74
|
+
* pursuing nursing and midwifery" — and those papers are more numerous than
|
|
75
|
+
* the novel. Downloading the top hits without checking their titles means
|
|
76
|
+
* fetching whatever happened to rank highest.
|
|
77
|
+
*
|
|
78
|
+
* A single word is treated as a keyword and matched anywhere in the title. A
|
|
79
|
+
* multi-word phrase is treated as a title and must be the title, or the title
|
|
80
|
+
* followed by a subtitle — "Pride and Prejudice" is the novel, but "Pride and
|
|
81
|
+
* Prejudice in the air" is a different book that happens to start the same
|
|
82
|
+
* way.
|
|
83
|
+
*/
|
|
84
|
+
export function titleMatches(query, title) {
|
|
85
|
+
const want = normaliseTitle(query);
|
|
86
|
+
const got = normaliseTitle(title);
|
|
87
|
+
if (!want || !got) return false;
|
|
88
|
+
|
|
89
|
+
if (!want.includes(' ')) {
|
|
90
|
+
const escaped = want.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
91
|
+
return new RegExp(`\\b${escaped}\\b`).test(got);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (got === want) return true;
|
|
95
|
+
|
|
96
|
+
const rawWant = String(query || '').toLowerCase().trim();
|
|
97
|
+
const rawGot = String(title || '').toLowerCase().trim();
|
|
98
|
+
const separator = /^[:\-–—(\[]/;
|
|
99
|
+
|
|
100
|
+
// A subtitle or an edition marker may follow the title.
|
|
101
|
+
if (rawGot.startsWith(rawWant) && separator.test(rawGot.slice(rawWant.length).trimStart())) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
// The request may be more specific than the record's title.
|
|
105
|
+
if (rawWant.startsWith(rawGot) && separator.test(rawWant.slice(rawGot.length).trimStart())) {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function pathExists(target) {
|
|
112
|
+
try {
|
|
113
|
+
await fs.access(target);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Download one URL to disk.
|
|
122
|
+
* Writes to a `.part` file first so an interrupted transfer never leaves a
|
|
123
|
+
* truncated book behind.
|
|
124
|
+
*/
|
|
125
|
+
export async function downloadFile(url, destination, { timeoutMs, onProgress } = {}) {
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
response = await fetch(url, {
|
|
129
|
+
method: 'GET',
|
|
130
|
+
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
|
|
131
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
132
|
+
redirect: 'follow',
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const cause = (err && (err.cause || err)) || {};
|
|
136
|
+
const code = cause.code ? ` (${cause.code})` : '';
|
|
137
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
138
|
+
throw new CliError(`Download timed out after ${timeoutMs} ms: ${url}`, { code: 'timeout' });
|
|
139
|
+
}
|
|
140
|
+
throw new CliError(`Could not reach the file host${code}: ${url}`, {
|
|
141
|
+
code: 'network',
|
|
142
|
+
hint: 'Check your connection and retry. The file host may also be temporarily unavailable.',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
throw new CliError(`The file host answered HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`, {
|
|
148
|
+
status: response.status,
|
|
149
|
+
code: 'download_failed',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lengthHeader = response.headers && response.headers.get('content-length');
|
|
154
|
+
const total = lengthHeader ? Number(lengthHeader) : 0;
|
|
155
|
+
const temporary = `${destination}.part`;
|
|
156
|
+
const handle = await fs.open(temporary, 'w');
|
|
157
|
+
|
|
158
|
+
let received = 0;
|
|
159
|
+
try {
|
|
160
|
+
const body = response.body;
|
|
161
|
+
if (body && typeof body.getReader === 'function') {
|
|
162
|
+
const reader = body.getReader();
|
|
163
|
+
for (;;) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
if (value) {
|
|
167
|
+
await handle.write(value);
|
|
168
|
+
received += value.byteLength;
|
|
169
|
+
if (onProgress) onProgress(received, total);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
174
|
+
await handle.write(buffer);
|
|
175
|
+
received = buffer.byteLength;
|
|
176
|
+
if (onProgress) onProgress(received, total);
|
|
177
|
+
}
|
|
178
|
+
await handle.close();
|
|
179
|
+
await fs.rename(temporary, destination);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
await handle.close().catch(() => {});
|
|
182
|
+
await fs.unlink(temporary).catch(() => {});
|
|
183
|
+
if (err instanceof CliError) throw err;
|
|
184
|
+
throw new CliError(`Transfer failed: ${err && err.message ? err.message : String(err)}`, {
|
|
185
|
+
code: 'download_failed',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { bytes: received, total };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function downloadCommand(opts, ctx) {
|
|
193
|
+
const query = positionalText(opts._.slice(1));
|
|
194
|
+
if (!query) {
|
|
195
|
+
throw usageError(
|
|
196
|
+
'Missing search query.',
|
|
197
|
+
'Try: anyfreebook download "pride and prejudice" --format epub',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (query.length > MAX_QUERY_LENGTH) {
|
|
201
|
+
throw usageError(
|
|
202
|
+
`Query is ${query.length} characters; the API accepts at most ${MAX_QUERY_LENGTH}.`,
|
|
203
|
+
'Shorten the query.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const limit = clampInt(opts.limit, DEFAULT_DOWNLOAD_LIMIT, 1, MAX_LIMIT, '--limit');
|
|
208
|
+
const format = validateFormat(opts.format);
|
|
209
|
+
const source = validateSource(opts.source);
|
|
210
|
+
const force = Boolean(opts.force);
|
|
211
|
+
const dryRun = Boolean(opts.dryRun);
|
|
212
|
+
const outDir = path.resolve(opts.out || DEFAULT_OUT_DIR);
|
|
213
|
+
|
|
214
|
+
const author = typeof opts.author === 'string' ? opts.author.trim() : '';
|
|
215
|
+
|
|
216
|
+
// With an author, the resolve endpoint is authoritative: it matches the
|
|
217
|
+
// title AND the author against the real record, so a search that returns
|
|
218
|
+
// papers which merely contain the word "prejudice" cannot displace the
|
|
219
|
+
// novel. Without one, the search hits are filtered to the titles that
|
|
220
|
+
// actually match, for the same reason.
|
|
221
|
+
let results = [];
|
|
222
|
+
let resolvedDirectly = false;
|
|
223
|
+
|
|
224
|
+
if (author) {
|
|
225
|
+
const resolved = await ctx.api.resolve({ title: query, author });
|
|
226
|
+
if (resolved && resolved.tier === 'download') {
|
|
227
|
+
results = [resolved];
|
|
228
|
+
resolvedDirectly = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!resolvedDirectly) {
|
|
233
|
+
const data = await ctx.api.search({ q: query, page: 1, source, limit });
|
|
234
|
+
const failedSources = Array.isArray(data.failedSources) ? data.failedSources : [];
|
|
235
|
+
if (data.partial || failedSources.length > 0) {
|
|
236
|
+
ctx.err.write(`${partialWarning(failedSources, ctx.style)}\n`);
|
|
237
|
+
}
|
|
238
|
+
results = Array.isArray(data.results) ? data.results : [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const items = [];
|
|
242
|
+
|
|
243
|
+
for (const result of results) {
|
|
244
|
+
const title = result?.title || result?.id || 'book';
|
|
245
|
+
if (result?.tier !== 'download') {
|
|
246
|
+
items.push({ status: 'refused', title, book: result, message: refusalMessage(result) });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const file = pickFile(result.files, format);
|
|
250
|
+
if (!file) {
|
|
251
|
+
items.push({
|
|
252
|
+
status: 'skipped',
|
|
253
|
+
title,
|
|
254
|
+
book: result,
|
|
255
|
+
message: format
|
|
256
|
+
? `No ${format} file was offered for "${title}".`
|
|
257
|
+
: `The API did not return a downloadable file for "${title}".`,
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// Only download a file when the result is the title that was asked for.
|
|
262
|
+
// The search fans out to sources that match free text, so "Pride and
|
|
263
|
+
// Prejudice" also finds real papers about prejudice — and one of those
|
|
264
|
+
// can outrank the novel and carry a perfectly downloadable PDF.
|
|
265
|
+
if (!resolvedDirectly && !titleMatches(query, result?.title)) {
|
|
266
|
+
items.push({
|
|
267
|
+
status: 'skipped',
|
|
268
|
+
title,
|
|
269
|
+
book: result,
|
|
270
|
+
message: `Not titled "${query}" — not downloading it as if it were the book you asked for.`,
|
|
271
|
+
});
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
items.push({ status: 'pending', title, book: result, file });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!dryRun && items.some((item) => item.status === 'pending')) {
|
|
278
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const summary = { downloaded: 0, skipped: 0, failed: 0, refused: 0, planned: 0 };
|
|
282
|
+
|
|
283
|
+
for (const item of items) {
|
|
284
|
+
if (item.status === 'refused') {
|
|
285
|
+
summary.refused += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (item.status === 'skipped') {
|
|
289
|
+
summary.skipped += 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const extension = String(item.file.format || 'bin').toLowerCase();
|
|
294
|
+
const name = fileNameFor(item.title, extension, item.book?.id);
|
|
295
|
+
const destination = resolveWithin(outDir, name);
|
|
296
|
+
if (!destination) {
|
|
297
|
+
item.status = 'failed';
|
|
298
|
+
item.message = `Refused to write outside ${outDir}.`;
|
|
299
|
+
summary.failed += 1;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!dryRun && (await pathExists(destination)) && !force) {
|
|
304
|
+
item.status = 'skipped';
|
|
305
|
+
item.message = `${destination} already exists — pass --force to overwrite it.`;
|
|
306
|
+
summary.skipped += 1;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (dryRun) {
|
|
311
|
+
item.status = 'planned';
|
|
312
|
+
item.path = destination;
|
|
313
|
+
item.message = `would download → ${destination}`;
|
|
314
|
+
summary.planned += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const { bytes } = await downloadFile(item.file.url, destination, {
|
|
320
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
321
|
+
onProgress: (received, total) => {
|
|
322
|
+
if (!ctx.err.isTTY) return;
|
|
323
|
+
const size = total ? `${humanBytes(received)} / ${humanBytes(total)}` : humanBytes(received);
|
|
324
|
+
const percent = total ? ` (${Math.min(100, Math.round((received / total) * 100))}%)` : '';
|
|
325
|
+
const line = ` ↓ ${name} ${size}${percent}`;
|
|
326
|
+
ctx.err.write(`\r${line.padEnd(72)}`);
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
item.status = 'downloaded';
|
|
330
|
+
item.path = destination;
|
|
331
|
+
item.bytes = bytes;
|
|
332
|
+
summary.downloaded += 1;
|
|
333
|
+
ctx.err.write(
|
|
334
|
+
`\r${` ✓ ${name} ${humanBytes(bytes)}`.padEnd(72)}\n`,
|
|
335
|
+
);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
item.status = 'failed';
|
|
338
|
+
item.message = err && err.message ? err.message : String(err);
|
|
339
|
+
summary.failed += 1;
|
|
340
|
+
ctx.err.write(`\r${` ✗ ${name} — ${item.message}`.padEnd(72)}\n`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (ctx.json) {
|
|
345
|
+
ctx.out.write(
|
|
346
|
+
`${JSON.stringify(
|
|
347
|
+
{
|
|
348
|
+
query,
|
|
349
|
+
out: outDir,
|
|
350
|
+
dryRun,
|
|
351
|
+
format: format || 'best-available',
|
|
352
|
+
items: items.map((item) => ({
|
|
353
|
+
title: item.title,
|
|
354
|
+
id: item.book?.id ?? null,
|
|
355
|
+
tier: item.book?.tier ?? null,
|
|
356
|
+
status: item.status,
|
|
357
|
+
format: item.file?.format ?? null,
|
|
358
|
+
url: item.file?.url ?? null,
|
|
359
|
+
path: item.path ?? null,
|
|
360
|
+
bytes: item.bytes ?? null,
|
|
361
|
+
message: item.message ?? null,
|
|
362
|
+
})),
|
|
363
|
+
summary,
|
|
364
|
+
},
|
|
365
|
+
null,
|
|
366
|
+
2,
|
|
367
|
+
)}\n`,
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const lines = [];
|
|
373
|
+
if (dryRun) {
|
|
374
|
+
lines.push(ctx.style.bold(`Dry run — nothing was written. Destination would be ${outDir}`));
|
|
375
|
+
lines.push('');
|
|
376
|
+
}
|
|
377
|
+
if (!items.length) {
|
|
378
|
+
lines.push(ctx.style.yellow(`No results found for "${query}".`));
|
|
379
|
+
}
|
|
380
|
+
for (const item of items) {
|
|
381
|
+
const mark =
|
|
382
|
+
item.status === 'downloaded' || item.status === 'planned'
|
|
383
|
+
? ctx.style.green('✓')
|
|
384
|
+
: item.status === 'refused'
|
|
385
|
+
? ctx.style.yellow('⊘')
|
|
386
|
+
: ctx.style.red('✗');
|
|
387
|
+
const detail =
|
|
388
|
+
item.status === 'downloaded'
|
|
389
|
+
? `${item.path} (${humanBytes(item.bytes)})`
|
|
390
|
+
: item.message || item.status;
|
|
391
|
+
lines.push(`${mark} ${item.title} — ${detail}`);
|
|
392
|
+
}
|
|
393
|
+
lines.push('');
|
|
394
|
+
lines.push(
|
|
395
|
+
ctx.style.bold('Summary:') +
|
|
396
|
+
` downloaded ${summary.downloaded} · skipped ${summary.skipped} · failed ${summary.failed} · refused ${summary.refused}` +
|
|
397
|
+
(dryRun ? ` · would download ${summary.planned}` : ''),
|
|
398
|
+
);
|
|
399
|
+
if (summary.refused) {
|
|
400
|
+
lines.push(
|
|
401
|
+
ctx.style.dim(
|
|
402
|
+
'Refused results are not downloadable: tier=borrow needs a free Internet Archive account and a loan, ' +
|
|
403
|
+
'tier=unverified means no free copy of that exact title could be confirmed.',
|
|
404
|
+
),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
ctx.out.write(`${lines.join('\n')}\n`);
|
|
409
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `anyfreebook health` — is the API reachable?
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export async function healthCommand(opts, ctx) {
|
|
6
|
+
const data = await ctx.api.health();
|
|
7
|
+
|
|
8
|
+
if (ctx.json) {
|
|
9
|
+
ctx.out.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const uptime = Number(data.uptimeSeconds);
|
|
14
|
+
const lines = [];
|
|
15
|
+
lines.push(`${ctx.style.bold('Status')} ${data.status || 'unknown'}`);
|
|
16
|
+
if (data.apiVersion) lines.push(`${ctx.style.bold('API ver')} ${data.apiVersion}`);
|
|
17
|
+
if (Number.isFinite(uptime)) {
|
|
18
|
+
const minutes = Math.floor(uptime / 60);
|
|
19
|
+
const seconds = uptime % 60;
|
|
20
|
+
lines.push(`${ctx.style.bold('Uptime')} ${uptime}s (${minutes}m ${seconds}s)`);
|
|
21
|
+
}
|
|
22
|
+
lines.push(`${ctx.style.bold('Base URL')} ${ctx.config.base}`);
|
|
23
|
+
ctx.out.write(`${lines.join('\n')}\n`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `anyfreebook resolve` — authoritative legal status for one specific title.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { usageError } from '../errors.js';
|
|
6
|
+
import { formatTier } from '../format.js';
|
|
7
|
+
import { positionalText } from './search.js';
|
|
8
|
+
|
|
9
|
+
export async function resolveCommand(opts, ctx) {
|
|
10
|
+
const title = positionalText(opts._.slice(1));
|
|
11
|
+
if (!title) {
|
|
12
|
+
throw usageError(
|
|
13
|
+
'Missing title.',
|
|
14
|
+
'Try: anyfreebook resolve "Pride and Prejudice" --author "Jane Austen"',
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const author = typeof opts.author === 'string' ? opts.author.trim() : '';
|
|
18
|
+
|
|
19
|
+
const data = await ctx.api.resolve({ title, author: author || undefined });
|
|
20
|
+
|
|
21
|
+
if (ctx.json) {
|
|
22
|
+
ctx.out.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const files = Array.isArray(data.files) ? data.files : [];
|
|
27
|
+
const links = Array.isArray(data.links) ? data.links : [];
|
|
28
|
+
const lines = [];
|
|
29
|
+
|
|
30
|
+
lines.push(ctx.style.bold(data.title || title));
|
|
31
|
+
if (data.author) lines.push(`${ctx.style.bold('Author')} ${data.author}`);
|
|
32
|
+
lines.push(`${ctx.style.bold('Tier')} ${formatTier(data.tier, ctx.style)}`);
|
|
33
|
+
if (data.tierReason) lines.push(`${ctx.style.bold('Reason')} ${data.tierReason}`);
|
|
34
|
+
if (data.license) lines.push(`${ctx.style.bold('License')} ${data.license}`);
|
|
35
|
+
if (data.sourceUrl || data.sourceType) {
|
|
36
|
+
const source = data.sourceType ? ` (${data.sourceType})` : '';
|
|
37
|
+
lines.push(`${ctx.style.bold('Source')} ${data.sourceUrl || data.sourceType}${source}`);
|
|
38
|
+
}
|
|
39
|
+
lines.push('');
|
|
40
|
+
|
|
41
|
+
lines.push(`${ctx.style.bold(`Files (${files.length})`)}`);
|
|
42
|
+
if (files.length) {
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
const label = file.label || String(file.format || 'file').toUpperCase();
|
|
45
|
+
const size = file.bytes ? ` — ${file.bytes} bytes` : '';
|
|
46
|
+
lines.push(` ${label.padEnd(10)} ${file.url}${size}`);
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
lines.push(ctx.style.dim(' none — this title has no direct file URL.'));
|
|
50
|
+
}
|
|
51
|
+
lines.push('');
|
|
52
|
+
|
|
53
|
+
lines.push(`${ctx.style.bold(`Links (${links.length})`)}`);
|
|
54
|
+
if (links.length) {
|
|
55
|
+
for (const link of links) {
|
|
56
|
+
const source = link.source ? ` [${link.source}]` : '';
|
|
57
|
+
lines.push(` ${link.label || 'Link'}${source} — ${link.url}`);
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
lines.push(ctx.style.dim(' none'));
|
|
61
|
+
}
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
if (data.tier === 'download') {
|
|
65
|
+
lines.push(ctx.style.dim(`Next: anyfreebook download "${data.title || title}" --format epub`));
|
|
66
|
+
} else if (data.tier === 'borrow') {
|
|
67
|
+
lines.push(
|
|
68
|
+
ctx.style.dim(
|
|
69
|
+
'Borrow-only: read it on the source site with a free Internet Archive account and a loan — it cannot be downloaded.',
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
lines.push(
|
|
74
|
+
ctx.style.dim('Unverified: no confirmed free copy of this exact title, so the CLI will not download it.'),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ctx.out.write(`${lines.join('\n')}\n`);
|
|
79
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `anyfreebook search` — fan out one query across the open-access sources.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SEARCH_LIMIT,
|
|
7
|
+
MAX_LIMIT,
|
|
8
|
+
MAX_QUERY_LENGTH,
|
|
9
|
+
clampInt,
|
|
10
|
+
validateFormat,
|
|
11
|
+
validateSource,
|
|
12
|
+
} from '../config.js';
|
|
13
|
+
import { usageError } from '../errors.js';
|
|
14
|
+
import { formatTier, partialWarning, renderSearchTable, tierLegend } from '../format.js';
|
|
15
|
+
|
|
16
|
+
/** Join the positional arguments into a single piece of text. */
|
|
17
|
+
export function positionalText(parts) {
|
|
18
|
+
return parts
|
|
19
|
+
.map((part) => String(part ?? '').trim())
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join(' ')
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the result carries a file in the requested format. */
|
|
26
|
+
export function hasFormat(result, format) {
|
|
27
|
+
if (!format) return true;
|
|
28
|
+
const files = Array.isArray(result?.files) ? result.files : [];
|
|
29
|
+
return files.some((file) => file && String(file.format).toLowerCase() === format);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function searchCommand(opts, ctx) {
|
|
33
|
+
const query = positionalText(opts._.slice(1));
|
|
34
|
+
if (!query) {
|
|
35
|
+
throw usageError('Missing search query.', 'Try: anyfreebook search "pride and prejudice"');
|
|
36
|
+
}
|
|
37
|
+
if (query.length > MAX_QUERY_LENGTH) {
|
|
38
|
+
throw usageError(
|
|
39
|
+
`Query is ${query.length} characters; the API accepts at most ${MAX_QUERY_LENGTH}.`,
|
|
40
|
+
'Shorten the query.',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const limit = clampInt(opts.limit, DEFAULT_SEARCH_LIMIT, 1, MAX_LIMIT, '--limit');
|
|
45
|
+
const page = clampInt(opts.page, 1, 1, 1000, '--page');
|
|
46
|
+
const source = validateSource(opts.source);
|
|
47
|
+
const format = validateFormat(opts.format);
|
|
48
|
+
const freeOnly = Boolean(opts.freeOnly);
|
|
49
|
+
|
|
50
|
+
const data = await ctx.api.search({ q: query, page, source, limit });
|
|
51
|
+
const all = Array.isArray(data.results) ? data.results : [];
|
|
52
|
+
const results = all.filter(
|
|
53
|
+
(result) => (!freeOnly || result?.tier === 'download') && hasFormat(result, format),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const failedSources = Array.isArray(data.failedSources) ? data.failedSources : [];
|
|
57
|
+
const partial = Boolean(data.partial) || failedSources.length > 0;
|
|
58
|
+
if (partial) {
|
|
59
|
+
// Always surfaced: on stderr in both modes so `--json` stdout stays pure.
|
|
60
|
+
ctx.err.write(`${partialWarning(failedSources, ctx.style)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (ctx.json) {
|
|
64
|
+
ctx.out.write(
|
|
65
|
+
`${JSON.stringify({ ...data, results, count: results.length }, null, 2)}\n`,
|
|
66
|
+
);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const total = Number.isFinite(Number(data.total)) ? Number(data.total) : all.length;
|
|
71
|
+
const lines = [];
|
|
72
|
+
lines.push(`${ctx.style.bold('Query')} ${query}`);
|
|
73
|
+
lines.push(
|
|
74
|
+
`${ctx.style.bold('Page')} ${page} · limit ${limit} · ${all.length} returned · ${total} total matches`,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const sourceCounts = data.sources && typeof data.sources === 'object' ? data.sources : {};
|
|
78
|
+
const sourceEntries = Object.entries(sourceCounts);
|
|
79
|
+
if (sourceEntries.length) {
|
|
80
|
+
const shown = sourceEntries
|
|
81
|
+
.slice(0, 6)
|
|
82
|
+
.map(([name, count]) => `${name} ${count}`)
|
|
83
|
+
.join(' · ');
|
|
84
|
+
const extra = sourceEntries.length > 6 ? ` · +${sourceEntries.length - 6} more` : '';
|
|
85
|
+
lines.push(`${ctx.style.bold('Sources')} ${shown}${extra}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push('');
|
|
88
|
+
|
|
89
|
+
if (!results.length) {
|
|
90
|
+
if (freeOnly && all.length) {
|
|
91
|
+
lines.push(
|
|
92
|
+
ctx.style.yellow(
|
|
93
|
+
`No results with a verified free download. The search returned ${all.length} result(s), ` +
|
|
94
|
+
'but none of them had tier=download.',
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
} else if (format && all.length) {
|
|
98
|
+
lines.push(
|
|
99
|
+
ctx.style.yellow(
|
|
100
|
+
`No results with a ${format} file. The search returned ${all.length} result(s), ` +
|
|
101
|
+
'but none of them offered that format.',
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
lines.push(ctx.style.yellow(`No results found for "${query}".`));
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
lines.push(renderSearchTable(results, { style: ctx.style, width: ctx.width }));
|
|
109
|
+
lines.push('');
|
|
110
|
+
if (results.length < all.length) {
|
|
111
|
+
const filters = [];
|
|
112
|
+
if (freeOnly) filters.push('tier=download');
|
|
113
|
+
if (format) filters.push(`format=${format}`);
|
|
114
|
+
lines.push(
|
|
115
|
+
ctx.style.dim(
|
|
116
|
+
`Showing ${results.length} of ${all.length} results on this page (filtered by ${filters.join(', ')}).`,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (total > all.length) {
|
|
121
|
+
lines.push(ctx.style.dim(`More results available — use --page ${page + 1} for the next page.`));
|
|
122
|
+
}
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(ctx.style.dim(tierLegend(ctx.style)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ctx.out.write(`${lines.join('\n')}\n`);
|
|
128
|
+
}
|