Right-click, "Save image as" does not scale past a handful of pictures. Here is a short Python script that downloads every image from a page, plus the traps that make naive scripts miss images or get you blocked.
The script
Install: pip install requests beautifulsoup4. Run: python grab_images.py https://books.toscrape.com/ images
#!/usr/bin/env python3 """Download every image from a web page. pip install requests beautifulsoup4""" import hashlib, os, re, sys, time from urllib.parse import urljoin, urlparse from urllib import robotparser import requests from bs4 import BeautifulSoup UA = "image-grabber-tutorial/1.0 (+contact: you@example.com)" CSS_URL = re.compile(r"url\(\s*['\"]?([^'\")]+)['\"]?\s*\)") LAZY_ATTRS = ("src", "data-src", "data-lazy-src", "data-original", "data-lazy") def best_from_srcset(srcset): """Pick the largest candidate from 'a.jpg 480w, b.jpg 1080w'.""" best, best_w = None, -1 for part in srcset.split(","): bits = part.strip().split() if not bits: continue w = 0 if len(bits) > 1 and bits[1][:-1].replace(".", "").isdigit(): w = float(bits[1][:-1]) * (1 if bits[1].endswith("w") else 1000) if w > best_w: best, best_w = bits[0], w return best def find_images(html, page_url): soup = BeautifulSoup(html, "html.parser") found = [] for img in soup.find_all("img"): for a in ("srcset", "data-srcset"): if img.get(a): found.append(best_from_srcset(img[a])) for a in LAZY_ATTRS: if img.get(a): found.append(img[a]) for src in soup.select("picture source[srcset]"): found.append(best_from_srcset(src["srcset"])) for m in soup.select("meta[property='og:image'], meta[name='twitter:image']"): found.append(m.get("content")) blobs = [t.get_text() for t in soup.find_all("style")] blobs += [t["style"] for t in soup.find_all(style=True)] for blob in blobs: found += CSS_URL.findall(blob) urls = [] for u in found: if u and not u.startswith("data:"): urls.append(urljoin(page_url, u.strip())) return list(dict.fromkeys(urls)) # dedupe URLs, keep order def main(page_url, out_dir="images", min_bytes=5000, delay=1.0): s = requests.Session() s.headers["User-Agent"] = UA # fetch robots.txt ourselves: urllib's default UA gets a 403 on some sites, # and RobotFileParser then treats the whole site as disallowed rp = robotparser.RobotFileParser() rr = s.get(urljoin(page_url, "/robots.txt"), timeout=20) rp.parse(rr.text.splitlines() if rr.status_code == 200 else []) if not rp.can_fetch(UA, page_url): sys.exit("robots.txt disallows this page") html = s.get(page_url, timeout=20).text urls = find_images(html, page_url) print(f"{len(urls)} candidate image URLs") os.makedirs(out_dir, exist_ok=True) seen, saved = set(), 0 for u in urls: if not rp.can_fetch(UA, u): continue time.sleep(delay) # be polite try: r = s.get(u, timeout=30) except requests.RequestException as e: print("skip", u, e); continue ct = r.headers.get("content-type", "") if r.status_code != 200 or not ct.startswith("image/"): continue if len(r.content) < min_bytes: # icons, tracking pixels continue digest = hashlib.sha1(r.content).hexdigest() if digest in seen: # same file under another URL continue seen.add(digest) name = os.path.basename(urlparse(u).path) or digest name = re.sub(r"[^A-Za-z0-9._-]", "_", name)[:80] with open(os.path.join(out_dir, f"{digest[:8]}_{name}"), "wb") as f: f.write(r.content) saved += 1 print(f"saved {len(r.content):>8} B {u[:90]}") print(f"done: {saved} unique images in {out_dir}/") if __name__ == "__main__": main(sys.argv[1], *(sys.argv[2:3] or ["images"])) What it handles (and why)
- Lazy loading. Many sites keep the real URL in
data-src,data-lazy-srcordata-originaland put a 1px placeholder insrc. The script checks all of them. - srcset and
<picture>. One<img>can list a dozen sizes (a.jpg 480w, b.jpg 1080w).best_from_srcsetkeeps the widest candidate instead of the thumbnail. - CSS background images.
url(...)inside inlinestyle=attributes and<style>blocks. Not covered: external.cssfiles (fetch those too if you need them). - Social images.
og:imageandtwitter:imagemeta tags often hold the best-quality version. - Dedupe twice. URLs are deduped first, then files are deduped by SHA-1 of the content, because the same picture is often served under two URLs.
- Size filter.
min_bytesdrops icons and tracking pixels. Raise it to 30000 for photos only.
Real output on two public sites
books.toscrape.com (a site made for scraping practice):
20 candidate image URLs saved 9876 B https://books.toscrape.com/media/cache/2c/da/2cdad67c44b002e7ead0cc35693c0e8b.jpg saved 7095 B https://books.toscrape.com/media/cache/26/0c/260c6ae16bce31c8f8c95daddd9f4a1c.jpg ... done: 17 unique images in out_b/ 20 URLs found, 17 unique files saved (the other 3 were dropped by the dedupe or the size filter).
en.wikipedia.org/wiki/Cat (heavy page with srcset everywhere):
112 candidate image URLs saved 115918 B https://en.wikipedia.org/static/images/icons/enwiki-25.svg saved 34961 B https://thumb.wikimedia.org/wikipedia/commons/thumb/2/25/Siam_lilacpoint.jpg/330px-Siam_li... ... skip https://en.wikipedia.org/static/images/footer/wikimedia.svg ... Read timed out done: 70 unique images in out_wp/ 112 candidates, 70 unique files saved, one timeout skipped (the script logs it and carries on). It took about two minutes, because of the 1 second delay between requests, which is deliberate.
Traps
- robots.txt. The script checks it for the page and every image. A bug I hit while testing: Python's
RobotFileParser.read()uses its own User-Agent, Wikipedia answered 403, and the parser then treated the whole site as disallowed, so the script refused to run. Fetchingrobots.txtyourself withrequestsand your own User-Agent, thenparse(), fixes it. - Rate limits. One request per second is polite. Hammering a site gets your IP blocked and is rude to the site owner. Set a User-Agent with a contact address.
- JavaScript-injected images. This script reads the HTML the server sends. Galleries that build themselves with JavaScript (infinite scroll, carousels fetched via API) need a real browser such as Playwright, or the JSON API the page calls (check the browser's Network tab).
- Hotlink protection. Some CDNs return 403 unless the
Refererheader matches the page. Adds.headers["Referer"] = page_url. - Copyright. Being able to download an image does not mean you may reuse it. Check the site's terms and the image licence (Wikimedia Commons images carry per-file licences). Use downloads for personal archiving, analysis or with permission.
When one script is not enough
For dozens of sites, same-site crawling, size filters, or a ZIP of the files without maintaining the script, there is a hosted option. I run the Apify actor pennydata/website-image-extractor (plain HTTP, no browser, respects robots.txt). It reads img, srcset, picture, og:image and optionally CSS backgrounds, and returns one row per unique image.
Screen recording of a real run (no audio).
Sample input:
{"mode":"images","startUrls":[{"url":"https://en.wikipedia.org/wiki/Cat"}],"maxImages":10} Real output rows from run mvWmlc1aaw8J1Bdi8 (2026-10-25, 3 seconds, 10 rows, trimmed to three fields; run on the Wikipedia Cat page):
ok Siam_lilacpoint.jpg image/jpeg 647001 bytes source: og:image ok 40px-Symbol_support_vote.svg.png image/png 2206 bytes source: img-srcset ok 330px-Siam_lilacpoint.jpg image/jpeg 34961 bytes source: img-srcset Same limits as the script: no JavaScript rendering and no login. Price at the time of writing: pay per event, USD 0.002 per image found on the free tier, failed or robots-blocked pages not charged. The actor page is the source of truth for current prices.
Questions or an image source the script misses? Comment below and I will test it.
I publish the Apify actor linked above and earn from its runs. Written with AI assistance (Claude).


