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-src or data-original and put a 1px placeholder in src. The script checks all of them.
  • srcset and <picture>. One <img> can list a dozen sizes (a.jpg 480w, b.jpg 1080w). best_from_srcset keeps the widest candidate instead of the thumbnail.
  • CSS background images. url(...) inside inline style= attributes and <style> blocks. Not covered: external .css files (fetch those too if you need them).
  • Social images. og:image and twitter:image meta 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_bytes drops 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

  1. 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. Fetching robots.txt yourself with requests and your own User-Agent, then parse(), fixes it.
  2. 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.
  3. 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).
  4. Hotlink protection. Some CDNs return 403 unless the Referer header matches the page. Add s.headers["Referer"] = page_url.
  5. 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.

20-second screen recording of a real run of website-image-extractor on a sample page

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).