# Paperful architecture Paperful is a **local CLI**. Fetch, identifier checks, and proposed metadata patches happen **on disk** (`out/`, `state/`). A **library adapter** reads the catalogue and, separately, writes PDFs or field patches back. Zotero (local API on `localhost:23119`) is the first adapter; `manager = "mendeley"` is reserved. `run` never rewrites bibliographic fields. Attach and `fix-metadata --apply` use the Zotero 10+ write API. ## Data flow ```mermaid flowchart LR adapter[LibraryBackend] resolve[prepare_identifiers] sources[Source lanes] disk[out/ and state/] writeback[attach / fix-metadata --apply] adapter -->|read| resolve --> sources --> disk --> writeback --> adapter ``` 1. **Scope** — collection subtree or whole library; `run` skips items that already have an imported PDF (and by default skip items with only a `linked_url` PDF). 2. **Prepare identifiers** — verify library DOI; optional in-memory swap; PubMed PMID→DOI; title→DOI via Crossref / OpenAlex / Semantic Scholar. Skipped for web/blog/forum types. 3. **Sources** — ordered list (Unpaywall, OpenAlex, arXiv, …, CORE, EZProxy, HTML→PDF); per-item routing skips inapplicable sources unless `--try-all`. 4. **Download** — validate PDF size; write under `out_dir`; extract PDF DOI (`pdftotext`, then `pypdf`); append to `state/manifest.jsonl`. 5. **Attach** — optional `imported_file` upload via local write API; failures recorded as `attach_failed` with typed reasons. `paperful lint` runs step 2 (and PDF-text DOI) for items **with and without** PDFs. `paperful fix-metadata` writes `state/metadata-patches.jsonl` then, with `--apply`, pushes patches through the adapter. ## Disk artifacts | Path | Role | | --- | --- | | `out//…pdf` | Collection-mirrored downloads | | `state/manifest.jsonl` | Append-only resume ledger. Latest line per item key wins. Fields include `doi` (used this attempt), `library_doi`, `doi_verified`, `pdf_doi` | | `state/metadata-patches.jsonl` | Proposed patches (`doi`, `title`, `date`, `publicationTitle`) | | `state/pdf-cache/` | Manager PDFs exported so lint reads text on disk | | `state/sessions/` | Chromium profile + `meta.json` (login timestamps, no secrets). Netscape dumps for httpx | | `state/last-run.json` | Latest `run` report (`paperful.run_report.v1`) | | `state/runs/-.json` | Historical `run` and `fix-metadata` reports | `doi_verified` is `ok` (≥ `crossref_min_score`), `suspect` (< `doi_suspect_score`), `swapped` (in-memory replacement), `unknown` (API down, mid-range match, or `verify_doi = false`), or `missing`. `unknown` never swaps. ## Library adapter [`paperful/library.py`](../paperful/library.py) defines `LibraryBackend`: list items, export a PDF **onto disk**, apply a field patch, attach a file. Identifier logic (`resolve`, `lint`, `pdfid`, `metadata`) must not import Zotero except through this protocol. ## Identifiers and lint [`paperful/resolve.py`](../paperful/resolve.py) `prepare_identifiers` is shared by `run` and `lint`. Swap is in memory only. PubMed uses NCBI ID Converter on `PMID:` / `PubMed PMID:` in Extra. [`paperful/lint.py`](../paperful/lint.py) finding codes (manager-agnostic “library DOI”): | Code | When | | --- | --- | | `missing_doi` | Scholarly type, no DOI after prepare | | `suspect_doi` | Library DOI fails title check, no swap candidate | | `swappable_doi` | High-confidence replacement ≠ library DOI | | `pmid_no_doi` | PMID present, converter failed | | `pdf_doi_mismatch` | PDF-text DOI ≠ library DOI and ≠ prepared DOI | | `no_identifier` | No DOI, arXiv id, PMID, or URL | `--json` prints only findings. Exit 0 unless `--strict`. Lint prefers a file already on disk (`item.pdf_path` or manifest `path`) and calls `export_pdf` only when `has_pdf` and nothing is on disk. [`paperful/metadata.py`](../paperful/metadata.py) whitelist: `doi`, `title`, `date`, `publicationTitle`. Default fills empty venue/date; `--overwrite` may replace title/date/venue. Never invents creators. ## PDF text [`paperful/pdfid.py`](../paperful/pdfid.py): `pdftotext` (Poppler) if on `PATH`, else `pypdf` (first two pages + `/Title`). Manager fulltext is last-resort: export the file to `state/pdf-cache/` first. `paperful doctor` reports amber if `pdftotext` is missing. ## Circuit breaker Open-access sources run in parallel (`concurrency_oa`). Block-like outcomes (CAPTCHA, 429, “sorry”, …) increment a per-source counter; after `circuit_breaker_threshold` the source is skipped for the rest of the run. Scholar, Sci-Hub, EZProxy, and HTML→PDF stay serial (Scholar/htmlpdf share one Chromium profile lock). ## Sci-Hub and presets Sci-Hub is **never** in the default source list; opt in via config, `--scihub`, or `--sources`. The `eoi` preset (`--preset eoi`) limits runs to open access plus campus EZProxy (no Scholar, no Sci-Hub). CORE is in the default list but skipped until `core_api_key` is set. ## Disk mirror vs cloud quota When Zotero cloud storage is full, attachments may fail with quota errors; PDFs still land on disk and can be attached later. Linked PDF URLs in Zotero are treated as “already covered” unless `--upgrade-linked` is set. ## Operator tooling - `paperful doctor` — preflight. Colours: **green** = ready; **amber** = usable with a degraded path (empty email, missing session, no `pdftotext`, Zotero 7–9 write API, incomplete grey-lit pack); **red** on `Zotero :23119` / `out_dir` / `state_dir` is fatal (`doctor` and any command that needs Zotero). Reports grey-lit packs (UNGA/undocs · BBNJ/DOALOS · ISA) when builtin is on. See [commands](commands.md#doctor). - `paperful run --dry-run` — no downloads. Per item: **Would-hit** is the routed source list in order (full `sources` when `--try-all`). - `paperful lint` / `paperful fix-metadata` — identifier hygiene; apply is explicit. - `paperful report` / `paperful report --last-run` — manifest totals plus the latest auditable run report (`state/last-run.json`, history under `state/runs/`). Each `run` prints a **Run summary** table (downloads, attached, deferred, errors). A one-line banner is a [1.0](ROADMAP.md#trust-10) tightening. When Zotero is unreachable, `collections`, `run`, and `attach` exit **2** and print next steps (start Zotero, enable local API, `paperful doctor`). (run-report-v1)= ## Report JSON (`paperful.run_report.v1`) `paperful report --json` is `{ counts, by_source, no_identifier, no_doi, attach_failed_by_code, last_run? }`. `last_run` (when present) is the same object as `state/last-run.json`. **0.x may add keys**; 1.0 freezes this schema name. | Field | Meaning | | --- | --- | | `schema` | Always `paperful.run_report.v1` on run reports | | `command` | `run` (or `fix-metadata` on apply reports under `state/runs/`) | | `started_at` / `finished_at` | ISO-8601 UTC | | `duration_s` | Wall time, or `null` if start unknown | | `scope` | Collection path(s) or library | | `sources_configured` | Source names for that run | | `flags` | CLI flags (`dry_run`, `scihub`, `preset`, …) | | `paths.out_dir` / `manifest` / `state_dir` | Absolute paths | | `summary.pdfs_downloaded` | Successful downloads (`ok` bumps) | | `summary.attached` / `attach_failed` | Write-back counts | | `summary.not_found` / `no_identifier` / `captcha` / `error` | Item outcomes | | `summary.skipped_manifest` / `linked_url_skipped` | Not attempted this run | | `summary.fields_corrected` / `fields_corrected_by_kind` | In-memory DOI enrichments (not library writes) | | `summary.identifiers_verified` | `verify:ok` count | | `summary.by_source` | Hits per source name | | `summary.sources_checked` | Per-source outcome tallies | | `summary.errors_by_type` / `attach_failed_by_code` | Typed errors | | `items[]` | Per-item: `itemKey`, `title`, `status`, `source`, `reason`, `doi`, `doi_verified`, `attempts`, `fields_corrected`, `path`, `error_type` | Manifest `counts` keys match ledger statuses (`ok`, `attached`, `not_found`, …). ## Grey literature `direct` runs a **playbook engine** ([`paperful/playbooks.py`](../paperful/playbooks.py)): declarative `rewrite` / `scrape` / `synthesize` rules from config. A builtin **ocean/governance example pack** (`paperful/data/grey_playbooks_ocean.toml`) ships named grey-lit packs plus FAO/OECD/IEA/WHO examples — not core product logic; set `grey_playbooks_builtin = false` or override by `name`. Skip-host item URLs (YouTube, Scholar, …) still allow Extra/title synthesize. Domain-agnostic OA rewrites (PMC, arXiv, HAL) and DSpace/OAI stay in code. DOI-less `report` / `document` items can fall through to `htmlpdf`. Campus EZProxy is never used for these public hosts. Unpaywall/OpenAlex already skip DOI-less items (no quota burn on institutional reports). ### Named packs (BBNJ product) | Pack id | Hosts / patterns | Rules | | --- | --- | --- | | `undocs-unga-vme` | `undocs.org`, `documents.un.org`, `daccess-ods.un.org` | `rewrite` via `parser = "undocs"` → `https://undocs.org/pdf?symbol=…` | | `undocs-unga-vme-symbol` | Extra/title symbols `A/RES/…`, `A/N/N`, `A/CONF.…`, `A/AC.…`, `ISBA/…`, `S/…` | `synthesize` → same undocs PDF URL | | `bbnj-doalos-prepcom` | `un.org` (`/bbnjagreement/`, `/depts/los/`), `highseasalliance.org`, `iisd.org` (ENB) | `scrape` first same-origin `.pdf` / `sites/default/files` / Download; direct `.pdf` URLs need no rewrite | | `isa-deepdata` | `isa.org.jm` (documents / news landings) | `scrape` same-origin PDF/download; OBIS/ODIS links are not treated as PDF sources | Smoke collections (dry-run): `HKF7T7EI` (UNGA/VME), `7R77ZJFH` / `XFD86ZFP` (BBNJ/PrepCom), `J2SEXDC5` (ISA). Items with no DOI, arXiv id, PMID, URL, or matching synthesize playbook still stop at `no_identifier`. ## Related docs - [ROADMAP.md](ROADMAP.md) — 0.1→1.0 trust; core vs maybe-later - [releases.md](releases.md) — 0.x vs 1.0 - [comparison.md](comparison.md) — where paperful sits next to plugins and bib tools - [commands.md](commands.md) — CLI and disk artifacts - [config.md](config.md) — `config.toml` keys and grey playbooks - [ezproxy.md](ezproxy.md) / [sessions.md](sessions.md) — campus proxy and browser vault