By the end of this tutorial, you will have a short Python script that audits a list of web pages with axe-core and returns one row per failing element: the rule it broke, how serious it is, its CSS selector, its HTML and the advice for fixing it.
axe-core is Deque's open-source accessibility testing engine. Running it yourself means driving a real browser, injecting the script, and flattening its nested results into rows.
In this guide, we use Python and the Apify Actor axe-core Accessibility Audit Scraper (reventadata/axe-scraper). It is unofficial: it bundles axe-core 4.10.2 (MPL-2.0) and is not affiliated with Deque Systems. You will configure an audit, run it and read the rows. The sample row below is a sample from a capture dated 2026-10-12, parsed with the Actor's own code.
⏱️ Time estimate: one sitting: install one package, set one token, run three short Python blocks.
Prerequisites
To follow this tutorial, you will need:
- Python 3.11+ (
apify-client3.3.0 requires it). - The Apify API client for Python.
- An Apify account and its API token, which sits on the API & Integrations page in Apify Console.
Install the client:
pip install "apify-client~=3.0" The code reads the token from an environment variable:
export APIFY_API_TOKEN="your_apify_token_here" What you'll achieve
You will turn a list of URLs into an accessibility report with one row per violating element. Each row carries url, ruleId (for example image-alt or color-contrast), impact (minor, moderate, serious or critical), help, description, helpUrl (a Deque University page for the rule), tags, target (CSS selectors), html (the element's markup) and failureSummary.
Note: Automated checks are a first pass, not a verdict. axe-core's own README says it finds "on average 57% of WCAG issues automatically". This Actor exports axe's violations only; the items axe marks "incomplete" (needs manual review) are not in the dataset.
Example use case: Suppose you want every critical and serious issue on your sign-up and checkout pages before a release. You would list both URLs in urls, set minImpact: "serious", and hand the rows to your issue tracker.
Step 1: Access axe-core Accessibility Audit Scraper
Go to the axe-core Accessibility Audit Scraper page on Apify Store. Its README lists every input and output field. In this tutorial we drive the Actor from Python, so everything after this step happens in code.
Step 2: Configure the pages and rules
The main input is urls: the pages to audit. Each must start with http:// or https:// and name a host; a URL that does not stops the run with an error at its start. Duplicates are removed, and pages are audited one after another in the order given.
wcagTags picks which axe rules run, by tag. It defaults to ["wcag2a", "wcag2aa"]. Per axe-core's API docs:
-
wcag2a,wcag2aa: WCAG 2.0 Level A and AA. -
wcag21a,wcag21aa: WCAG 2.1 Level A and AA. -
wcag22aa: WCAG 2.2 Level AA. -
best-practice: common accessibility best practices. -
section508: old Section 508 rules.
Here is the setup. It audits one page against WCAG 2.0 A and AA rules and saves at most 50 rows:
import os from apify_client import ApifyClient # 1. Initialize ApifyClient with the token from the environment client = ApifyClient(os.environ["APIFY_API_TOKEN"]) # 2. Define the audit: one page, WCAG 2.0 A and AA rules, at most 50 rows run_input = { "urls": ["https://example.com"], "wcagTags": ["wcag2a", "wcag2aa"], "minImpact": "minor", "maxItems": 50, } This is the Actor's prefill input. Swap in your own pages.
💡 Tip: use the lowercase tags above. The Actor trims and lowercases every tag before passing it to axe.
Step 3: Add optional settings
| Parameter | Type | What it does |
|---|---|---|
minImpact | string | Keeps rows at or above this severity: minor (all, the default), moderate, serious or critical. |
maxItems | integer | Caps the rows saved across all URLs (default 100). |
browserAuth | string | Optional Bright Data Scraping Browser credentials, brd-customer-ID-zone-NAME:PASSWORD. A secret field. |
Three configurations to try:
- Release gate:
minImpact: "serious"on your key templates. - WCAG 2.1 AA sweep:
wcagTags: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]. - Best-practice review:
wcagTags: ["best-practice"],minImpact: "moderate".
⚠️ A
minImpactoutside the four values is refused before the run starts.browserAuthis a secret input: Apify's docs say such values get encrypted when the input is saved.
Step 4: Run the Actor
Now start the run with client.actor("reventadata/axe-scraper").call(run_input=run_input). The call starts the Actor on Apify and waits for it to finish before returning the run:
# 3. Start the Actor run and wait for it to finish print("Starting accessibility audit...") run = client.actor("reventadata/axe-scraper").call(run_input=run_input) if run is None or run.status != "SUCCEEDED": status = run.status if run else "unknown" raise SystemExit(f"Run did not succeed (status: {status}).") # 4. Fetch the saved rows items = client.dataset(run.default_dataset_id).list_items().items print(f"Retrieved {len(items)} rows.\n") Behind the scenes:
- The Actor opens a real Chromium browser: Bright Data's Scraping Browser over CDP when Scraping Browser credentials are available, otherwise a headless Chromium it launches itself.
- For each URL it loads the page (45-second limit), waits one more second, injects axe-core 4.10.2 and runs it with your tags.
- It turns each violation into one row per failing element, applies
minImpact, and saves the rows untilmaxItemsis reached.
Step 5: View and download results
Loop over the rows to print what you need:
from collections import Counter for item in items: if item["ruleId"] == "wcag-pass": print(f"{item['url']}: no violations for the selected tags") continue print(f"[{item['impact']}] {item['ruleId']}: {item['help']}") print(f" Element: {item['target']}") print(f" Docs: {item['helpUrl']}") print("-" * 40) print(Counter(item["impact"] for item in items if item["ruleId"] != "wcag-pass")) Here is one row, a sample from a capture dated 2026-10-12 (an axe-core result for one image without alt text), parsed with the Actor's own code. The capture stores no page address, so url is the placeholder the Actor's tests pass. A saved row also carries scrapedAt, the audit time:
{ "url": "https://example.com/test", "ruleId": "image-alt", "impact": "critical", "help": "Images must have alternative text", "description": "Ensure <img> elements have alternative text or a role of none or presentation", "helpUrl": "https://dequeuniversity.com/rules/axe/4.10/image-alt?application=axeAPI", "tags": [ "cat.text-alternatives", "wcag2a", "wcag111", "section508", "section508.22.a", "TTv5", "TT7.a", "TT7.b", "EN-301-549", "EN-9.1.1.1", "ACT" ], "html": "<img src=\"broken.jpg\">", "target": [ "img" ], "failureSummary": "Fix any of the following:\n Element does not have an alt attribute\n aria-label attribute does not exist or is empty\n aria-labelledby attribute does not exist, references elements that do not exist or references elements that are empty\n Element has no title attribute\n Element's default semantics were not overridden with role=\"none\" or role=\"presentation\"" } Apify datasets export as JSON, CSV, XML, Excel, HTML table, RSS or JSONL.
Understanding your results
The fields that matter most:
-
ruleIdandhelp: the axe rule and its one-line title.helpUrlopens Deque's page for that rule. -
impact: axe's severity for this element. When an element carries its own impact, the row uses it; otherwise the rule's. -
target: CSS selectors for the element. Each entry is one frame level, so an element inside one iframe has two entries. -
html: the element's markup, so you can find it in your templates. -
failureSummary: what failed, phrased as a fix. "Fix any of the following" means one of the listed changes is enough. -
tags: the standards the rule maps to.wcag111is WCAG success criterion 1.1.1.
Two special rows: a page with no violations for your tags saves one row with ruleId: "wcag-pass" and impact: null. A rule axe reports without element details saves one row with html: null and an empty target.
Pro tips
- Mind the shared cap.
maxItemscounts rows across all URLs, and once it is reached the remaining URLs are not audited. A page with many violations listed first can use the whole budget, so raisemaxItemsor audit busy pages in their own run. - Filter at the source.
minImpact: "serious"keeps only serious and critical rows, which keeps a release check short. - Pick tags deliberately. The default covers WCAG 2.0 A and AA. Add
wcag21a,wcag21aaorwcag22aafor the newer criteria;best-practiceadds rules beyond WCAG. - Group before you fix. One rule often fails on many elements. Group rows by
ruleIdand fix the shared component once. - Plan manual review. The dataset holds axe's violations only; the "needs review" items stay for a person to check.
- Make it a gate. The status check in Step 4 already stops on a failed run. Add one line that exits non-zero when any row has
impact == "critical", and the audit can block a deploy.
Troubleshooting
- Run stops at once: a URL lacks
http://orhttps://, orurlsis empty. - Zero rows: the status message starts "0 results for ...". FAILED means no page loaded or audited, or at least one page could not be read, so matches may have been missed. SUCCEEDED with an empty dataset means the pages were audited but every row was below
minImpact: lower it. - One page missing from the results: it failed to load and was skipped; the log has an
Audit failed for <url>warning. OrmaxItemswas reached before its turn. - Reading the log:
client.log(run.id).get(). It counts rows dropped byminImpactand near the end logsPushed N item(s) across N attempted URL(s).
For help, use the Actor's Store page.
Pricing and next steps
For current pricing and details, see the Store page.
To repeat an audit, save the input as a task and add an Apify schedule, which runs an Actor or task on a cron expression (0 8 * * * is every day at 8 AM). To react when a run finishes, add a webhook on ACTOR.RUN.SUCCEEDED; its payload carries the run object, including defaultDatasetId.
Which pages are you planning to audit first? Drop a comment! 💬