doc-dl is a command-line downloader for documents that do not always behave
like ordinary download links. Give it a public document or landing-page URL and
it works through the sensible options: direct download first, then page
discovery, browser downloads, and finally a verified PDF reconstruction when a
viewer is the only available public representation.
It is built for the slightly annoying reality of modern document sites: pages that hide the file behind JavaScript, viewers that lazy-load pages, redirects, and downloads that fail halfway through. The goal is simple: get a usable, verified file or get a clear reason why that was not possible.
- Download PDFs, Office documents, EPUBs, OpenDocument files, RTF, CSV, and plain text when a site exposes a real document response.
- Find document URLs embedded in links, metadata, JSON-LD, and page source.
- Watch browser downloads and document responses on JavaScript-heavy sites.
- Reconstruct a complete, image-backed PDF from visible viewer pages when no original file is available.
- Resume interrupted HTTP downloads when the server provides safe validators.
- Verify files before saving them to their final name.
- Keep sign-in cookies in an isolated browser profile, never in command-line arguments.
The portable release includes Python and every required library, so you do not need to install Python yourself. There are two kinds of release:
- Slim (recommended): small download. The first time you open a site that
needs a browser to extract its document,
doc-dlautomatically downloads Chromium once and reuses it after that. Direct file downloads never need Chromium at all. - Full: a larger, offline-ready download with Chromium already included, for machines that are offline or on a locked-down network where an on-demand download would not work.
If you are not sure which one you need, use slim. The commands below install the slim release by default.
Open PowerShell and run:
irm https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/install.ps1 | iexThe installer downloads the latest slim Windows release, verifies its SHA-256
checksum, installs it under your local application-data folder, and adds
doc-dl to your user PATH. To install the full offline build instead, set
DOC_DL_VARIANT first:
$env:DOC_DL_VARIANT = "full"
irm https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/install.ps1 | iexcurl -fsSL https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/install.sh | shThe installer selects the right slim release for Linux x64, Intel macOS, or
Apple Silicon, verifies its checksum, and links doc-dl into ~/.local/bin.
For the full offline build instead:
curl -fsSL https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/install.sh | DOC_DL_VARIANT=full shRemove the installed program and its PATH entry:
irm https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/uninstall.ps1 | iexOn macOS or Linux:
curl -fsSL https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/uninstall.sh | shBoth commands preserve isolated sign-in profiles, any Chromium runtime
doc-dl downloaded on demand, and other runtime state. To remove that state
too (sign-in sessions and any downloaded Chromium runtime together), use one
of these explicit commands:
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/uninstall.ps1))) -PurgeDatacurl -fsSL https://raw.githubusercontent.com/mkhlz/doc-dl/master/scripts/uninstall.sh | sh -s -- --purge-dataTo remove only the downloaded Chromium runtime and keep your sign-in profiles,
use doc-dl uninstall-browser instead; see
Managing the Chromium browser runtime.
Open a new terminal after installation and give doc-dl a URL:
doc-dl "https://www.scribd.com/document/1039114955/GMAT-Syllabus-PDF"PowerShell users can also use the winget-style -Url form:
doc-dl -Url "https://www.scribd.com/document/1039114955/GMAT-Syllabus-PDF"That is the whole everyday workflow. Run doc-dl doctor if you ever want to
check the installation.
Try these in PowerShell:
-
W3C direct PDF, quick smoke test (a plain file download, no browser needed):
doc-dl "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
-
Internet Archive public-domain PDF:
doc-dl "https://ia801907.us.archive.org/17/items/librivoxcdcoverart36/bestfriend_2006.pdf"
-
arXiv research paper PDF:
doc-dl "https://arxiv.org/pdf/2212.07286"
-
Scribd multi-page viewer test: this is the case that needs Chromium, so it is also a good way to see the one-time browser download in action on a fresh install:
doc-dl "https://www.scribd.com/document/959028055/Ten-Page-Sample" --verbose
Scribd downloads depend on the access available through your normal Scribd account; the example above is a public Scribd sample that does not require signing in.
-
SlideShare presentation: slide images are read straight from the page, so this never needs Chromium at all:
doc-dl "https://www.slideshare.net/RachelElliottRigolino/sample-oral-report-powerpoint"
Use this pattern to keep files in your Downloads folder instead of the current directory:
doc-dl "PASTE-URL-HERE" --output "$HOME\Downloads" --verboseOpen the GitHub Releases page and
download the archive for your computer. Extract the doc-dl folder somewhere
permanent and add that folder to PATH.
Slim (recommended): Python and every required library, but no Chromium. The first browser-backed download triggers a one-time Chromium download; plain file downloads never need it.
| Release file | System |
|---|---|
doc-dl_win.zip |
Windows 10 or newer, Intel or AMD 64-bit |
doc-dl_linux.tar.gz |
64-bit Linux |
doc-dl_macos_x64.tar.gz |
Intel Mac |
doc-dl_macos_arm64.tar.gz |
Apple Silicon Mac |
Full: everything in slim, plus Chromium already included, for offline use or locked-down networks. Noticeably larger.
| Release file | System |
|---|---|
doc-dl_win_full.zip |
Windows 10 or newer, Intel or AMD 64-bit |
doc-dl_linux_full.tar.gz |
64-bit Linux |
doc-dl_macos_x64_full.tar.gz |
Intel Mac |
doc-dl_macos_arm64_full.tar.gz |
Apple Silicon Mac |
SHA2-256SUMS in the same release lists SHA-256 checksums for every archive
above, slim and full alike. During a workflow run, GitHub also displays
temporary build-bin-* artifacts that transfer these archives between jobs;
the final GitHub Release shows the public filenames in the tables above.
Python users can keep using the smaller wheel:
python -m pip install .\dist\doc_dl-0.1.9-py3-none-any.whl
doc-dl doctorThis method requires Python 3.11 or newer. Chromium is not installed yet;
doc-dl downloads it automatically the first time a browser-backed site needs
it, or you can install it ahead of time with doc-dl install-browser.
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
.\.venv\Scripts\doc-dl.exe doctorOn macOS and Linux, use .venv/bin/python and .venv/bin/doc-dl instead. To
run the full test suite, including the browser-backed tests, also install
Chromium once:
.\.venv\Scripts\python.exe -m playwright install --no-shell chromiumIf the virtual environment is not activated, call its executable directly:
.\.venv\Scripts\doc-dl.exe -Url "https://www.scribd.com/document/1039114955/GMAT-Syllabus-PDF"doc-dl has two commands with different jobs: download (the default, so
just doc-dl URL) looks for an actual document file; archive snapshots a
page as it renders, for pages that were never a document in the first place.
doc-dl URL (download) |
doc-dl archive URL |
|
|---|---|---|
| What it's for | A PDF, Office file, EPUB, or other document behind the link | A news article, blog post, or any web page you want a record of |
| What you get | The original file when one exists; a rebuilt PDF from a viewer's pages otherwise | One PDF made from the page's own rendered layout |
| Result quality | Byte-identical to the source when original; otherwise a faithful reconstruction | A screenshot of the page -- readable, but not the site's own typography |
| If nothing downloadable is found | Falls back to the same page-capture archive does, unless --original-only is set |
N/A -- that's the whole point of the command |
| History log entry | Opt-in (--write-metadata) |
On by default (--no-metadata to skip) -- capture provenance is the point |
| Distinguishing options | --original-only, --no-browser, --retries, --no-resume |
--max-pages, --select-range, --wait-for |
| Shared options | --output, --filename, --profile, --timeout, --overwrite, --quiet, --verbose, --json, --batch, --concurrency |
Rule of thumb: if you'd expect a "Download" or "Export as PDF" button
somewhere on the page, use download. If you're saving a page the way a
browser print-to-PDF would, use archive.
doc-dl "https://example.com/report.pdf" # a real document
doc-dl archive "https://example.com/news/some-article" # a page, not a document--choose reads the link, tells you what it looks like and why, then offers
only the routes that make sense for it, with the likeliest one marked:
doc-dl "https://www.slideshare.net/slideshow/some-deck/123456" --choose slideshare.net
This looks like a document on slideshare.net, which doc-dl has a dedicated provider for.
The slideshare provider fetches the document itself instead of screenshotting a
browser, so it is both higher quality and less likely to trip a bot check.
What would you like to do?
1. Download the document (recommended)
doc-dl URL
2. Download, but only the publisher's own file (never a rebuild)
doc-dl URL --original-only
3. Snapshot the page as a PDF
doc-dl archive URL
0. Cancel
Choice:
Each option shows the plain command it stands for, so it doubles as a way to learn the flags rather than a thing you have to keep reaching for. The read is based on the link alone — no network call, so the menu is instant — which makes it a strong hint rather than a certainty. Every route stays on the list whatever the link looks like; a wrong guess costs a glance, not a second command.
--choose needs a real terminal and handles one URL, so it can't be
combined with --json, --quiet, or --batch.
The short version: if the site is hosting a file, use download. If the
site is the content, use archive. A few concrete cases:
| Site / kind of link | Use | Why |
|---|---|---|
| SlideShare, Scribd | download |
Both have a dedicated provider that pulls the real page images or file directly — no browser screenshot involved, so nothing to fool a bot check with, and the result is the actual document, not a picture of it. |
| Google Drive, Dropbox, OneDrive share links | download |
Same idea — these resolve straight to the file's real download endpoint. |
A direct link ending in .pdf, .docx, .pptx, etc. |
download |
It's already a file. archive would just screenshot whatever viewer the browser shows for it. |
| News articles, blog posts | archive |
There's no file to find — the page itself is what you want a record of. |
| A site behind Cloudflare/Fastly-style bot checks (a CAPTCHA, "checking your browser") | download if it's a known document host (see above); otherwise expect archive to refuse with a clear message rather than save a screenshot of the challenge |
Loading a full browser to screenshot a page is exactly the kind of behavior these checks are built to catch. A direct-file provider mostly avoids the browser step entirely. |
| A long news homepage or "feed" page that bunches several stories together | archive --select-range 1-3 or --max-pages N |
There's no single "document" here either, and letting it capture the whole scroll length would sweep in unrelated stories below the one you wanted. |
| A page that's slow to load (heavy ad-laden news sites) | archive --wait-for "selector" |
Gives the page a specific thing to wait for instead of guessing. |
If you're not sure which applies, download is the safer first try — it
falls back to the same page-capture archive does when nothing downloadable
turns up (unless --original-only is set), so you're unlikely to walk away
with nothing either way.
Why a "stuck" spinner might actually mean it already finished: if
archive refuses a page (a bot challenge, for example), the spinner and the
error can appear to overlap in some terminals — the spinner writes to
standard output and the error to standard error, and depending on how the
terminal buffers the two, the error can print before the spinner's own
"stop" frame clears. It looks like:
⠸ Resolving link ▲ This page is showing a bot-detection challenge instead of its content
That is cosmetic, not a hang — the command has already exited by the time
that line appears. If it's sitting there for more than a few seconds with no
error at all, that is worth interrupting and re-checking with --verbose.
Save into a downloads folder and record redacted provenance metadata:
doc-dl URL --output .\downloads --write-metadata --verboseChoose one exact output path:
doc-dl URL --output .\downloads\my-document.pdfUse a filename pattern. Available fields are {title}, {ext}, {provider}
and {filename}:
doc-dl URL --output .\downloads --filename "{provider}-{title}.{ext}"Process a list of URLs at once, several at a time:
doc-dl --batch urls.txt --concurrency 4 --output .\downloadsurls.txt is one URL per line (blank lines and # comments are skipped).
Each URL goes through the exact same pipeline as a single doc-dl URL run —
same retries, same reconstruction — just several running concurrently. One
line prints per result as it completes, in whichever order that happens to
be, followed by a succeeded / failed count; a failed URL doesn't stop the
rest of the batch.
| Option | What it does |
|---|---|
--output, -o |
Output directory or exact file path. |
--filename |
Filename template for downloads saved to a directory. |
--original-only |
Accept only original document bytes. Never use a rendered PDF. |
--no-browser |
Skip browser discovery and viewer reconstruction. |
--profile NAME |
Use an isolated signed-in browser profile. |
--timeout 5m |
Set the full operation timeout. Supports seconds, minutes, and hours. |
--retries 5 |
Retry transient transfer failures. |
--no-resume |
Disable protected resume for interrupted HTTP transfers. |
--overwrite |
Replace an existing output file. |
--write-metadata |
Record a redacted provenance entry in the history log. |
--choose |
Inspect the link and pick a route interactively instead of guessing the flags. |
--batch FILE |
Process every URL in this file instead of one URL. |
--concurrency N |
How many --batch URLs to run at once (default 3, max 10). |
--verbose |
Show which strategies are being tried. |
--json |
Emit newline-delimited JSON events for automation. |
--quiet |
Print only errors and the final output path. |
doc-dl archive snapshots a page as a PDF instead of looking for a
downloadable file. It expands any "Read more" / "Continue reading" toggle
and dismisses cookie banners first, then takes one continuous full-page
screenshot — the same idea as a scrolling-capture tool — and slices that
into normal PDF pages in Python, rather than scrolling and re-screenshotting
step by step (which could crop or duplicate content at page boundaries if
anything shifted between shots). No attempt is made to isolate or re-style
"the article part" of the page, since that varies too much from site to
site to do reliably:
doc-dl archive "https://example.com/news/some-article"A provenance entry is recorded by default (opt out with --no-metadata) —
title, byline, publish date, site name, and whether paywall indicators were
detected — but never as a file next to the PDF. Pasting a link and getting
a document back should mean exactly that: the download folder holds just
the file. Everything else goes into a single history log instead (run
doc-dl doctor to see where):
{
"title": "Harbor City Approves New Transit Line",
"byline": "Jordan Rivera",
"published": "2026-08-01T09:00:00Z",
"site_name": "The Harbor Gazette",
"provenance": "captured",
"paywall_suspected": false
}A page flagged paywall_suspected: true still captures whatever is visible
(usually just the free teaser above the paywall) — the flag is a signal to
check the result, not a guarantee the capture is incomplete.
The default filename is the title, site name, and capture date —
Harbor City Approves New Transit Line - The Harbor Gazette - 2026-08-01.pdf
— so a folder of archived pages sorts sensibly and generic titles from
different sites ("Live Updates", "Home") don't collide. A --filename
template still gets the plain article title as {title}.
A long feed-style page can bunch the linked article together with unrelated
ones below it (a news homepage, or a listing that keeps loading more stories
as you scroll). --max-pages stops the capture after a set number of pages;
--select-range keeps a specific range instead of just the first N:
doc-dl archive "https://example.com/news/some-article" --max-pages 3
doc-dl archive "https://example.com/news/some-article" --select-range 2-4Some sites need more time than the built-in settle heuristics guess —
--wait-for waits for a CSS selector to appear before capturing starts:
doc-dl archive "https://example.com/slow-page" --wait-for "article"Archiving supports the same --batch / --concurrency options as
download, for saving a list of articles at once.
| Option | What it does |
|---|---|
--profile NAME |
Use an isolated browser profile (useful for sites you're signed into). |
--timeout 90s |
Set the full operation timeout. |
--max-pages N |
Stop after N pages instead of capturing the whole scroll length. |
--select-range 2-4 |
Keep only this page range (1-indexed, inclusive); mutually exclusive with --max-pages. |
--wait-for SELECTOR |
Wait for this CSS selector to become visible before capturing. |
--batch FILE / --concurrency N |
Same as download. |
--no-metadata |
Skip recording a history entry. |
--overwrite |
Replace an existing output file. |
doc-dl extract-pages copies a page range out of a local PDF into a new
file — grabbing one chapter out of a long book without re-uploading the
whole thing:
doc-dl extract-pages book.pdf --pages 37-75
doc-dl extract-pages book.pdf --pages 37-75 --output chapter-2.pdfThe default output filename is <source>-pages-<start>-<end>.pdf next to
the source file.
For documents your normal provider account can access, create an isolated profile and finish sign-in in the browser window:
doc-dl login scribd --profile personal
doc-dl URL --profile personalRemove that profile when you are finished:
doc-dl logout scribd --profile personal --yesdoc-dl prefers an original file whenever one is available. If a site offers
only a visible viewer, it can create a reconstructed PDF from the pages the
viewer actually displays. Successful results identify their source as
original, exported, reconstructed, or captured in the metadata.
The tool rejects empty files, HTML masquerading as a document, corrupt PDFs, invalid Office containers, and visibly blank reconstructed pages. A partial or unverified result is not promoted to the final output path.
If nothing downloadable turns up at all — a news article or blog post with no
attached file, for example — download falls back to snapshotting the page
itself, the same way doc-dl archive would, rather than failing outright.
This is reported as captured provenance so it's never confused with the
real document. Pass --original-only to disable this and get a hard failure
instead.
doc-dl version
doc-dl providers
doc-dl doctor
doc-dl archive URL
doc-dl extract-pages book.pdf --pages 37-75
doc-dl install-browser
doc-dl uninstall-browserdoctor checks Python, the required libraries, whether Chromium is installed,
and the writable application-state location. It reports Chromium's status but
does not fail because of it: a missing Chromium is expected for a fresh slim
install and is not a problem until a browser-backed site actually needs it.
You never have to think about Chromium for direct file downloads; those never
touch a browser. The first time a browser-backed site does need one, doc-dl
downloads Chromium automatically, shows progress, and reuses it for every
download after that:
doc-dl "https://example.com/some-javascript-viewer"
# Chromium is not installed. Downloading the browser runtime now
# (one-time download into C:\Users\you\AppData\Local\doc-dl\browsers)...To install it ahead of time instead of waiting for the first browser-backed download:
doc-dl install-browserTo remove the downloaded browser runtime later and reclaim the disk space, without touching your sign-in profiles:
doc-dl uninstall-browserChromium is stored in a stable per-user location (doc-dl doctor reports the
exact path), never beside the installed program and never inside a release
archive. --no-browser still skips browser escalation entirely, so it also
skips this download. A full offline release already has Chromium bundled, so
install-browser and uninstall-browser report it as already installed;
uninstall-browser refuses to remove a bundled offline copy, since that would
defeat the purpose of choosing the full build.
.\.venv\Scripts\ruff.exe check src tests
.\.venv\Scripts\python.exe -m pytest
.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir distdocs/ISSUES.md records real failures hit against live sites, their root causes, and how each was fixed. Worth reading before debugging a similar report: several of those symptoms turned out to be something other than what the error message said.
The release workflow lives in .github/workflows/release.yml and follows the
same build, collect, tag, and publish sequence for every entry point:
- Run Release manually with the version field empty to build and verify all packages without creating a tag or GitHub Release.
- Run Release manually with a bare version such as
0.1.3to verify that it matches the package, build the packages, push the tag, and publish the release. - Push a bare version tag such as
0.1.3to run the same build and publish path.
Before tagging, update the version in pyproject.toml and
src/doc_dl/__init__.py, run the development checks, and commit the release
changes. The tag should be the exact version without a v prefix.
For a local slim build, install the build extras and run the builder for the
current operating system. --variant slim is the default, so it can be
omitted:
python -m pip install ".[build]"
python scripts\build_portable.py --target windows-x64 --variant slimFor a local full build, also place Chromium in a dedicated directory first:
python -m pip install ".[build]"
$env:PLAYWRIGHT_BROWSERS_PATH = "$PWD\build\playwright-browsers"
python -m playwright install --no-shell chromium
python scripts\build_portable.py --target windows-x64 --variant fullThe resulting archive is written to release-assets/. The builder runs both
doc-dl version and doc-dl doctor against the frozen executable before it
creates the archive; doc-dl doctor passing without Chromium is expected and
required for a slim build. The GitHub Actions release workflow builds both
variants for every platform in the same run.
generic: direct files, redirects, page-source discovery, JavaScript downloads, document responses, and compatible viewer reconstruction.scribd: normalized document URLs, isolated login profiles, embedded viewer activation, lazy page loading, and complete-page PDF reconstruction.slideshare: presentation slide URLs parsed directly from the page, no browser required; every slide is fetched and merged into one PDF.googledrive: shared file links resolved to Drive's own download endpoint, preserving the resource key that older shared links require.dropbox: share links resolved to the file itself rather than the preview page, keeping the access key newer links carry.onedrive: personal share links resolved through OneDrive's public shares endpoint. Business and SharePoint links are not rewritten.
Websites can change their layouts or restrict access. In those cases,
doc-dl returns a stable error and leaves no unverified final file behind.
This project is free for personal, educational, research, and other non-commercial use.
Commercial use requires prior written permission from the copyright holder.
See the LICENSE file for full terms.
