A headless CLI that translates an English Wikipedia article into a target Wikipedia language (Persian by default) using Perseus Desktop's existing Core pipeline, translating via OpenRouter only.
This is not a rewrite of Perseus. It reuses Core's real Parser, Chunker, Translator, Merger, Wikitext Generator, and Translation Session logic verbatim. The only things added are a thin CLI layer and a Node DOM polyfill; the only things removed are Desktop-only code (Tauri file/clipboard APIs) and every LLM provider except OpenRouter.
Wikipedia tables are still not translated — that limitation lives in
Core's Extractor/ParsoidParser and was left untouched, exactly as
specified.
pnpm install
pnpm run build # -> dist/cli.jsexport OPENROUTER_API_KEY=sk-...
# Fresh translation
node dist/cli.js translate "https://en.wikipedia.org/wiki/Special_relativity" \
--model google/gemini-2.5-flash \
--output ./output \
--target-wiki fa
# Resume an interrupted or partially-failed run
node dist/cli.js resume ./output/Special_relativity.session.json \
--output ./output(Or pnpm link / install as a package, then perseus-cli translate "<url>".)
| Variable | Required | Purpose |
|---|---|---|
OPENROUTER_API_KEY |
Yes | OpenRouter API key. Never hard-coded, never written into a session file. |
Each run writes two files into --output (default ./output), named after
the article's slugified title:
<Title>.txt— the final translated Wikitext.<Title>.session.json— a Translation Session file, byte-for-byte the same format Perseus Desktop reads/writes (format: "perseus-package",formatVersion: 1). You can open this directly in Perseus Desktop to keep editing, or hand it back toperseus-cli resumelater.
The session is saved incrementally after every chunk (translated or skipped), not just at the end — killing the process mid-run still leaves a resumable session file on disk.
stdout carries exactly one JSON object per invocation; all logging goes to stderr. Example success output:
{
"sourceTitle": "Special relativity",
"targetWiki": "fa",
"totalChunks": 12,
"translatedChunks": 12,
"skippedAlreadyTranslatedChunks": 0,
"failedChunks": [],
"wikitextPath": "output/Special_relativity.txt",
"sessionPath": "output/Special_relativity.session.json"
}If some chunks fail (e.g. persistent OpenRouter errors after retries), they
are listed in failedChunks ({chunkId, message}) — Wikitext/session are
still written with whatever did translate, and the session remains
resumable for just the failed chunk(s).
| Code | Meaning |
|---|---|
0 |
Full success — every chunk translated. |
1 |
Fatal error — nothing usable was produced (bad URL, missing API key, Parsoid/Wikidata unreachable, invalid session file, etc.). Error JSON printed to stdout with category/message. |
2 |
Partial success — Wikitext/session were written, but one or more chunks are in failedChunks. |
Resuming re-fetches the article by its saved revision ID (never the
live/latest revision — see Core's Pipeline.reconstructFromRevision), then
re-derives the same chunk grouping (deterministic given the same HTML and
chunk-size budget) and skips any chunk whose session entry is already fully
translated. Only the chunks that are missing or partially translated get
sent to OpenRouter again.
Nothing about the session format was changed. translation-sessions/types.ts,
export.ts, import.ts, and validate.ts are Core's unmodified files. A
session this CLI writes can be opened in Perseus Desktop, and a session
saved by Perseus Desktop can be resumed by this CLI.
Core keeps the parsed Parsoid HTML alive as the IR's structural backbone and
therefore calls DOMParser, Element, Document directly — APIs that only
exist in a browser/webview, not plain Node. src/cli/dom-polyfill.ts
installs linkedom (a fast,
spec-compliant DOM implementation) onto globalThis before any Core module
is imported. This is the only accommodation Core needed to run
headlessly — no parsing/merge/generation logic was changed.
Per the requirements, two small decorators wrap the OpenRouter provider outside Core:
src/cli/retryingProvider.ts— retries a transientProviderErrorup to 3 times with linear backoff; does not retryConfigurationError(e.g. a missing API key can't be fixed by retrying).src/cli/sanitizingProvider.ts— strips a wrapping Markdown code fence if the model wraps its whole answer in one.
Everything else — missing/duplicate segment handling, per-unit fallback
retry, placeholder-token corruption tolerance, and the hard
"unknown-node-reference" merge check — is Core's existing, unmodified
validation (see stages/06-translation/Translator.ts,
stages/02-parsing/placeholders.ts, stages/07-merge/Merger.ts).
perseus-cli translate "<url>" --output /path/to/dropThen read stdout as JSON, check the exit code, and hand
wikitextPath/sessionPath off to Telegram. perseus-cli never sends
anything to Telegram or Wikipedia itself — output is files only, exactly as
specified.
- Wikipedia tables are never translated (an intentional, pre-existing
Core limitation — not something this CLI added or could easily lift
without touching
Extractor/ParsoidParser, which was explicitly out of scope). - Chunk grouping (
SizeBoundedChunker, 2500-char budget) is fixed, not CLI-configurable, to guarantee a resumed session's chunks line up with the original run's. - Resuming still requires network access throughout (revision fetch, link resolution, final Wikitext generation) — there is no offline restore path, matching Core's own documented design.
- A chunk is treated as "already translated" only if every unit inside it differs from the freshly reconstructed original text. A partially-edited chunk (e.g. someone hand-edited one paragraph in Perseus Desktop but left another untouched) will be re-translated in full on resume, overwriting the hand-edit — this CLI doesn't do unit-level patching within a chunk, only whole-chunk skip/redo.
- Reference Attention (Core's heuristic "needs human review" classifier) runs and annotates the IR during generation, matching Core's normal pipeline flow, but this CLI does not currently surface those annotations anywhere (Core doesn't persist them in a Translation Session either).