# Website Image Extractor — Alt Text, Size & SEO Audit (`far003/website-image-extractor`) Actor

List every image on a web page or site with its real pixel size and file size, alt text and SEO/accessibility flags. Reads only the first bytes of each image: nothing is downloaded or stored. Pay per image, blocked pages are free.

- **URL**: https://apify.com/far003/website-image-extractor.md
- **Developed by:** [Francesco Antonio Russo](https://apify.com/far003) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.80 / 1,000 image reads

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Website Image Extractor — Alt Text, Size & SEO Audit

Give it a web page (or a whole site) and get **one record per image**: the real pixel size and file size, the alt text, where the image sits on the page, and a list of SEO, accessibility and performance problems. It reads only the **first bytes of each image** to learn its size: **no image is downloaded, stored or returned**, so there is nothing to unzip, nothing to pay storage for, and no copy of anyone's pictures.

Use it to audit alt text before a launch, find the 3 MB hero image that slows a page, list every image of a competitor's product page with its real dimensions, feed an image inventory into a spreadsheet or a CMS migration, or check that a site declares `width`/`height` (layout shift) on its images.

- **Plain HTTP, no browser.** Fast, cheap, 256 MB of memory. The flip side: images that a page only draws with JavaScript are not seen (see *Limits*).
- **Pay per image read: $0.0008 per image** ($0.80 per 1,000). Pages that block us, images that cannot be read and pages that need JavaScript are **free**.
- **Polite and honest.** It identifies itself as `ImageMetadataBot`, obeys `robots.txt` (for the page *and* for the host of every image), spaces its requests and never tries to get past a bot wall.

### What you get

| Field | Meaning |
|---|---|
| `pageUrl`, `imageUrl`, `finalUrl` | The page, the image as the page declares it (absolute URL), and the image URL after redirects |
| `source` | `img` · `picture` · `srcset` (no `src`, the largest candidate) · `lazy-attr:data-src` (also `data-lazy-src`, `data-original`, `data-lazy`, `data-url`) · `css-background` · `og:image` · `twitter:image` |
| `alt` | The alt text as written. **`null` = no alt attribute at all, `""` = a decorative image** (the difference matters for accessibility) |
| `declaredWidth`, `declaredHeight` | The `width` / `height` attributes of the tag, in pixels |
| `naturalWidth`, `naturalHeight` | The **real** size, read from the file header (JPEG: EXIF rotation applied, as browsers do) |
| `format`, `contentType` | Format from the file's magic bytes (jpeg, png, gif, webp, avif, heic, svg, ico, bmp) and the type the server sent |
| `fileSizeBytes`, `sizeSource` | File size, and how it was learned: `range` (Content-Range) · `content-length` · `body` (a short body counted) · `unknown` |
| `srcset` | Every `srcset` and `<picture><source>` candidate with its descriptor (`800w`, `2x`) and MIME type |
| `loading`, `fetchpriority`, `region`, `domIndex`, `occurrences` | `loading` and `fetchpriority` attributes; nearest `header`/`nav`/`main`/`aside`/`footer`; position on the page; how many times the same URL appears on it |
| `status` | `ok` (read) · `broken` (404/410) · `unmeasured` (could not be read: free) |
| `unmeasuredReason`, `httpStatus` | Why an image was not measured (`robots_txt`, `blocked`, `rate_limited`, `timeout`, `http_error`, `private_address`, `no_dimensions`…) and the HTTP status |
| `issues` | The flags below |
| `charged`, `checkedAt` | Whether this record is a billed event; when it was checked |

#### Example record (real run, `https://wordpress.org/news/`)

```json
{
  "pageUrl": "https://wordpress.org/news/",
  "imageUrl": "https://wordpress.org/news/files/2024/01/sunita-rai-featured-img.jpg",
  "finalUrl": "https://wordpress.org/news/files/2024/01/sunita-rai-featured-img.jpg",
  "source": "img",
  "alt": "Sunita Rai at WordCamp Asia",
  "title": null,
  "declaredWidth": 1920,
  "declaredHeight": 1200,
  "naturalWidth": 1920,
  "naturalHeight": 1200,
  "format": "jpeg",
  "contentType": "image/jpeg",
  "fileSizeBytes": 1457137,
  "sizeSource": "range",
  "srcset": [
    { "url": "https://i0.wp.com/wordpress.org/news/files/2024/01/sunita-rai-featured-img.jpg?w=1920&ssl=1", "descriptor": "1920w" },
    { "url": "https://i0.wp.com/wordpress.org/news/files/2024/01/sunita-rai-featured-img.jpg?resize=300%2C188&ssl=1", "descriptor": "300w" }
  ],
  "loading": null,
  "fetchpriority": "high",
  "region": "main",
  "domIndex": 0,
  "occurrences": 1,
  "status": "ok",
  "unmeasuredReason": null,
  "httpStatus": 206,
  "issues": ["TOO_HEAVY", "LEGACY_FORMAT"],
  "charged": true,
  "checkedAt": "2026-10-15T10:37:41.796Z"
}
```

(The real record lists five `srcset` candidates; two are shown. 1.4 MB is the real size of that file: the check read 32 KB of it.)

#### The flags in `issues`

| Flag | When it is raised | Why you care |
|---|---|---|
| `ALT_MISSING` | An `<img>`-based image with no `alt` attribute, not marked decorative (`role="presentation"`, `aria-hidden="true"`) | Accessibility and image SEO |
| `ALT_EMPTY` | `alt=""` | Correct for decorative images; listed so you can check each one really is decorative |
| `ALT_TOO_LONG` | Alt text over 125 characters | Screen readers cut or skip it |
| `ALT_IS_FILENAME` | Alt is a file name: with an extension (`hero.jpg`), a camera name (`IMG_1234`, `DSC0042`) or a technical name with digits, hyphens or underscores (`hero-banner-2`). A plain word that is also a fair description ("Atalanta" for `atalanta.png`, "PayPal" for `paypal.svg`) is not flagged | It describes nothing |
| `ALT_EMPTY_IN_LINK` | `alt=""` on an image that is the only content of a link | The link has no accessible name |
| `TOO_HEAVY` | File larger than `heavyThresholdKb` (default 300 KB) | Page weight |
| `DIMENSIONS_NOT_DECLARED` | No `width` **and** `height` (nor inline `width`+`height` or `aspect-ratio`) | Layout shift (CLS) |
| `DECLARED_MISMATCH` | (not for SVG) The declared width/height ratio differs from the real one by more than 5 % | Distorted or shifting image (unless a stylesheet crops it on purpose, e.g. `object-fit: cover`: stylesheets are not read) |
| `OVERSIZED_FOR_DISPLAY` | (not for SVG) Real width more than 2x the declared width (declared sides under 10 px, like `width="3" height="2"`, are read as an aspect ratio, not a size) | Bytes the visitor never sees |
| `LEGACY_FORMAT` | JPEG or PNG over 100 KB, with no WebP/AVIF alternative in the page's `srcset` or `<picture>` | Smaller formats are available |
| `BROKEN_IMAGE` | The image answers 404 or 410 | Broken image on the page |
| `MIXED_CONTENT` | An `http://` image loaded by an `https://` page | Browsers block or warn |
| `NOT_AN_IMAGE` | The URL answers 200 but the bytes are not an image (usually an HTML error page) | A "soft 404" |

Size flags (`TOO_HEAVY`, `DECLARED_MISMATCH`, `OVERSIZED_FOR_DISPLAY`, `LEGACY_FORMAT`) need a measured image (an SVG is drawn at any size, so it has no `DECLARED_MISMATCH` or `OVERSIZED_FOR_DISPLAY`); alt and dimension flags work from the HTML alone. CSS backgrounds and `og:image`/`twitter:image` get size flags but no alt flags (they have no alt attribute to check).

#### Run summary

Next to the dataset, the key-value store holds a `SUMMARY` record (free): every page read with its status, the number of images found, delivered and charged, the issues per page, `PAGE_LIKELY_CSR` flags and warnings, plus totals and a count per issue type. Use it to see at a glance *why* a page gave nothing.

### Input

| Field | Default | What it does |
|---|---|---|
| `startUrls` | Wikipedia "Lighthouse" | Pages to read. A bare domain (`example.com`) gets `https://` |
| `crawlSameDomain` | off | Also read pages linked from yours, on the same host (www. and the bare domain count as one). At most 30 new links per page. Never leaves the site |
| `maxCrawlDepth` | 1 | Link levels to follow (1-3) |
| `maxPages` | 20 | Pages read in total, start pages included (1-1,000). The start pages you list are always read first (in your order, up to `maxPages`; any beyond it are listed in `SUMMARY.pages` as `stopped`, with a warning). The pages the crawl finds share what is left: sites take turns, equal parts, and a part a site does not use goes to the others. Every page left out for lack of allowance is counted in `SUMMARY.warnings` |
| `maxImagesPerPage` | 200 | Images processed per page, in document order (1-500). The rest are neither read nor charged |
| `measureImages` | on | Read each image's header. Off = HTML facts only (alt, declared size, source) and no size flags |
| `includeCssBackgrounds` | on | `background` / `background-image` in `style` attributes and `<style>` blocks |
| `includeMetaImages` | on | `og:image` and `twitter:image`. A tag whose value is not a URL (`content="https:"`) is skipped with a page warning, free |
| `heavyThresholdKb` | 300 | `TOO_HEAVY` threshold (1 KB = 1,024 bytes) |
| `minSidePx` | 0 | Skip icons and tracking pixels: images whose longest side is below this are not delivered and not charged |
| `dedupeAcrossPages` | on | An image used on several pages (logo, icons) is delivered once, with the first page that uses it; the later pages say how many images they left out (`imagesFound` still counts them) |

Repeats of one URL on the same page always collapse into one record (`occurrences` says how many; the alt/dimension problems of each occurrence are kept).

### What you pay, and what you don't

**$0.0008 per image record that was read** (event `image`): **$0.80 per 1,000 images**. A record is billed when its `status` is `ok` or `broken`.

| Run | Images billed | Cost |
|---|---|---|
| One article page (Wikipedia "Lighthouse", real run) | 39 | $0.031 |
| A shop product page (Allbirds, real run) | 34 | $0.027 |
| 5 pages of a site, crawling (wordpress.org, real run) | 76 | $0.061 |
| 1,000 images | 1,000 | $0.80 |
| 50 pages x 40 images | 2,000 | $1.60 |

**Free, always:**

- a page that answers 401 / 403 / 429 or shows a challenge page ("blocked"), 404/410, a server error, a timeout, a file that is not HTML;
- a page that `robots.txt` forbids;
- a page that only draws its pictures with JavaScript (`PAGE_LIKELY_CSR`): there is nothing in the HTML to report, so nothing is billed;
- an image that could not be measured (`status: "unmeasured"`): forbidden by `robots.txt`, hot-link protected (403), rate limited (429), server error, timeout, unreachable host, or an image whose pixel size could not be read (`no_dimensions`: an unusual format, an SVG with no size, a header the Actor could not reach). Such an image is still delivered, with the alt text and every HTML-based flag, at no charge. (A measured image is never billed without its dimensions; the one exception is a `broken` image, a 404/410, which is billed as a verified finding and has no dimensions to give);
- images skipped by `minSidePx`, repeats removed by deduplication, images beyond `maxImagesPerPage`, the `SUMMARY` record.

**Your spending limit is exact.** If you set a maximum cost per run, the Actor checks the budget before every record and stops when it is spent. It never charges past it; `SUMMARY.stopReason` says `spending limit reached` and `heldByLimit` counts what was found but not delivered. `maxPages` x `maxImagesPerPage` is the other natural cap.

### FAQ

**Does it download the images?** No. For each image it asks the server for the first 32 KB (`Range: bytes=0-32767`) and reads the size from the header. If the server ignores `Range` it reads at most 64 KB (256 KB for a JPEG whose header sits behind a large EXIF block, or an AVIF/HEIC whose size box comes late) and hangs up. The bytes are examined in memory and thrown away; no file is stored or returned.

**Can I use the images I find?** The Actor reports facts about images. **You** are responsible for the right to use any image it lists: most images are protected by copyright and licence terms, whatever the technical possibility of fetching them.

**Why is `alt` sometimes `null` and sometimes `""`?** `null` means the tag has no `alt` attribute (a problem for screen readers and SEO); `""` means the author deliberately marked the image as decorative (usually correct). Images from CSS backgrounds and meta tags have no alt attribute at all and are `null` by nature.

**Which image does a `srcset` record show?** The one in `src`. With no `src`, the largest `srcset` candidate. All candidates, including `<picture>` sources, are listed in `srcset`.

**A page returned nothing. Why?** Open `SUMMARY`: each page has a `status` and a `reason`. `blocked` = the site answered 401/403/429 or a challenge (this Actor does not try to get past bot protection: no proxies, no CAPTCHA solving, one honest User-Agent). `robots_disallowed` = the page's `robots.txt` forbids the bot. `PAGE_LIKELY_CSR` = the HTML has no images and looks like a JavaScript app. All of these are free.

**Why is a crawl slow on some sites?** If a host's `robots.txt` has a `Crawl-delay`, requests to that host are spaced accordingly (up to 10 seconds). Image CDNs that ask for 3 seconds make 40 images take two minutes. That is the polite behaviour; it costs you almost nothing in compute.

**Why did `og:image` show a different size than I expected?** The record reports the real file, not the `og:image:width` the page claims.

**Does it follow links to other sites?** No. With `crawlSameDomain` it stays on the host of each start URL (www. and the bare domain are one host). Subdomains are different hosts.

**Can it read internal addresses?** No. Only `http`/`https` on ports 80 and 443; private, loopback, link-local and cloud-metadata addresses are refused, including after redirects and for image URLs.

### Limits, stated plainly

- **JavaScript-drawn images are not seen.** Plain HTTP reads the HTML the server sends. On four of five sites we compared, a real browser added only 0-13 % more images, mostly trackers; on a single-page app the HTML may have none. Such pages are flagged `PAGE_LIKELY_CSR`, free.
- Content inside `<noscript>` is ignored (it is a fallback for images already present, and often a tracking pixel). External stylesheets and scripts are not fetched, so CSS backgrounds are only those in `style` attributes and `<style>` blocks of the page.
- HTML is read up to 5 MB per page. At most 5 redirects are followed.
- A page is read once per run. Pages behind a login, or that need cookies, are not supported.
- The first measurement of an image is what is reported; CDNs that choose a format by browser (WebP/AVIF) are asked with a browser-like `Accept` header, so you see what a modern browser receives.
- Concurrency is 4 requests per host and 10 in total.

### Run it

Locally: put your input in `<storage>/key_value_stores/default/INPUT.json`, then `CRAWLEE_STORAGE_DIR=$PWD/<storage> node src/main.js`. Tests: `npm test`.

Fetch only the images with problems through the dataset API, for example the heavy ones with no alt text:

```
.../datasets/<id>/items?format=csv&fields=pageUrl,imageUrl,naturalWidth,naturalHeight,fileSizeBytes,alt,issues
```

and filter on the `issues` column in your spreadsheet, n8n or Make flow.

# Actor input Schema

## `startUrls` (type: `array`):

The pages to read. A bare domain (example.com) gets https://. Each page is read once; every image found on it becomes one record.

## `crawlSameDomain` (type: `boolean`):

Also read pages linked from your start pages, on the same host (www. and the bare domain count as one). Never leaves the site.

## `maxCrawlDepth` (type: `integer`):

How many link levels to follow from a start page (1 = the pages it links to). Only used when following links is on. At most 30 new links are taken from each page.

## `maxPages` (type: `integer`):

Pages read in total, start pages included. The start pages you list are read first (in your order); pages found by following links share what is left, sites taking turns. Together with the limit per page this is your cost cap: images × price. Your own 'maximum cost per run' also stops the run exactly.

## `maxImagesPerPage` (type: `integer`):

Images processed per page, in document order, after removing repeats. Images beyond it are not read and not charged.

## `measureImages` (type: `boolean`):

Read the first bytes of every image to get its real pixel size, format and file size (no download). Turn off to get only what the HTML says (alt, declared size, source); the size checks are then skipped.

## `includeCssBackgrounds` (type: `boolean`):

Images set with background / background-image in style attributes and <style> blocks of the page. External stylesheets are not read.

## `includeMetaImages` (type: `boolean`):

The share-preview images declared in the page's meta tags.

## `heavyThresholdKb` (type: `integer`):

Files bigger than this get the TOO_HEAVY flag (1 KB = 1024 bytes).

## `minSidePx` (type: `integer`):

Drop icons and tracking pixels: images whose longest side is under this many pixels are not delivered and not charged. 0 keeps everything.

## `dedupeAcrossPages` (type: `boolean`):

An image used on several pages (logo, icons) is delivered once, with the first page that uses it. Turn off to get one record per page.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://en.wikipedia.org/wiki/Lighthouse"
    }
  ],
  "crawlSameDomain": false,
  "maxCrawlDepth": 1,
  "maxPages": 20,
  "maxImagesPerPage": 200,
  "measureImages": true,
  "includeCssBackgrounds": true,
  "includeMetaImages": true,
  "heavyThresholdKb": 300,
  "minSidePx": 0,
  "dedupeAcrossPages": true
}
```

# Actor output Schema

## `images` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://en.wikipedia.org/wiki/Lighthouse"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("far003/website-image-extractor").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "startUrls": [{ "url": "https://en.wikipedia.org/wiki/Lighthouse" }] }

# Run the Actor and wait for it to finish
run = client.actor("far003/website-image-extractor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "startUrls": [
    {
      "url": "https://en.wikipedia.org/wiki/Lighthouse"
    }
  ]
}' |
apify call far003/website-image-extractor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,far003/website-image-extractor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/YSuZ3MWrUx7bEEZ9b/builds/xPRbwFA8BKPbCm3Jc/openapi.json
