anyfreebook-cli 0.0.0-stage → 0.1.0
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 +444 -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 +325 -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,325 @@
|
|
|
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
|
+
async function pathExists(target) {
|
|
61
|
+
try {
|
|
62
|
+
await fs.access(target);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Download one URL to disk.
|
|
71
|
+
* Writes to a `.part` file first so an interrupted transfer never leaves a
|
|
72
|
+
* truncated book behind.
|
|
73
|
+
*/
|
|
74
|
+
export async function downloadFile(url, destination, { timeoutMs, onProgress } = {}) {
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetch(url, {
|
|
78
|
+
method: 'GET',
|
|
79
|
+
headers: { 'user-agent': USER_AGENT, accept: '*/*' },
|
|
80
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
81
|
+
redirect: 'follow',
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const cause = (err && (err.cause || err)) || {};
|
|
85
|
+
const code = cause.code ? ` (${cause.code})` : '';
|
|
86
|
+
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
87
|
+
throw new CliError(`Download timed out after ${timeoutMs} ms: ${url}`, { code: 'timeout' });
|
|
88
|
+
}
|
|
89
|
+
throw new CliError(`Could not reach the file host${code}: ${url}`, {
|
|
90
|
+
code: 'network',
|
|
91
|
+
hint: 'Check your connection and retry. The file host may also be temporarily unavailable.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new CliError(`The file host answered HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}.`, {
|
|
97
|
+
status: response.status,
|
|
98
|
+
code: 'download_failed',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lengthHeader = response.headers && response.headers.get('content-length');
|
|
103
|
+
const total = lengthHeader ? Number(lengthHeader) : 0;
|
|
104
|
+
const temporary = `${destination}.part`;
|
|
105
|
+
const handle = await fs.open(temporary, 'w');
|
|
106
|
+
|
|
107
|
+
let received = 0;
|
|
108
|
+
try {
|
|
109
|
+
const body = response.body;
|
|
110
|
+
if (body && typeof body.getReader === 'function') {
|
|
111
|
+
const reader = body.getReader();
|
|
112
|
+
for (;;) {
|
|
113
|
+
const { done, value } = await reader.read();
|
|
114
|
+
if (done) break;
|
|
115
|
+
if (value) {
|
|
116
|
+
await handle.write(value);
|
|
117
|
+
received += value.byteLength;
|
|
118
|
+
if (onProgress) onProgress(received, total);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
123
|
+
await handle.write(buffer);
|
|
124
|
+
received = buffer.byteLength;
|
|
125
|
+
if (onProgress) onProgress(received, total);
|
|
126
|
+
}
|
|
127
|
+
await handle.close();
|
|
128
|
+
await fs.rename(temporary, destination);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
await handle.close().catch(() => {});
|
|
131
|
+
await fs.unlink(temporary).catch(() => {});
|
|
132
|
+
if (err instanceof CliError) throw err;
|
|
133
|
+
throw new CliError(`Transfer failed: ${err && err.message ? err.message : String(err)}`, {
|
|
134
|
+
code: 'download_failed',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { bytes: received, total };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function downloadCommand(opts, ctx) {
|
|
142
|
+
const query = positionalText(opts._.slice(1));
|
|
143
|
+
if (!query) {
|
|
144
|
+
throw usageError(
|
|
145
|
+
'Missing search query.',
|
|
146
|
+
'Try: anyfreebook download "pride and prejudice" --format epub',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (query.length > MAX_QUERY_LENGTH) {
|
|
150
|
+
throw usageError(
|
|
151
|
+
`Query is ${query.length} characters; the API accepts at most ${MAX_QUERY_LENGTH}.`,
|
|
152
|
+
'Shorten the query.',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const limit = clampInt(opts.limit, DEFAULT_DOWNLOAD_LIMIT, 1, MAX_LIMIT, '--limit');
|
|
157
|
+
const format = validateFormat(opts.format);
|
|
158
|
+
const source = validateSource(opts.source);
|
|
159
|
+
const force = Boolean(opts.force);
|
|
160
|
+
const dryRun = Boolean(opts.dryRun);
|
|
161
|
+
const outDir = path.resolve(opts.out || DEFAULT_OUT_DIR);
|
|
162
|
+
|
|
163
|
+
const data = await ctx.api.search({ q: query, page: 1, source, limit });
|
|
164
|
+
const failedSources = Array.isArray(data.failedSources) ? data.failedSources : [];
|
|
165
|
+
if (data.partial || failedSources.length > 0) {
|
|
166
|
+
ctx.err.write(`${partialWarning(failedSources, ctx.style)}\n`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
170
|
+
const items = [];
|
|
171
|
+
|
|
172
|
+
for (const result of results) {
|
|
173
|
+
const title = result?.title || result?.id || 'book';
|
|
174
|
+
if (result?.tier !== 'download') {
|
|
175
|
+
items.push({ status: 'refused', title, book: result, message: refusalMessage(result) });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const file = pickFile(result.files, format);
|
|
179
|
+
if (!file) {
|
|
180
|
+
items.push({
|
|
181
|
+
status: 'skipped',
|
|
182
|
+
title,
|
|
183
|
+
book: result,
|
|
184
|
+
message: format
|
|
185
|
+
? `No ${format} file was offered for "${title}".`
|
|
186
|
+
: `The API did not return a downloadable file for "${title}".`,
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
items.push({ status: 'pending', title, book: result, file });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!dryRun && items.some((item) => item.status === 'pending')) {
|
|
194
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const summary = { downloaded: 0, skipped: 0, failed: 0, refused: 0, planned: 0 };
|
|
198
|
+
|
|
199
|
+
for (const item of items) {
|
|
200
|
+
if (item.status === 'refused') {
|
|
201
|
+
summary.refused += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (item.status === 'skipped') {
|
|
205
|
+
summary.skipped += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const extension = String(item.file.format || 'bin').toLowerCase();
|
|
210
|
+
const name = fileNameFor(item.title, extension, item.book?.id);
|
|
211
|
+
const destination = resolveWithin(outDir, name);
|
|
212
|
+
if (!destination) {
|
|
213
|
+
item.status = 'failed';
|
|
214
|
+
item.message = `Refused to write outside ${outDir}.`;
|
|
215
|
+
summary.failed += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!dryRun && (await pathExists(destination)) && !force) {
|
|
220
|
+
item.status = 'skipped';
|
|
221
|
+
item.message = `${destination} already exists — pass --force to overwrite it.`;
|
|
222
|
+
summary.skipped += 1;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (dryRun) {
|
|
227
|
+
item.status = 'planned';
|
|
228
|
+
item.path = destination;
|
|
229
|
+
item.message = `would download → ${destination}`;
|
|
230
|
+
summary.planned += 1;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const { bytes } = await downloadFile(item.file.url, destination, {
|
|
236
|
+
timeoutMs: ctx.config.timeoutMs,
|
|
237
|
+
onProgress: (received, total) => {
|
|
238
|
+
if (!ctx.err.isTTY) return;
|
|
239
|
+
const size = total ? `${humanBytes(received)} / ${humanBytes(total)}` : humanBytes(received);
|
|
240
|
+
const percent = total ? ` (${Math.min(100, Math.round((received / total) * 100))}%)` : '';
|
|
241
|
+
const line = ` ↓ ${name} ${size}${percent}`;
|
|
242
|
+
ctx.err.write(`\r${line.padEnd(72)}`);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
item.status = 'downloaded';
|
|
246
|
+
item.path = destination;
|
|
247
|
+
item.bytes = bytes;
|
|
248
|
+
summary.downloaded += 1;
|
|
249
|
+
ctx.err.write(
|
|
250
|
+
`\r${` ✓ ${name} ${humanBytes(bytes)}`.padEnd(72)}\n`,
|
|
251
|
+
);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
item.status = 'failed';
|
|
254
|
+
item.message = err && err.message ? err.message : String(err);
|
|
255
|
+
summary.failed += 1;
|
|
256
|
+
ctx.err.write(`\r${` ✗ ${name} — ${item.message}`.padEnd(72)}\n`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (ctx.json) {
|
|
261
|
+
ctx.out.write(
|
|
262
|
+
`${JSON.stringify(
|
|
263
|
+
{
|
|
264
|
+
query,
|
|
265
|
+
out: outDir,
|
|
266
|
+
dryRun,
|
|
267
|
+
format: format || 'best-available',
|
|
268
|
+
items: items.map((item) => ({
|
|
269
|
+
title: item.title,
|
|
270
|
+
id: item.book?.id ?? null,
|
|
271
|
+
tier: item.book?.tier ?? null,
|
|
272
|
+
status: item.status,
|
|
273
|
+
format: item.file?.format ?? null,
|
|
274
|
+
url: item.file?.url ?? null,
|
|
275
|
+
path: item.path ?? null,
|
|
276
|
+
bytes: item.bytes ?? null,
|
|
277
|
+
message: item.message ?? null,
|
|
278
|
+
})),
|
|
279
|
+
summary,
|
|
280
|
+
},
|
|
281
|
+
null,
|
|
282
|
+
2,
|
|
283
|
+
)}\n`,
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const lines = [];
|
|
289
|
+
if (dryRun) {
|
|
290
|
+
lines.push(ctx.style.bold(`Dry run — nothing was written. Destination would be ${outDir}`));
|
|
291
|
+
lines.push('');
|
|
292
|
+
}
|
|
293
|
+
if (!items.length) {
|
|
294
|
+
lines.push(ctx.style.yellow(`No results found for "${query}".`));
|
|
295
|
+
}
|
|
296
|
+
for (const item of items) {
|
|
297
|
+
const mark =
|
|
298
|
+
item.status === 'downloaded' || item.status === 'planned'
|
|
299
|
+
? ctx.style.green('✓')
|
|
300
|
+
: item.status === 'refused'
|
|
301
|
+
? ctx.style.yellow('⊘')
|
|
302
|
+
: ctx.style.red('✗');
|
|
303
|
+
const detail =
|
|
304
|
+
item.status === 'downloaded'
|
|
305
|
+
? `${item.path} (${humanBytes(item.bytes)})`
|
|
306
|
+
: item.message || item.status;
|
|
307
|
+
lines.push(`${mark} ${item.title} — ${detail}`);
|
|
308
|
+
}
|
|
309
|
+
lines.push('');
|
|
310
|
+
lines.push(
|
|
311
|
+
ctx.style.bold('Summary:') +
|
|
312
|
+
` downloaded ${summary.downloaded} · skipped ${summary.skipped} · failed ${summary.failed} · refused ${summary.refused}` +
|
|
313
|
+
(dryRun ? ` · would download ${summary.planned}` : ''),
|
|
314
|
+
);
|
|
315
|
+
if (summary.refused) {
|
|
316
|
+
lines.push(
|
|
317
|
+
ctx.style.dim(
|
|
318
|
+
'Refused results are not downloadable: tier=borrow needs a free Internet Archive account and a loan, ' +
|
|
319
|
+
'tier=unverified means no free copy of that exact title could be confirmed.',
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
ctx.out.write(`${lines.join('\n')}\n`);
|
|
325
|
+
}
|
|
@@ -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
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration, limits and validation helpers.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is deliberately dependency-free and side-effect free so the
|
|
5
|
+
* same values can be reused by the CLI and by the tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
import { usageError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
function readVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(path.join(here, '..', 'package.json'), 'utf8');
|
|
19
|
+
return JSON.parse(raw).version || '0.0.0';
|
|
20
|
+
} catch {
|
|
21
|
+
return '0.0.0';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const VERSION = readVersion();
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_API_BASE = 'https://anyfreebook.com/api/v1';
|
|
28
|
+
export const DEFAULT_TIMEOUT_MS = 20000;
|
|
29
|
+
export const DEFAULT_SEARCH_LIMIT = 10;
|
|
30
|
+
export const DEFAULT_DOWNLOAD_LIMIT = 5;
|
|
31
|
+
export const DEFAULT_OUT_DIR = './books';
|
|
32
|
+
export const MAX_LIMIT = 50;
|
|
33
|
+
export const MAX_QUERY_LENGTH = 200;
|
|
34
|
+
|
|
35
|
+
/** Source identifiers accepted by the `source` query parameter. */
|
|
36
|
+
export const SOURCES = [
|
|
37
|
+
'all',
|
|
38
|
+
'gutenberg',
|
|
39
|
+
'openlibrary',
|
|
40
|
+
'archive',
|
|
41
|
+
'googlebooks',
|
|
42
|
+
'pubmed',
|
|
43
|
+
'doaj',
|
|
44
|
+
'librivox',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** File formats the CLI knows how to download. */
|
|
48
|
+
export const FORMATS = ['epub', 'pdf', 'txt', 'html'];
|
|
49
|
+
|
|
50
|
+
/** Preference order used when no explicit `--format` is given. */
|
|
51
|
+
export const FORMAT_PREFERENCE = ['epub', 'pdf', 'txt', 'html'];
|
|
52
|
+
|
|
53
|
+
export const USER_AGENT = `anyfreebook-cli/${VERSION} (+https://anyfreebook.com)`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge CLI flags with environment variables.
|
|
57
|
+
* Flags win, then env, then the built-in defaults.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveConfig(flags = {}, env = {}) {
|
|
60
|
+
const base = String(flags.apiBase || env.ANYFREEBOOK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
61
|
+
const apiKey = String(flags.apiKey || env.ANYFREEBOOK_API_KEY || '').trim();
|
|
62
|
+
const timeoutMs =
|
|
63
|
+
flags.timeout !== undefined && flags.timeout !== null
|
|
64
|
+
? Number(flags.timeout)
|
|
65
|
+
: env.ANYFREEBOOK_TIMEOUT_MS !== undefined
|
|
66
|
+
? Number(env.ANYFREEBOOK_TIMEOUT_MS)
|
|
67
|
+
: DEFAULT_TIMEOUT_MS;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
base: base || DEFAULT_API_BASE,
|
|
71
|
+
apiKey,
|
|
72
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse an integer option and clamp it into [min, max]. */
|
|
77
|
+
export function clampInt(value, fallback, min, max, label) {
|
|
78
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
79
|
+
const parsed = Number(value);
|
|
80
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
81
|
+
throw usageError(`${label} must be a whole number (got "${value}").`);
|
|
82
|
+
}
|
|
83
|
+
if (parsed < min || parsed > max) {
|
|
84
|
+
throw usageError(`${label} must be between ${min} and ${max} (got ${parsed}).`);
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Validate the `--source` option. */
|
|
90
|
+
export function validateSource(value) {
|
|
91
|
+
if (value === undefined || value === null || value === '') return 'all';
|
|
92
|
+
const normalized = String(value).trim().toLowerCase();
|
|
93
|
+
if (!SOURCES.includes(normalized)) {
|
|
94
|
+
throw usageError(
|
|
95
|
+
`Unknown source "${value}".`,
|
|
96
|
+
`Valid sources: ${SOURCES.join(', ')}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate the `--format` option. */
|
|
103
|
+
export function validateFormat(value) {
|
|
104
|
+
if (value === undefined || value === null || value === '') return null;
|
|
105
|
+
const normalized = String(value).trim().toLowerCase().replace(/^\./, '');
|
|
106
|
+
if (!FORMATS.includes(normalized)) {
|
|
107
|
+
throw usageError(
|
|
108
|
+
`Unknown format "${value}".`,
|
|
109
|
+
`Valid formats: ${FORMATS.join(', ')}.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|