Output formats
Text for reading, json for piping, and the exit code that carries the rest.
Every command takes -o:
spr get /article/10.1007/s10994-021-05946-3 # text, for reading
spr get -o json /article/10.1007/s10994-021-05946-3 # json, for piping
spr get --body is a third thing: the response body itself, unformatted, so a page or a pdf goes straight to a file or another tool.
spr get --body /journal/10994 > journal.html
spr get --body --kind pdf /content/pdf/10.1007/s10994-021-05946-3.pdf > paper.pdf
The exit code is part of the output
A command that fetched a page successfully and a command that fetched a paywalled page both print something worth reading, so the difference between them is the exit code rather than an error message.
| Code | Meaning |
|---|---|
| 0 | It did what it was asked |
| 1 | A flag or argument this tool does not understand. Nothing was fetched. |
| 2 | The search surface answered with a client challenge and there was no fallback left |
| 3 | The page was fetched and understood and there was nothing in it |
| 4 | The publisher states access=No. The metadata was printed; only the body is missing. |
| 5 | A network failure, a timeout, or a 5xx that outlived the retries |
| 6 | An upstream said, in a header, that the budget is spent |
| 7 | spr verify found that a page no longer reads the way the ledger recorded it |
Seven is the only one of those that is a statement about the site rather than about the run, which is why it is not folded into any of the others. A scheduled job that wants to alert on Springer changing a page should not have to tell that apart from a mistyped flag.
So this works the way you would want it to:
spr get -o json "$url" || case $? in
4) echo "paywalled, metadata above" ;;
2) echo "challenged, try the rss feed" ;;
esac
Those are the codes for one identifier. A run given many of them, or reading them off stdin, answers about the run instead: a status has to cover every single target before it becomes the run's code, because one paywalled work in five hundred is not a restricted run. The counts go to stderr either way. See reading identifiers from stdin.
Every record carries an envelope
A record is not just fields. spr work -o json puts an envelope next to them:
{
"doi": "10.1007/s10994-021-05946-3",
"title": "Aleatoric and epistemic uncertainty in machine learning: an introduction to concepts and methods",
"envelope": {
"tier": "html",
"urls": ["https://link.springer.com/article/10.1007/s10994-021-05946-3"],
"fetched": "2026-08-18T10:12:04Z",
"status": "ok",
"redirects": 3,
"bytes": 718572,
"via": {
"authors": "linkdata:author[]",
"references": "highwire:citation_reference",
"sections": "region:section[data-title]"
},
"unread": ["MPU1-ad", "access-count", "altmetric-score"]
}
}
There is no missed key on that record because nothing was missed, which is the same rule the fields follow.
| Field | What it is for |
|---|---|
tier |
which surface produced the record: html, rss, search, sitemap, crossref, openalex or api |
urls |
the requested urls, never the effective ones, because the effective url carries a per request uuid and is not an identifier |
via |
which rung and which exact tag or region answered each field |
missed |
every field that was looked for and did not arrive, each with the reason |
unread |
every region on the page nobody read, so the record never looks more complete than it is |
Absent means absent. A field the page did not carry is left out of the json rather than emitted as null, so .abstract == null and no abstract key are the same answer, and a field in missed is the third case: it should have been there and something stopped it.
Provenance on a graph
spr graph is the one command whose output is not a record, and it carries the same two keys in a different place. via and tier sit on every node and every edge rather than once in an envelope, because a graph walked over forty pages and enriched from two backends is a document where the question is never what produced this file, it is what produced this edge.
{"from": "spr:work/10.1007/s10994-021-05946-3", "to": "spr:person/orcid/0000-0002-9944-4108",
"type": "authoredBy", "position": 1, "via": "jsonld:author[]", "tier": "html"}
The graph's own envelope keeps the seed url and the running byte count and not the url of every page read, since a list of fourteen hundred urls is a log rather than provenance. Under --format nq the tier becomes the fourth term of every quad, so the same question can be asked of a triple store.
Seeing what it did
--debug puts one line per request on stderr, which stays out of the pipe:
$ spr get --debug --no-cache /article/10.1007/s10994-021-05946-3 -o json | jq .bytes
spr: 200 ok 718872 bytes 3 redirects https://link.springer.com/article/10.1007/s10994-021-05946-3
718872