When Omnivore shut down in 2024 I had a year's worth of saved articles and highlights sitting in an export archive with nowhere to go. This script moves them into Zotero — an open-source reference manager that stores attachments locally, syncs across devices, and handles both PDFs and web articles well. The highlights were the whole point, so the script embeds them as PDF annotations and as a child note on every item, giving you two ways to find them later.
This was also my first experiment building a real tool through vibe coding with Claude.
The spec lives in CLAUDE.md; the script was written entirely through that conversation.
It worked on the first real run.
Migrate a personal Omnivore export archive into Zotero.
Each article that has highlights gets imported as a Zotero item with:
- Full metadata (title, author, URL, tags, dates)
- The original PDF or HTML as a stored attachment
- Highlights embedded as yellow PDF annotations (PDFs, best-effort text search)
- Highlights as a Zotero child note (always — the reliable fallback and search layer)
Articles with no highlights are skipped entirely.
- Python 3.10+
- A Zotero account with a personal library
- Your Omnivore export zip (obtained from Omnivore's settings before shutdown)
Install dependencies:
pip install -r requirements.txtYou need two values from your Zotero account:
- Go to zotero.org/settings/keys
- Note your numeric user ID shown at the top of that page
- Click Create new private key
- Give it a name (e.g.
omnivore-rescue) - Allow library access: Read/Write
- Save and copy the key
- Give it a name (e.g.
Copy .env.example to .env and fill in both values:
cp .env.example .env.env is gitignored — it will never be committed.
python migrate.py \
--zip /path/to/omnivore-export.zip \
--user-id YOUR_ZOTERO_USER_ID \
--api-key YOUR_ZOTERO_API_KEY \
--collection "Omnivore Archive"Or with credentials in .env (loaded automatically):
python migrate.py --zip /path/to/omnivore-export.zip --collection "Omnivore Archive"| Flag | Description |
|---|---|
--zip |
Path to the Omnivore export zip file |
--user-id |
Zotero numeric user ID (or set ZOTERO_USER_ID in .env) |
--api-key |
Zotero API key (or set ZOTERO_API_KEY in .env) |
--collection |
Zotero collection name to import into (created if absent) |
--dry-run |
Validate and parse everything, make no API calls |
--limit N |
Process only the first N highlighted articles (useful for testing) |
python migrate.py --zip /path/to/omnivore-export.zip --dry-runThis parses the full archive, reports how many articles have highlights, and validates content without touching Zotero. Good sanity check before the real run.
The script writes migrate.log with one line per document (slug, status, Zotero item key).
On re-run it checks Zotero for existing items by URL and skips anything already imported.
Safe to interrupt and resume.
- Articles with no highlights file, an empty highlights file, or no
>quote lines - That's it — everything else gets imported regardless of reading progress
This project was built with Claude (Anthropic). The migration
spec, architecture decisions, and testing were mine; Claude wrote the code. Commits
carry Co-Authored-By: Claude Sonnet 4.6 attribution. Licensed MIT — Anthropic's
terms assign output ownership to the user, and the usual caveats about AI-generated
code apply: read it before you run it.
Total articles in export: 137
With highlights: XX
Skipped (no highlights): XX
Zotero items created: XX
PDFs - full annotation: XX
PDFs - partial annotation: XX
PDFs - no annotation (scanned): XX
HTML articles: XX
Missing content file: XX
Child notes created: XX
Highlights total: XXX
Matched to PDF text: XXX
Fell back to sticky note: XX
Errors: X (see migrate.log)