anyfreebook-cli 0.1.0 → 0.2.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/README.md +15 -1
- package/package.json +2 -2
- package/src/cli.js +5 -3
- package/src/commands/download.js +89 -5
- package/src/config.js +5 -0
package/README.md
CHANGED
|
@@ -148,7 +148,7 @@ anyfreebook search "python" --json | jq '.results[] | select(.tier=="download")
|
|
|
148
148
|
| --- | --- | --- |
|
|
149
149
|
| `--limit <n>` | 10 | Results per page (max 50). |
|
|
150
150
|
| `--page <n>` | 1 | Page number. |
|
|
151
|
-
| `--source <name>` | `all` | `all`, `gutenberg`, `openlibrary`, `archive`, `googlebooks`, `pubmed`, `doaj`, `librivox`. |
|
|
151
|
+
| `--source <name>` | `all` | `all`, `gutenberg`, `openlibrary`, `archive`, `googlebooks`, `pubmed`, `doaj`, `librivox`, `openalex`, `wikisource`, `europeana`, `zenodo`, `otl`. |
|
|
152
152
|
| `--format <fmt>` | — | Keep only results offering `epub`, `pdf`, `txt` or `html`. |
|
|
153
153
|
| `--free-only` | off | Keep only `tier === "download"` results. |
|
|
154
154
|
| `--json` | off | Print the raw JSON response to stdout and nothing else. |
|
|
@@ -157,6 +157,7 @@ anyfreebook search "python" --json | jq '.results[] | select(.tier=="download")
|
|
|
157
157
|
|
|
158
158
|
| Option | Default | Meaning |
|
|
159
159
|
| --- | --- | --- |
|
|
160
|
+
| `--author <name>` | — | Resolve this exact title+author instead of searching. Recommended. |
|
|
160
161
|
| `--limit <n>` | 5 | Results to consider. |
|
|
161
162
|
| `--source <name>` | `all` | Same values as `search`. |
|
|
162
163
|
| `--format <fmt>` | best available | `epub` > `pdf` > `txt` > `html`. |
|
|
@@ -168,6 +169,19 @@ Downloads are **always** restricted to `tier === "download"`. A `borrow` or
|
|
|
168
169
|
`unverified` result is reported as refused, with the reason — it is never
|
|
169
170
|
skipped quietly.
|
|
170
171
|
|
|
172
|
+
A result is only downloaded if its **own title** is the title you asked for.
|
|
173
|
+
The search fans out to sources that match free text, so "Pride and Prejudice"
|
|
174
|
+
also finds real academic papers about prejudice, and one of those can outrank
|
|
175
|
+
the novel and carry a perfectly good PDF. Those are reported and skipped, not
|
|
176
|
+
downloaded as if they were the book.
|
|
177
|
+
|
|
178
|
+
Pass `--author` when you know it. That resolves one exact title against the
|
|
179
|
+
real record instead of searching, which is both faster and unambiguous:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
anyfreebook download "Pride and Prejudice" --author "Jane Austen" --format epub
|
|
183
|
+
```
|
|
184
|
+
|
|
171
185
|
File names are `<title-slug>-<source-id>.<ext>`, so two different books with
|
|
172
186
|
the same title do not overwrite each other.
|
|
173
187
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyfreebook-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Search
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Search 12 open-access book sources and download only what is verifiably public domain.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"anyfreebook": "bin/anyfreebook.js"
|
package/src/cli.js
CHANGED
|
@@ -92,7 +92,7 @@ export function parseArgs(argv) {
|
|
|
92
92
|
return opts;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
export const GENERAL_HELP = `anyfreebook ${VERSION} — search
|
|
95
|
+
export const GENERAL_HELP = `anyfreebook ${VERSION} — search 12 open-access book sources, download only what is verifiably public domain.
|
|
96
96
|
|
|
97
97
|
Usage:
|
|
98
98
|
anyfreebook search <query> [options]
|
|
@@ -127,7 +127,8 @@ Archive, Google Books, PubMed Central, DOAJ and LibriVox.
|
|
|
127
127
|
--limit <n> Results per page (default 10, max 50)
|
|
128
128
|
--page <n> Page number (default 1)
|
|
129
129
|
--source <name> all | gutenberg | openlibrary | archive | googlebooks |
|
|
130
|
-
pubmed | doaj | librivox
|
|
130
|
+
pubmed | doaj | librivox | openalex | wikisource |
|
|
131
|
+
europeana | zenodo | otl (default all)
|
|
131
132
|
--format <fmt> Keep only results offering epub | pdf | txt | html
|
|
132
133
|
--free-only Keep only results with a verified free download
|
|
133
134
|
`,
|
|
@@ -138,7 +139,8 @@ Download — search, then write verified public-domain books to a folder.
|
|
|
138
139
|
|
|
139
140
|
--limit <n> Results to consider (default 5, max 50)
|
|
140
141
|
--source <name> all | gutenberg | openlibrary | archive | googlebooks |
|
|
141
|
-
pubmed | doaj | librivox
|
|
142
|
+
pubmed | doaj | librivox | openalex | wikisource |
|
|
143
|
+
europeana | zenodo | otl (default all)
|
|
142
144
|
--format <fmt> epub | pdf | txt | html (default: best available —
|
|
143
145
|
epub > pdf > txt > html)
|
|
144
146
|
--out <dir> Destination folder (default ./books)
|
package/src/commands/download.js
CHANGED
|
@@ -57,6 +57,57 @@ export function refusalMessage(result) {
|
|
|
57
57
|
return `"${title}" has tier "${result?.tier ?? 'unknown'}" — only tier=download results may be downloaded.`;
|
|
58
58
|
}
|
|
59
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
|
+
|
|
60
111
|
async function pathExists(target) {
|
|
61
112
|
try {
|
|
62
113
|
await fs.access(target);
|
|
@@ -160,13 +211,33 @@ export async function downloadCommand(opts, ctx) {
|
|
|
160
211
|
const dryRun = Boolean(opts.dryRun);
|
|
161
212
|
const outDir = path.resolve(opts.out || DEFAULT_OUT_DIR);
|
|
162
213
|
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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 : [];
|
|
167
239
|
}
|
|
168
240
|
|
|
169
|
-
const results = Array.isArray(data.results) ? data.results : [];
|
|
170
241
|
const items = [];
|
|
171
242
|
|
|
172
243
|
for (const result of results) {
|
|
@@ -187,6 +258,19 @@ export async function downloadCommand(opts, ctx) {
|
|
|
187
258
|
});
|
|
188
259
|
continue;
|
|
189
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
|
+
}
|
|
190
274
|
items.push({ status: 'pending', title, book: result, file });
|
|
191
275
|
}
|
|
192
276
|
|