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 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.1.0",
4
- "description": "Search 7 open-access book sources and download only what is verifiably public domain.",
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 7 open-access book sources, download only what is verifiably public domain.
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 (default all)
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 (default all)
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)
@@ -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 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`);
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
 
package/src/config.js CHANGED
@@ -42,6 +42,11 @@ export const SOURCES = [
42
42
  'pubmed',
43
43
  'doaj',
44
44
  'librivox',
45
+ 'openalex',
46
+ 'wikisource',
47
+ 'europeana',
48
+ 'zenodo',
49
+ 'otl',
45
50
  ];
46
51
 
47
52
  /** File formats the CLI knows how to download. */