Merge pull request 'docs(spec): Zotero full-text connector (Unpaywall, libgen JSON API, Anna's fast download)' (#702) from feat/zotero-fetch-connector into main
All checks were successful
CI / lint (push) Successful in 34s
CI / notebooks-smoke (push) Successful in 1m27s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 58s
Infra CI / zotero (push) Successful in 18s
Infra CI / docs (push) Successful in 23s
Infra CI / api (push) Successful in 23s
Infra CI / llm (push) Successful in 22s
Infra CI / mc (push) Successful in 14s
Deploy / report (push) Successful in 18s
CI / test (push) Successful in 28m50s

This commit was merged in pull request #702.
This commit is contained in:
2026-09-09 20:25:18 +00:00

View File

@@ -0,0 +1,76 @@
# Zotero full-text connector (Anna's Archive, libgen, Unpaywall) — design
**Status:** draft for operator approval, 2026-09-09.
**Related:** `dev/scripts/add_ama_coding_to_zotero.py` (PR #697), corwins.media issue #707 (Anna's key), corwins spec `2026-09-09-direct-source-tier-design.md` (egress proxy).
## Problem
The Zotero library on fhirworx has 190,995 parent items and 167,377 of them carry no PDF or EPUB. Among those, 13,543 journal articles have a DOI and 576 books have an ISBN. Zotero's own "Find Available PDF" reaches only open-access copies through the browser. The operator holds an Anna's Archive membership and wants Zotero items filled from every freely reachable source, papers and books together, without hand work.
## What was verified from the VPN namespace (2026-09-09)
| Source | Scriptable? | Use |
|---|---|---|
| `api.unpaywall.org/v2/{doi}?email=` | yes | open-access PDF for a DOI (legal, no quota beyond politeness) |
| `libgen.li/json.php?object=e&doi=…` / `&isbn=…` (`addkeys=*`) | yes | edition → file md5s for a DOI (scimag corpus) or ISBN |
| `libgen.li/ads.php?md5=``get.php` | yes, per-file 503 throttling | file download |
| `annas-archive.gl/dyn/api/fast_download.json?md5=&key=` | yes (member) | file download by md5, 50/day on the current tier |
| Anna's HTML (`/search`, `/md5/`, `/isbn/`, `/doi/`, `/scidb/`) | no — DDoS-Guard JS challenge on every mirror | none |
| Sci-Hub mirrors | no — `.se/.st` unresolvable from the exit, `.ru` robot check, `.box` 403 | none |
| Anna's clone `annas-archive.cc` | not official | never |
Consequence: discovery is Unpaywall and the libgen JSON API; delivery is libgen or Anna's fast download by md5. Nothing depends on Anna's HTML or Sci-Hub.
## Decisions
1. **One resolver, two identifier paths, one attach path.** A Zotero parent item resolves to a `Candidate(source, url_or_md5, ext, size)` list: DOI → Unpaywall `best_oa_location.url_for_pdf` first, then libgen JSON editions → md5s; ISBN → libgen JSON editions → md5s; a book with no ISBN → libgen title search (the index page parser the AMA script already exercises) with the year-aware containment match. Delivery tries libgen `ads.php→get.php` for each md5, then Anna's fast download for the same md5, in that order. Papers and books share every stage except the first lookup.
2. **Lives in the stack repo as `stack zot fetch`.** Package `src/zot/fetch/` (`resolve.py`, `deliver.py`, `attach.py`, `ledger.py`), CLI verb in `src/cli/zot.py`. Direct `zot.db` writes as every other `stack zot` command does, under the existing `--hold` guard (Zotero desktop stopped for the write, restarted after). The AMA script's attach logic moves into `attach.py` in the same change so there is one implementation.
3. **Selection is explicit.** `--collection NAME`, `--tag TAG`, `--type journalArticle|book|…`, `--item KEY`, or `--all`; always excludes items that already have a PDF/EPUB attachment, deleted items, and the `document` type (146k regulatory PDFs that are not papers or books and would burn the quota on misses). `--limit N` caps attempts per run, `--dry-run` prints the plan.
4. **Quota-aware.** Anna's daily budget is read from the API's `account_fast_download_info` at run start and guarded by `ANNAS_DAILY_RESERVE` (default 5 kept back). libgen queries are paced 3 s apart; Unpaywall gets the operator contact email in the query and 1 req/s. When the Anna's budget is exhausted the run continues with libgen-only delivery and reports what it skipped.
5. **A ledger prevents re-trying misses forever.** `data/zotero/fetch_ledger.jsonl` (gitignored data), one line per attempt: item key, identifiers used, outcome (`attached` / `no_candidate` / `all_failed` / `quota`), source, md5, timestamp. An item with a terminal miss is retried only after 30 days or with `--retry`. The ledger is the report the operator reads.
6. **Provenance on the item.** Tags `source:unpaywall` / `source:libgen` / `source:annas`, plus `md5:<md5>` or `doi:` note in `extra`; the attachment title is the file name; nothing on the parent item is edited beyond tags and `extra`.
7. **Egress through the gluetun HTTP proxy** (`VPN_HTTP_PROXY` in stack `.env`, host-local `127.0.0.1:19035`, published by the corwins compose change). Unpaywall may go direct; libgen and Anna's must not. A missing proxy is an error naming the compose step, not a fallback.
8. **Secrets stay in `.env`.** `ANNAS_ARCHIVE_KEY` (already present in corwins `.env`; copied to stack `.env` by the operator, never committed), `UNPAYWALL_EMAIL` (defaults to the existing contact used elsewhere in the stack).
9. **No timer in this spec.** The operator runs `stack zot fetch`; a daily systemd timer is a separate, operator-approved step once a few manual runs have been read.
## Components
```
src/zot/fetch/
resolve.py identifiers_for(item) -> {doi, isbn, title, year}; candidates(ids, http) -> list[Candidate]
deliver.py fetch(candidate, budget) -> Path | None (libgen ads→get, 503 → next; Anna's fast download)
attach.py attach_file(db, item_id, path, *, source, md5) ; has_full_text(db, item_id) -> bool
ledger.py Ledger(path).record(...) / .should_try(item_key) ; summary()
select.py select_items(db, collection=, tag=, type_=, item=, all_=) -> list[ItemRef]
src/cli/zot.py @app.command("fetch")
tests/zot/test_fetch_*.py fixtures: unpaywall_doi.json, libgen_json_doi.json, libgen_json_isbn.json, libgen_ads.html, annas_fast_download.json
```
`Candidate = dataclass(source: str, md5: str | None, url: str | None, ext: str, size_bytes: int | None, title: str)`. Order: Unpaywall (source `unpaywall`, url) before libgen md5s; within md5s, epub before pdf for books, pdf only for papers.
`fetch()` streams to a temp file, rejects non-file content types and bodies under 10 kB, and returns the path. Budget is a small object with `annas_left` decremented on each fast-download use.
`attach_file()` is the AMA script's `_attach` generalised: storage dir `data/zotero/data/storage/<KEY>/`, `sudo mkdir/cp/chown 100999:100999`, `db.add_attachment(parent, key=, content_type=, path="storage:<name>")`, title = file name; `has_full_text` checks `itemAttachments.contentType` for `application/pdf` or `application/epub+zip` under the parent.
## Error handling
- Per-item failures never abort the run; they are ledger lines.
- Unpaywall 404 or `is_oa=false` is not an error (next resolver).
- libgen `ads.php` 500 → one retry after 3 s; `get.php` 503 → next md5, then Anna's.
- Anna's API 401 → stop the run with a clear message (key wrong); 403 `Not a member` → same; any other non-200 → skip Anna's for the run.
- Zotero DB locked → the `--hold` guard already stops the desktop; if `--no-hold` is passed and the DB is locked, fail before any download.
## Testing
Unit tests per module with `httpx.MockTransport` (adopt httpx for this package; the stack has no single HTTP client convention) and pinned fixtures captured through the proxy. `test_resolve.py`: DOI → Unpaywall first, libgen JSON second, order of candidates; ISBN → md5s; no-ISBN book → title search with containment. `test_deliver.py`: 503 → next candidate → Anna's; HTML body rejected; budget decrements. `test_attach.py`: `has_full_text` truth table on a scratch Zotero DB built from `src/zot/schema.sql`; attachment row shape. `test_ledger.py`: 30-day retry rule. CLI: `typer.testing.CliRunner` with `--dry-run` on a scratch DB. No network in tests.
## Slices
- **Z1 — resolver + delivery + attach, manual runs.** Everything above except the timer. Done when: `stack zot fetch --collection "AMA Coding Publications" --dry-run` lists candidates for the AMA books; a real run on `--type journalArticle --limit 20` attaches at least one Unpaywall PDF and one libgen/Anna's PDF and the ledger explains every miss.
- **Z2 — fold the AMA script in.** `add_ama_coding_to_zotero.py` keeps only the item-minting half and calls `attach.py`; the folder-to-title match moves with it.
- **Z3 — timer** (separate approval): daily `stack zot fetch --all --limit 40`.
- **Z4 (only on a measured gap)** — a headless-browser challenge pass in the VPN namespace for Anna's HTML/SciDB, if the libgen JSON index proves to miss many DOIs the ledger shows Anna's has.
## Out of scope
Editing Zotero metadata beyond tags/extra; Zotero Web API or sync; Sci-Hub; Anna's HTML scraping; Z-Library (would need an account and its own login flow; can join later as another `deliver` source); the 146k `document` items.