open-alex-wrapper 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 +437 -0
- package/dist/cli.cjs +1880 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +31 -0
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +1848 -0
- package/dist/cli.js.map +1 -0
- package/dist/client-CLRZTPFv.d.cts +1266 -0
- package/dist/client-CLRZTPFv.d.ts +1266 -0
- package/dist/index.cjs +1831 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +105 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +1750 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Luis Hernando
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# open-alex-wrapper
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/open-alex-wrapper)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](package.json)
|
|
7
|
+
[](test)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
A fully-typed TypeScript SDK for the [OpenAlex](https://openalex.org) API. It covers all eight entity types, streams results past the API's paging cap, fetches pages in parallel, and prices an extraction in dollars before you run it.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { OpenAlexClient, gt } from 'open-alex-wrapper'
|
|
14
|
+
|
|
15
|
+
const client = new OpenAlexClient({ apiKey: process.env.OPENALEX_API_KEY })
|
|
16
|
+
|
|
17
|
+
// 1. Know the price before you pay it
|
|
18
|
+
const est = await client.works
|
|
19
|
+
.filter({ publication_year: gt(2020), 'open_access.is_oa': true })
|
|
20
|
+
.estimateCost()
|
|
21
|
+
console.log(`~$${est.totalCostUsd} for ${est.matchingResults.toLocaleString()} works`)
|
|
22
|
+
|
|
23
|
+
// 2. Stream every matching work, cursor paging, past the 10k cap
|
|
24
|
+
for await (const work of client.works.filter({ publication_year: 2023 }).all()) {
|
|
25
|
+
console.log(work.display_name)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 3. See exactly what you've spent
|
|
29
|
+
console.log(client.usageReport())
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Jump to:** [Why not the raw API](#why-not-just-call-the-api-directly) · [Install](#install) · [Config](#configuration) · [Entities](#entities) · [Cost & budget](#cost-and-budget) · [Export](#export) · [Citation graph](#citation-graph) · [Resumable](#resumable-extractions) · [Snapshot](#snapshot-bridge-extract-all-data-at-0) · [Full text](#full-text-pdf--tei-xml) · [CLI](#cli) · [Errors](#errors) · [Performance](#performance-notes)
|
|
33
|
+
|
|
34
|
+
## Why not just call the API directly?
|
|
35
|
+
|
|
36
|
+
OpenAlex has a clean REST API and you can absolutely drive it with `fetch`. The question is how much of the surrounding machinery you want to write yourself, and since February 2026 that machinery includes keeping track of a bill.
|
|
37
|
+
|
|
38
|
+
Here is one ordinary job, harvesting every open-access work from 2023, written both ways.
|
|
39
|
+
|
|
40
|
+
**With `fetch` against the raw API:**
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
const filter = 'publication_year:2023,open_access.is_oa:true'
|
|
44
|
+
let cursor = '*'
|
|
45
|
+
let spent = 0
|
|
46
|
+
|
|
47
|
+
while (cursor) {
|
|
48
|
+
const url =
|
|
49
|
+
`https://api.openalex.org/works?filter=${encodeURIComponent(filter)}` +
|
|
50
|
+
`&per-page=200&cursor=${cursor}&api_key=${process.env.OPENALEX_API_KEY}`
|
|
51
|
+
|
|
52
|
+
let res
|
|
53
|
+
for (let attempt = 0; ; attempt++) {
|
|
54
|
+
res = await fetch(url)
|
|
55
|
+
if (res.status !== 429 && res.status < 500) break
|
|
56
|
+
const wait = Number(res.headers.get('retry-after') ?? 2 ** attempt)
|
|
57
|
+
await new Promise((r) => setTimeout(r, wait * 1000))
|
|
58
|
+
}
|
|
59
|
+
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`)
|
|
60
|
+
|
|
61
|
+
spent += 0.0001 // your own running total
|
|
62
|
+
const { results, meta } = await res.json() // results are untyped
|
|
63
|
+
for (const work of results) {
|
|
64
|
+
// ...
|
|
65
|
+
}
|
|
66
|
+
cursor = meta.next_cursor // crash here and you start over
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**With this SDK:**
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
for await (const work of client.works
|
|
74
|
+
.filter({ publication_year: 2023, 'open_access.is_oa': true })
|
|
75
|
+
.all()) {
|
|
76
|
+
// ...
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The second version also retries, throttles against your remaining budget, tracks spend from the response headers, and can checkpoint so a crash resumes instead of re-billing.
|
|
81
|
+
|
|
82
|
+
### Line by line
|
|
83
|
+
|
|
84
|
+
| What you want to do | With `fetch` and the raw API | With this SDK |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| Read past the 10,000 result cap | Thread `cursor=*` and `meta.next_cursor` through your own loop | `for await (const w of q.all())` |
|
|
87
|
+
| Know the price before running it | Not possible. You learn the cost from headers after you have paid | `await q.estimateCost()` |
|
|
88
|
+
| Track what you have spent | Parse `x-ratelimit-*` off every response and aggregate it | `client.usageReport()` |
|
|
89
|
+
| Stop before a daily cap | Catch the 402 or 429 once you have already hit it | `dailyBudgetUsd` plus adaptive throttling |
|
|
90
|
+
| Write a filter | Hand-build `filter=publication_year:>2015,type:!dataset` strings | `.filter({ publication_year: gt(2015), type: not('dataset') })` |
|
|
91
|
+
| Recall 206 filter field names | Keep the docs open in another tab | IDE autocomplete, per entity |
|
|
92
|
+
| Look up a DOI, ORCID, ROR or ISSN | Different prefix rules for each identifier | `client.works.get('10.7717/peerj.4375')` |
|
|
93
|
+
| Survive a 429 or a 5xx | Write backoff that honours `Retry-After` | Retried automatically |
|
|
94
|
+
| Resume a harvest that died | Start over and pay for the same pages twice | Checkpoint resume, no double billing |
|
|
95
|
+
| Read an abstract | Invert `abstract_inverted_index` yourself | `decodeAbstract(work.abstract_inverted_index)` |
|
|
96
|
+
| Pull the entire dataset | A separate S3 bucket, gzip, manifest parsing | `client.snapshot.stream('works')` at $0 |
|
|
97
|
+
| Get static types | There are no official ones | Typed models for all eight entities |
|
|
98
|
+
|
|
99
|
+
None of this is exotic. It is the code most people write anyway, on their third project against the API, after the first two taught them why they needed it.
|
|
100
|
+
|
|
101
|
+
## What you get
|
|
102
|
+
|
|
103
|
+
- All eight entities, plus autocomplete, n-grams, and abstract decoding.
|
|
104
|
+
- A fluent, immutable query builder with typed per-entity filter-key autocomplete.
|
|
105
|
+
- Streaming auto-pagination: cursor for unbounded reads, parallel basic paging when the result set is under 10k.
|
|
106
|
+
- Dollar-cost estimation and live budget tracking read from the `x-ratelimit-*` headers.
|
|
107
|
+
- Budget auto-throttle and a cost-optimal query planner.
|
|
108
|
+
- A snapshot bridge that extracts the full dataset from the public S3 dump at $0.
|
|
109
|
+
- Resumable extractions, so a crashed harvest is never paid for twice.
|
|
110
|
+
- Full-text download (PDF and TEI-XML) and citation graph helpers.
|
|
111
|
+
- An `oaw` CLI, zero runtime dependencies, and JSONL / CSV / JSON export.
|
|
112
|
+
|
|
113
|
+
The last six do not exist in any other OpenAlex client. [`docs/COMPETITIVE.md`](docs/COMPETITIVE.md) has the full comparison against pyalex, openalexR, and the other TS and JS libraries.
|
|
114
|
+
|
|
115
|
+
## OpenAlex pricing, in brief
|
|
116
|
+
|
|
117
|
+
OpenAlex moved to usage-based pricing in February 2026. An API key is now required, you get a free daily allowance ($1.00/day with a key, $0.10/day without), and every request has a price:
|
|
118
|
+
|
|
119
|
+
| Request type | Price / call | Free-tier daily cap |
|
|
120
|
+
| --- | --- | --- |
|
|
121
|
+
| Single entity lookup (by ID, DOI, and so on) | **$0** | unlimited |
|
|
122
|
+
| List / filter | **$0.0001** | 10,000 calls · 1M results |
|
|
123
|
+
| Search | **$0.001** | 1,000 calls · 100k results |
|
|
124
|
+
| PDF/XML full-text download | **$0.01** | 100 calls |
|
|
125
|
+
|
|
126
|
+
That pricing change is why cost sits at the centre of this SDK rather than off to one side. You can estimate an extraction's dollar cost before running it, and read authoritative live spend from the `x-ratelimit-*` headers OpenAlex returns on every call. Free keys are at [openalex.org/settings/api](https://openalex.org/settings/api). The data itself is still free: [they sell services, not data](https://blog.openalex.org/openalex-api-new-features-and-usage-based-pricing/).
|
|
127
|
+
|
|
128
|
+
## Install
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npm install open-alex-wrapper
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Requires Node 18+ for native `fetch`. No runtime dependencies.
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
const client = new OpenAlexClient({
|
|
140
|
+
apiKey: process.env.OPENALEX_API_KEY, // or set OPENALEX_API_KEY
|
|
141
|
+
mailto: 'you@example.com', // polite pool; or set OPENALEX_MAILTO
|
|
142
|
+
concurrency: 8, // max parallel requests
|
|
143
|
+
dailyBudgetUsd: 5.0, // optional soft cap, throws BudgetExceededError
|
|
144
|
+
throttle: true, // adaptive rate limiting (see Auto-throttle)
|
|
145
|
+
cache: true, // opt-in LRU cache for GETs
|
|
146
|
+
cacheTtlMs: 300_000,
|
|
147
|
+
timeoutMs: 60_000,
|
|
148
|
+
retry: { maxRetries: 5, baseDelayMs: 500 },
|
|
149
|
+
onBudgetWarning: ({ spentUsd, limitUsd, ratio }) => { /* ... */ },
|
|
150
|
+
})
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Entities
|
|
154
|
+
|
|
155
|
+
All eight entity types share one interface:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
client.works client.authors client.sources client.institutions
|
|
159
|
+
client.topics client.keywords client.publishers client.funders
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Fetch one
|
|
163
|
+
|
|
164
|
+
`get()` takes an OpenAlex ID, DOI, ORCID, ROR, ISSN, Wikidata QID, PMID, MAG id, or a full URL, and normalizes all of them:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
await client.works.get('10.7717/peerj.4375') // DOI
|
|
168
|
+
await client.authors.get('0000-0002-1298-3089') // ORCID
|
|
169
|
+
await client.institutions.get('https://ror.org/03yrm5c26') // ROR URL
|
|
170
|
+
await client.sources.get('issn:2167-8359') // ISSN
|
|
171
|
+
await client.works.get('W2741809807', { select: ['id', 'title'] })
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Fetch many (batched, parallel)
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// Batches into `ids.openalex` OR-filters (default 50/call), run in parallel.
|
|
178
|
+
const works = await client.works.getMany(['W2741809807', 'W2755950973', /* ... */])
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Query, filter, sort, select
|
|
182
|
+
|
|
183
|
+
The builder is immutable, so queries branch and get reused safely:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { gt, lt, not, or } from 'open-alex-wrapper'
|
|
187
|
+
|
|
188
|
+
const q = client.works
|
|
189
|
+
.filter({
|
|
190
|
+
publication_year: gt(2015), // >2015
|
|
191
|
+
'open_access.is_oa': true,
|
|
192
|
+
type: not('dataset'), // !dataset
|
|
193
|
+
language: or('en', 'es'), // en OR es (arrays also work)
|
|
194
|
+
})
|
|
195
|
+
.searchField('title', 'genomics')
|
|
196
|
+
.sort({ cited_by_count: 'desc' })
|
|
197
|
+
.select(['id', 'display_name', 'cited_by_count'])
|
|
198
|
+
|
|
199
|
+
const page = await q.get() // one page
|
|
200
|
+
const first = await q.first() // first match or null
|
|
201
|
+
const total = await q.count() // just meta.count (cheap)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Typed filter keys.** `.filter({ … })` autocompletes each entity's real filter keys in your IDE (206 for works, 43 for authors, and so on), generated from the live OpenAlex API via `npm run gen:filters`. Unknown keys still pass through, so a new OpenAlex field never breaks your build.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
client.works.filter({ publication_year: 2020 }) // 'publication_year' autocompletes
|
|
208
|
+
client.authors.filter({ h_index: gt(40) }) // per-entity keys
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Iterate everything
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// Default: cursor paging. Unbounded, memory-safe, sequential.
|
|
215
|
+
for await (const work of q.all()) { /* ... */ }
|
|
216
|
+
|
|
217
|
+
// Fast path for ≤10k results: parallel basic paging (throttled by `concurrency`).
|
|
218
|
+
for await (const work of q.all({ parallel: true })) { /* ... */ }
|
|
219
|
+
|
|
220
|
+
// Page objects instead of items:
|
|
221
|
+
for await (const { meta, results } of q.paginate()) { /* ... */ }
|
|
222
|
+
|
|
223
|
+
// Collect (bounded):
|
|
224
|
+
const top100 = await q.perPage(100).toArray({ maxResults: 100 })
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Aggregate, sample, autocomplete
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
await client.works.filter({ publication_year: 2023 }).groups('open_access.is_oa')
|
|
231
|
+
await client.works.sample(50, /* seed */ 42).get()
|
|
232
|
+
await client.autocomplete('einst') // cross-entity typeahead
|
|
233
|
+
await client.authors.autocomplete('einstein') // scoped
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Cost and budget
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
// Estimate before extracting
|
|
240
|
+
const est = await client.works.search('crispr').estimateCost()
|
|
241
|
+
// → { matchingResults, pageCalls, pagesCostUsd, probeCostUsd, totalCostUsd, cappedByBasicPaging }
|
|
242
|
+
|
|
243
|
+
// Live, authoritative usage (from OpenAlex response headers)
|
|
244
|
+
const snap = client.usageSnapshot()
|
|
245
|
+
// → { spentUsd, remainingUsd, dailyLimitUsd, creditsRemaining, resetSeconds, byType, ... }
|
|
246
|
+
|
|
247
|
+
console.log(client.usageReport())
|
|
248
|
+
// OpenAlex usage report
|
|
249
|
+
// API key: yes
|
|
250
|
+
// Total API calls: 12
|
|
251
|
+
// Spend today: $0.001200 (reported by OpenAlex)
|
|
252
|
+
// Daily limit: $1.0000
|
|
253
|
+
// Remaining: $0.998800
|
|
254
|
+
// ...
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Set `dailyBudgetUsd` and any call, or any estimated extraction, that would cross it throws `BudgetExceededError` before spending.
|
|
258
|
+
|
|
259
|
+
### Auto-throttle
|
|
260
|
+
|
|
261
|
+
Adaptive rate limiting paces requests and slows down as the budget runs low, so long extractions ease into the limit instead of hitting a 429 or 402:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const client = new OpenAlexClient({
|
|
265
|
+
throttle: { maxRequestsPerSecond: 10, minRemainingUsd: 0.05 }, // or just `throttle: true`
|
|
266
|
+
})
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Spacing doubles under 25% remaining budget and quadruples under 10%. `minRemainingUsd` is a hard stop that throws `BudgetExceededError`.
|
|
270
|
+
|
|
271
|
+
### Cost-optimal planner
|
|
272
|
+
|
|
273
|
+
The planner counts once, then picks the cheapest way to read the rest:
|
|
274
|
+
|
|
275
|
+
```mermaid
|
|
276
|
+
flowchart TD
|
|
277
|
+
A["your query"] --> B["one count probe"]
|
|
278
|
+
B --> C{"estimated bill ≥ $1<br/>and the snapshot covers it?"}
|
|
279
|
+
C -->|yes| S["free S3 snapshot · $0"]
|
|
280
|
+
C -->|no| D{"results ≤ 10,000?"}
|
|
281
|
+
D -->|yes| P["parallel basic paging · fastest"]
|
|
282
|
+
D -->|no| U["cursor streaming · unbounded"]
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const plan = await client.planExtraction(client.works.filter({ 'open_access.is_oa': true }))
|
|
287
|
+
// { matchingResults, recommendedStrategy: 'parallel-basic'|'cursor'|'snapshot',
|
|
288
|
+
// recommendedPerPage, estimate, useSnapshot, snapshotAwsCommand?, rationale }
|
|
289
|
+
|
|
290
|
+
// Or just run the optimal plan (auto per-page, plus parallel or cursor):
|
|
291
|
+
for await (const work of client.works.filter({ publication_year: 2023 }).streamOptimized()) { /* ... */ }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## Export
|
|
295
|
+
|
|
296
|
+
Stream results straight to disk without buffering:
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
await client.works
|
|
300
|
+
.filter({ publication_year: 2023 })
|
|
301
|
+
.select(['id', 'doi', 'display_name', 'cited_by_count'])
|
|
302
|
+
.export('works-2023.jsonl') // format inferred from extension
|
|
303
|
+
|
|
304
|
+
await q.export('out.csv', { format: 'csv' }) // flattened, RFC-escaped
|
|
305
|
+
await q.export('out.json', { format: 'json' }) // single JSON array
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Citation graph
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// Composable QueryBuilders. Chain .select(), .all(), .export(), and the rest.
|
|
312
|
+
client.works.citedBy('W2741809807') // works that CITE this one
|
|
313
|
+
client.works.references('W2741809807') // works this one CITES
|
|
314
|
+
client.works.related('W2741809807') // OpenAlex "related_to"
|
|
315
|
+
|
|
316
|
+
// One-hop neighbourhood in parallel:
|
|
317
|
+
const { seed, references, citedBy } = await client.works.citationGraph('W2741809807', {
|
|
318
|
+
limit: 50,
|
|
319
|
+
select: ['id', 'display_name', 'cited_by_count'],
|
|
320
|
+
})
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
## Resumable extractions
|
|
324
|
+
|
|
325
|
+
Long extractions checkpoint their cursor after every page, so a crash, a rate limit, or a Ctrl-C picks up where it stopped. You do not re-pay for pages you already fetched.
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { FileCheckpoint } from 'open-alex-wrapper'
|
|
329
|
+
|
|
330
|
+
const resume = new FileCheckpoint('.harvest.checkpoint')
|
|
331
|
+
|
|
332
|
+
await client.works.filter({ publication_year: 2023 }).export('works-2023.jsonl', {
|
|
333
|
+
resume, // survives restarts, auto-cleared on completion
|
|
334
|
+
onProgress: ({ emitted, total }) =>
|
|
335
|
+
console.log(`${emitted.toLocaleString()} / ${total?.toLocaleString()}`),
|
|
336
|
+
})
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
## Snapshot bridge: extract all data at $0
|
|
340
|
+
|
|
341
|
+
OpenAlex publishes the whole dataset as a free, public S3 snapshot (gzipped JSON Lines, no credentials). For large extractions it costs a lot less than the metered API. This SDK compares the two and streams the snapshot in-process, with no dependencies.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
// Should I pay the API or grab the free snapshot?
|
|
345
|
+
const plan = await client.snapshotPlan(client.works.filter({ 'open_access.is_oa': true }))
|
|
346
|
+
console.log(plan.recommendation) // 'api' | 'snapshot'
|
|
347
|
+
console.log(plan.rationale) // e.g. "...covers 42% of all works. The full works snapshot (666 GB, 510M records) is free."
|
|
348
|
+
console.log(plan.awsCommand) // aws s3 sync "s3://openalex/data/jsonl/works" ... --no-sign-request
|
|
349
|
+
|
|
350
|
+
// Manifest totals (all 21 entities, or one):
|
|
351
|
+
await client.snapshot.totals()
|
|
352
|
+
await client.snapshot.entityTotals('works') // { recordCount: 510_000_000, contentLength: 665e9 }
|
|
353
|
+
|
|
354
|
+
// Stream the snapshot directly, in-process, at $0, with a client-side filter.
|
|
355
|
+
for await (const work of client.snapshot.stream('works', {
|
|
356
|
+
filter: (w) => w.open_access?.is_oa === true,
|
|
357
|
+
maxRecords: 100_000,
|
|
358
|
+
onPart: ({ index, total }) => console.log(`part ${index + 1}/${total}`),
|
|
359
|
+
})) {
|
|
360
|
+
// ...no API credits spent
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Full text (PDF / TEI-XML)
|
|
365
|
+
|
|
366
|
+
OpenAlex caches full text for roughly 60M open-access works. Downloads cost $0.01 per call and are budget-tracked like everything else.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
// Find works that have a downloadable PDF:
|
|
370
|
+
const withPdf = client.works.withPdf().filter({ publication_year: 2024 })
|
|
371
|
+
|
|
372
|
+
// Download one (bytes, or stream to a file with `dest`):
|
|
373
|
+
const { bytes } = await client.works.downloadFulltext('W3038568908', { format: 'pdf' })
|
|
374
|
+
await client.works.downloadFulltext('W3038568908', { format: 'xml', dest: 'paper.tei.xml' })
|
|
375
|
+
|
|
376
|
+
// Or just get the (auth-redacted) content URL:
|
|
377
|
+
client.works.fulltextUrl('W3038568908') // …/works/W3038568908.pdf?api_key=REDACTED
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## CLI
|
|
381
|
+
|
|
382
|
+
Installs an `oaw` command, usable globally or through `npx`:
|
|
383
|
+
|
|
384
|
+
```bash
|
|
385
|
+
oaw works --filter "publication_year:>2020,open_access.is_oa:true" --limit 5
|
|
386
|
+
oaw works --search crispr --select id,display_name --export out.jsonl --parallel
|
|
387
|
+
oaw works --filter "publication_year:2024" --estimate # dollar cost preview
|
|
388
|
+
oaw get authors A5023888391 --select id,display_name
|
|
389
|
+
oaw snapshot totals works # 510,372,821 records 665.7 GB
|
|
390
|
+
oaw snapshot plan works --filter "open_access.is_oa:true" # API $ vs free snapshot
|
|
391
|
+
oaw snapshot stream works --limit 100 --export works.jsonl # $0 harvest
|
|
392
|
+
oaw fulltext W3038568908 --format pdf --out paper.pdf
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Global flags: `--api-key` (or `OPENALEX_API_KEY`), `--mailto`, `--json`, `--help`.
|
|
396
|
+
|
|
397
|
+
## Helpers
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
import { decodeAbstract, fetchNgrams } from 'open-alex-wrapper'
|
|
401
|
+
|
|
402
|
+
const work = await client.works.get('W2741809807')
|
|
403
|
+
const abstract = decodeAbstract(work.abstract_inverted_index) // reconstruct text
|
|
404
|
+
const ngrams = await client.ngrams('W2741809807') // fulltext n-grams
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Errors
|
|
408
|
+
|
|
409
|
+
Every failure is a typed subclass of `OpenAlexError`: `AuthenticationError` (401/403), `NotFoundError` (404), `ValidationError` (400/422), `RateLimitError` (429), `ServerError` (5xx), `TimeoutError`, `NetworkError`, and `BudgetExceededError`. Retryable statuses (429 and 5xx) are retried automatically with exponential backoff that honours `Retry-After`.
|
|
410
|
+
|
|
411
|
+
## Performance notes
|
|
412
|
+
|
|
413
|
+
- Concurrency is bounded by a semaphore (`concurrency`, default 8) across the whole client. `getMany`, parallel paging, and any concurrent `await`s share the limit.
|
|
414
|
+
- Cursor paging streams unlimited results with constant memory. Parallel basic paging (`{ parallel: true }`) is the fast path for the very common case of 10k results or fewer.
|
|
415
|
+
- HTTP keep-alive is on by default through Node's global `fetch` agent.
|
|
416
|
+
- `select` fewer fields for smaller, faster payloads.
|
|
417
|
+
- Caching (`cache: true`) de-duplicates identical GETs.
|
|
418
|
+
|
|
419
|
+
## Scripts
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
npm run build # ESM + CJS + .d.ts via tsup
|
|
423
|
+
npm run typecheck # tsc --noEmit
|
|
424
|
+
npm test # vitest (mocked HTTP, no network)
|
|
425
|
+
npm run gen:filters # regenerate typed filter keys from the live API
|
|
426
|
+
|
|
427
|
+
# Opt-in live smoke test against the real API:
|
|
428
|
+
OPENALEX_LIVE=1 OPENALEX_API_KEY=... npx vitest run test/live.smoke.test.ts
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Changelog
|
|
432
|
+
|
|
433
|
+
See [`CHANGELOG.md`](CHANGELOG.md).
|
|
434
|
+
|
|
435
|
+
## License
|
|
436
|
+
|
|
437
|
+
MIT © Jose Luis Hernando
|