scalpel@labs: ~/glossary/image-source-discovery.mdx5 sections

How to Find Every Image on a Web Page

Images reach a page through several channels, not just the <img> tag. A thorough extractor also reads srcset and <picture> candidates, lazy-load attributes like data-src, CSS background-image, same-origin <canvas>, preload hints and the og:image meta tag.

extension: Scalpel Imagesupdated: 2026-08-14read_time: 4 min
less image-source-discovery.mdx

Why it matters

A simple right-click "save image" or a basic <img> tag scraper misses most of a modern page. Product photos are often background-image styles. Heroes are lazy-loaded in data-src attributes. The highest-resolution version lives in a srcset candidate the browser never picked for your screen. Open a page with a bulk downloader that only reads <img src> and you'll find 5 images when there are really 35.

To collect everything, you need to read eight channels. Each one is a different extraction problem, but together they catch nearly every image a page loads, or would load if the viewer scrolled or resized their window.

How it works

The eight channels:

1. Plain <img src>: The DOM's simplest case. Read every <img> element and grab its src attribute.

2. Responsive images via srcset: A single <img> can list several candidate files in its srcset attribute. <img srcset="small.jpg 400w, large.jpg 1200w" src="medium.jpg" /> offers three choices. The browser picks one based on viewport width and screen density; an extractor should keep the largest.

3. The <picture> element: An alternative to srcset that wraps multiple <source> elements, each with its own srcset. Used for art direction: different crops for different screen sizes. Walk the tree, extract every <source> tag, and parse its srcset.

4. Lazy-loaded data-src attributes: Modern sites defer image loading to speed up the initial page render. The real URL sits in data-src, data-lazy-src, or custom attributes, not in src. <img src="placeholder.jpg" data-src="real-photo.jpg" /> looks like it's only one small image until you look closer. You need to sweep the DOM for these attributes, both standard ones and custom data attributes.

5. CSS background-image: Painted backgrounds aren't in the DOM at all. <div style="background-image: url('/hero.jpg')"> has no <img> tag. The only way to find it is to walk every element, call getComputedStyle(), and parse the background-image property. This is expensive (a 1000-element page with heavy styling can take a moment), but it's the only reliable method. You have to be careful about performance, so cap the scan to avoid crawling the entire render tree.

6. Same-origin <canvas> elements: A canvas draws pixels with JavaScript instead of loading a file. There's no URL to copy. But you can export it: call canvas.toDataURL() and you get a PNG-encoded data URI. Cross-origin canvases become "tainted" and throw a SecurityError, so you skip those. A 1×1 pixel canvas is often tracking code, not a user-visible image, so filter by minimum size.

7. <link rel="preload"> and <link rel="prefetch">: Pages sometimes hint at images they'll load later. <link rel="preload" as="image" href="/coming-up.jpg" /> tells the browser to start fetching early. Extractors that miss these leave out images the page will definitely load.

8. Open Graph meta tags: The <meta property="og:image" content="..."> tag specifies the image shown when a page is shared on social media. It's often a high-quality version that doesn't appear anywhere else on the page. Also check <meta name="twitter:image"> for Twitter-specific images.

Shadow DOM: Some frameworks render content inside a shadow root, invisible to normal DOM queries. If the page uses Shadow DOM, you have to walk into it explicitly. Call element.shadowRoot and repeat the scan inside.

What does not matter

Tiny tracking pixels, 1×1 GIFs used for analytics, are technically images but usually not what you want to save. Filter by minimum dimensions (say, 16×16) and you'll catch most of them.

Broken images (a src pointing to a URL that returns 404) still appear in the DOM and will be extracted. You can't know they're broken without trying to load them, and that's not the extractor's job. Leave broken URLs in the list so the user can see what failed.

Images inside iframes are usually unreachable without permission from the iframe's origin. Skip iframe content unless you're crawling your own site.

Format detection from URL extensions is fragile. A URL like /image?size=large has no extension. A better approach: ask for the HTTP Content-Type header, or try to load it and see what the browser reports. For extractors that can't make requests, storing "unknown" is honest.

Code example

Here's a minimal extractor that hits four major channels:

async function extractAllImages() {
  const images = new Set();

  // 1. Plain <img> tags
  document.querySelectorAll('img').forEach(img => {
    if (img.src) images.add(img.src);
    // Also grab srcset candidates
    if (img.srcset) {
      img.srcset.split(',').forEach(candidate => {
        const url = candidate.trim().split(/\s+/)[0];
        if (url) images.add(url);
      });
    }
  });

  // 2. <picture> elements
  document.querySelectorAll('picture source').forEach(source => {
    if (source.srcset) {
      source.srcset.split(',').forEach(candidate => {
        const url = candidate.trim().split(/\s+/)[0];
        if (url) images.add(url);
      });
    }
  });

  // 3. CSS background-image (expensive; cap the scan)
  document.querySelectorAll('*').forEach((el, i) => {
    if (i > 5000) return; // Cap at 5000 elements
    const bgImage = getComputedStyle(el).backgroundImage;
    const match = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
    if (match && match[1]) images.add(match[1]);
  });

  // 4. Lazy-load data-src
  document.querySelectorAll('[data-src], [data-lazy-src]').forEach(el => {
    const lazySrc = el.dataset.src || el.dataset.lazySrc;
    if (lazySrc) images.add(lazySrc);
  });

  // 5. Open Graph
  const ogImage = document.querySelector('meta[property="og:image"]');
  const twitterImage = document.querySelector('meta[name="twitter:image"]');
  if (ogImage) images.add(ogImage.content);
  if (twitterImage) images.add(twitterImage.content);

  // 6. Canvas (same-origin only)
  document.querySelectorAll('canvas').forEach(canvas => {
    if (canvas.width > 16 && canvas.height > 16) {
      try {
        images.add(canvas.toDataURL());
      } catch (e) {
        // Tainted canvas; skip
      }
    }
  });

  return Array.from(images);
}

const allImages = await extractAllImages();
console.log(`Found ${allImages.length} images`);

This is intentionally kept simple. A production extractor would handle more edge cases: URL normalisation, fragment identifiers, relative-to-absolute URL conversion, and performance tuning for huge pages.

How Scalpel shows it

The source filter chips at the top (img, background, canvas, data, other) let you toggle each channel on and off. Each tile shows a source badge: IMG for an <img> tag, BG for background-image, CNV for canvas, and so on. If you only want the big hero images (probably backgrounds or srcset), uncheck img and data. If you want to find every piece of branding (usually SVGs), filter by type and source together.

The extension scans all eight channels automatically, so nothing gets missed. The UI just lets you see which channel found each image and filter by it. Lazy-loaded images that haven't rendered yet show with a "probed" badge while the extractor loads them in the background to measure their size.

Sources