Download every image a web page shows (and keep a CSV manifest)
"Grab the images from this page" sounds like a five-line script until you try it on a real article. The markup mixes img[src], srcset, lazy-load attributes, root-relative paths, and the occasional PDF. Half the "images" are tracking pixels. One hero URL returns a login wall with Content-Type: text/html. Another logo appears three times under three CDNs. If you only keep the files that landed on disk, you cannot tell what was skipped or why.
I maintain a small Python tool aimed at that job: page-image-downloader (MIT, standard library only, Python 3.9+). This post is about the problem shape and a pattern you can run locally. A hosted runner is optional later — start with the free repo.
Page images are not the same problem as a CSV
If someone already handed you a spreadsheet of direct image URLs, use a list downloader. We publish one of those too: bulk-image-downloader. A page is different: you must discover candidates from HTML first, resolve them against the page URL, then download.
What page-image-downloader collects from HTML (at commit 263a6f6):
-
imgvia lazy-load attrs (data-src,data-lazy-src,data-original) beforesrc - largest
srcset/data-srcset/data-lazy-srcsetcandidate (highestw, else highestx) - each
<picture><source srcset="...">largest candidate -
og:image/twitter:image(and a few related meta keys) - optional linked files with
--links pdf(or other extensions)
Relative URLs are resolved against <base href> and the page URL. The same absolute URL is fetched once per page. Whatever you collect, write a manifest with one row per candidate. Status beats vibes.
Minimal local run
Verified CLI at commit 263a6f6 (python3 page_image_downloader.py --help): URLs are positional (there is no --url). Output directory is -o / --out (default images).
git clone https://github.com/fecklesspleb/page-image-downloader cd page-image-downloader python3 page_image_downloader.py \ "https://example.com/" \ -o page-images Or pass a text file of URLs with -i / --input (one URL per line, # comments allowed).
What that run writes:
- Folder
page-images/<page-host>/with files named<stem>-<first 10 hex of sha256>.<ext>(extension from sniffed bytes, not the URL) -
page-images/manifest.csv(always) - A JSON summary printed to stdout (not a separate
.jsonfile). Progress lines go to stderr (-qhides them).
manifest.csv columns (exact header):
page_url, image_url, final_url, found_in, alt, status, file, content_type, bytes, width, height, sha256, error
status values the tool writes: saved, duplicate, too_small, too_narrow, too_large, not_an_image, html_instead_of_file, robots_disallowed, error.
Exit code is 1 only when nothing was saved and at least one request failed.
Pattern: resolve, fetch, sniff, record
Even if you write your own script, keep this shape. The snippets below are illustrative and use only the standard library (Python 3.9+; Optional keeps the annotation 3.9-safe).
from urllib.parse import urljoin, urlparse from urllib.request import Request, urlopen import hashlib from pathlib import Path from typing import Optional UA = "page-image-example/0.1 (+https://github.com/fecklesspleb/page-image-downloader)" def fetch(url: str, timeout: float = 30) -> bytes: req = Request(url, headers={"User-Agent": UA}) with urlopen(req, timeout=timeout) as resp: return resp.read() def looks_like_image(data: bytes) -> Optional[str]: if data.startswith(b"\x89PNG\r\n\x1a\n"): return "png" if data[:3] == b"\xff\xd8\xff": return "jpg" if data[:4] == b"GIF8": return "gif" if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "webp" head = data[:200].lstrip().lower() if head.startswith(b"<svg") or b"<svg" in head: return "svg" return None def save_unique(out_dir: Path, url: str, data: bytes) -> Path: kind = looks_like_image(data) if not kind: raise ValueError("not_an_image") digest = hashlib.sha256(data).hexdigest()[:10] host = urlparse(url).netloc or "unknown" path = out_dir / host path.mkdir(parents=True, exist_ok=True) dest = path / f"asset-{digest}.{kind}" if not dest.exists(): dest.write_bytes(data) return dest page = "https://example.com/articles/one" candidate = "/static/hero.jpg" absolute = urljoin(page, candidate) print(absolute) Running only the resolve lines prints:
https://example.com/static/hero.jpg Sniffing the first bytes matters more than trusting Content-Type or a .jpg suffix. Soft 404 pages and interstitial HTML with Content-Type: text/html fail the sniff and should be recorded as not_an_image, not written as a corrupt JPEG that breaks the next pipeline step. (page-image-downloader does that when the bytes do not sniff as an image and the MIME type is not an image type either.)
Manifest columns worth keeping
At minimum — and these are the columns the tool actually writes:
| Column | Why |
|---|---|
page_url | The page (or direct URL) you asked for |
image_url / final_url | Candidate URL, and where it ended after redirects |
found_in | img, img srcset, picture source, og:image, twitter:image, a href, or direct |
status | saved / duplicate / not_an_image / error / … |
file | Relative path on disk (for duplicate, the first copy) |
bytes | Spot empty or huge surprises |
width / height | From the file header when readable |
sha256 | Dedupe across hosts |
error | HTTP/timeout detail on error rows |
With that table you can answer "how many assets did we actually get?" without opening the folder.
Politeness and rights
- Send a User-Agent that names the project and points at the repo.
- Cap concurrency; the tool defaults to a 0.5 s per-host delay (
--delay). - It checks
robots.txtunless you pass--ignore-robots(only for sites you run). - Only download pages and assets you have a right to use. "It was on a public page" is not automatically a license to redistribute.
Free repo first
The free GitHub project is the default. Use it in CI, on a laptop, or inside a container with no API keys. If you later want the same workflow on a hosted crawler platform, treat that as an optional convenience for people who already live there — not as the primary link in a how-to.
Try it: fecklesspleb/page-image-downloader. For CSV/text URL lists instead of HTML discovery, use bulk-image-downloader.
Disclosure
We built and maintain page-image-downloader (and the sibling bulk-image-downloader for CSV lists). This article and the tool/README were written with AI assistance and reviewed before publishing.
