<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[fecklesspleb]]></title><description><![CDATA[fecklesspleb]]></description><link>https://fecklesspleb.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 13 Nov 2026 19:51:22 GMT</lastBuildDate><atom:link href="https://fecklesspleb.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Download every image a web page shows (and keep a CSV manifest)]]></title><description><![CDATA["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…]]></description><link>https://fecklesspleb.hashnode.dev/download-every-image-a-web-page-shows-and-keep-a-csv-manifest</link><guid isPermaLink="true">https://fecklesspleb.hashnode.dev/download-every-image-a-web-page-shows-and-keep-a-csv-manifest</guid><category><![CDATA[Python]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[open source]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[fecklesspleb]]></dc:creator><pubDate>Thu, 12 Nov 2026 17:32:23 GMT</pubDate><content:encoded><![CDATA[<p>"Grab the images from this page" sounds like a five-line script until you try it on a real article. The markup mixes <code>img[src]</code>, <code>srcset</code>, lazy-load attributes, root-relative paths, and the occasional PDF. Half the "images" are tracking pixels. One hero URL returns a login wall with <code>Content-Type: text/html</code>. 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.</p>
<p>We maintain a small Python tool aimed at that job: <a href="https://github.com/fecklesspleb/page-image-downloader">page-image-downloader</a> (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.</p>
<h2>Page images are not the same problem as a CSV</h2>
<p>If someone already handed you a spreadsheet of direct image URLs, use a list downloader. We publish one of those too: <a href="https://github.com/fecklesspleb/bulk-image-downloader">bulk-image-downloader</a>. A <strong>page</strong> is different: you must discover candidates from HTML first, resolve them against the page URL, then download.</p>
<p>What page-image-downloader collects from HTML (at commit <code>263a6f6</code>):</p>
<ul>
<li><code>img</code> via lazy-load attrs (<code>data-src</code>, <code>data-lazy-src</code>, <code>data-original</code>) before <code>src</code></li>
<li>largest <code>srcset</code> / <code>data-srcset</code> / <code>data-lazy-srcset</code> candidate (highest <code>w</code>, else highest <code>x</code>)</li>
<li>each <code>&lt;picture&gt;&lt;source srcset="..."&gt;</code> largest candidate</li>
<li><code>og:image</code> / <code>twitter:image</code> (and a few related meta keys)</li>
<li>optional linked files with <code>--links pdf</code> (or other extensions)</li>
</ul>
<p>Relative URLs are resolved against <code>&lt;base href&gt;</code> and the page URL. The same absolute URL is fetched once per page. Whatever you collect, write a <strong>manifest</strong> with one row per candidate. Status beats vibes.</p>
<h2>Minimal local run</h2>
<p>Verified CLI at commit <code>263a6f6</code> (<code>python3 page_image_downloader.py --help</code>): URLs are <strong>positional</strong> (there is no <code>--url</code>). Output directory is <code>-o</code> / <code>--out</code> (default <code>images</code>).</p>
<pre><code class="language-bash">git clone https://github.com/fecklesspleb/page-image-downloader
cd page-image-downloader
python3 page_image_downloader.py \
  "https://example.com/" \
  -o page-images
</code></pre>
<p>Or pass a text file of URLs with <code>-i</code> / <code>--input</code> (one URL per line, <code>#</code> comments allowed).</p>
<p>What that run writes:</p>
<ul>
<li>Folder <code>page-images/&lt;page-host&gt;/</code> with files named <code>&lt;stem&gt;-&lt;first 10 hex of sha256&gt;.&lt;ext&gt;</code> (extension from sniffed bytes, not the URL)</li>
<li><code>page-images/manifest.csv</code> (always)</li>
<li>A <strong>JSON summary printed to stdout</strong> (not a separate <code>.json</code> file). Progress lines go to stderr (<code>-q</code> hides them).</li>
</ul>
<p><code>manifest.csv</code> columns (exact header):</p>
<p><code>page_url</code>, <code>image_url</code>, <code>final_url</code>, <code>found_in</code>, <code>alt</code>, <code>status</code>, <code>file</code>, <code>content_type</code>, <code>bytes</code>, <code>width</code>, <code>height</code>, <code>sha256</code>, <code>error</code></p>
<p><code>status</code> values the tool writes: <code>saved</code>, <code>duplicate</code>, <code>too_small</code>, <code>too_narrow</code>, <code>too_large</code>, <code>not_an_image</code>, <code>html_instead_of_file</code>, <code>robots_disallowed</code>, <code>error</code>.</p>
<p>Exit code is <code>1</code> only when nothing was saved and at least one request failed.</p>
<h2>Pattern: resolve, fetch, sniff, record</h2>
<p>Even if you write your own script, keep this shape. The snippets below are illustrative and use only the standard library (Python 3.9+; <code>Optional</code> keeps the annotation 3.9-safe).</p>
<pre><code class="language-python">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) -&gt; bytes:
    req = Request(url, headers={"User-Agent": UA})
    with urlopen(req, timeout=timeout) as resp:
        return resp.read()

def looks_like_image(data: bytes) -&gt; 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"&lt;svg") or b"&lt;svg" in head:
        return "svg"
    return None

def save_unique(out_dir: Path, url: str, data: bytes) -&gt; 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)
</code></pre>
<p>Running only the resolve lines prints:</p>
<pre><code class="language-text">https://example.com/static/hero.jpg
</code></pre>
<p>Sniffing the first bytes matters more than trusting <code>Content-Type</code> or a <code>.jpg</code> suffix. Soft 404 pages and interstitial HTML with <code>Content-Type: text/html</code> fail the sniff and should be recorded as <code>not_an_image</code>, 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.)</p>
<h2>Manifest columns worth keeping</h2>
<p>At minimum — and these are the columns the tool actually writes:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td><code>page_url</code></td>
<td>The page (or direct URL) you asked for</td>
</tr>
<tr>
<td><code>image_url</code> / <code>final_url</code></td>
<td>Candidate URL, and where it ended after redirects</td>
</tr>
<tr>
<td><code>found_in</code></td>
<td><code>img</code>, <code>img srcset</code>, <code>picture source</code>, <code>og:image</code>, <code>twitter:image</code>, <code>a href</code>, or <code>direct</code></td>
</tr>
<tr>
<td><code>status</code></td>
<td>saved / duplicate / not_an_image / error / …</td>
</tr>
<tr>
<td><code>file</code></td>
<td>Relative path on disk (for <code>duplicate</code>, the first copy)</td>
</tr>
<tr>
<td><code>bytes</code></td>
<td>Spot empty or huge surprises</td>
</tr>
<tr>
<td><code>width</code> / <code>height</code></td>
<td>From the file header when readable</td>
</tr>
<tr>
<td><code>sha256</code></td>
<td>Dedupe across hosts</td>
</tr>
<tr>
<td><code>error</code></td>
<td>HTTP/timeout detail on <code>error</code> rows</td>
</tr>
</tbody></table>
<p>With that table you can answer "how many assets did we actually get?" without opening the folder.</p>
<h2>Politeness and rights</h2>
<ul>
<li>Send a User-Agent that names the project and points at the repo.</li>
<li>Cap concurrency; the tool defaults to a 0.5 s per-host delay (<code>--delay</code>).</li>
<li>It checks <code>robots.txt</code> unless you pass <code>--ignore-robots</code> (only for sites you run).</li>
<li>Only download pages and assets you have a right to use. "It was on a public page" is not automatically a license to redistribute.</li>
</ul>
<h2>Free repo first</h2>
<p>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.</p>
<p>Try it: <a href="https://github.com/fecklesspleb/page-image-downloader">fecklesspleb/page-image-downloader</a>. For CSV/text URL lists instead of HTML discovery, use <a href="https://github.com/fecklesspleb/bulk-image-downloader">bulk-image-downloader</a>.</p>
<h2>Disclosure</h2>
<p>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.</p>
]]></content:encoded></item></channel></rss>