azcrawlerpy
A framework for navigating and filling multi-step web forms programmatically. Supports Camoufox anti-detect browser (built on Firefox with C++ level fingerprint spoofing) and standard Chromium via Playwright. Uses JSON instruction files to define form navigation workflows, making it ideal for automated form submission, web scraping, and AI agent-driven web interactions.
Table of Contents
- Installation
- Quick Start
- Logging & Telemetry
- Core Concepts
- Instructions Schema
- Data Points (input_data)
- Element Discovery
- AI Agent Guidance
- Retry and Resilience
- Error Handling and Diagnostics
- Browser Profile Building (Profiler)
- Examples
Installation
Requires Python 3.11–3.13.
uv add azcrawlerpy
Or install from source:
uv pip install -e .
Quick Start
FormCrawler requires a logger. In production, pass an AzureLogger from
azpaddypy so crawl telemetry (spans, correlation
IDs, structured records) flows into Application Insights. For local scripts
and notebooks, any stdlib-shaped logger works — anything that implements
debug/info/warning/error/exception/critical.
import asyncio
from azcrawlerpy import CrawlerBrowserConfig, DebugMode, FormCrawler, HumanizeConfig
from azpaddypy.mgmt.logging import AzureLogger, bootstrap_azure_monitor
async def main():
# 1. Wire telemetry once per process (idempotent; no-op without a
# connection string so it's safe in local runs).
bootstrap_azure_monitor(service_name="crawler-demo")
logger = AzureLogger(__name__)
# 2. CrawlerBrowserConfig controls runtime browser settings
# (proxy, stealth, humanize).
browser_config = CrawlerBrowserConfig(humanize=HumanizeConfig(enabled=True))
# 3. FormCrawler now requires a logger -- pass AzureLogger for App Insights
# integration, or a stdlib `logging.getLogger(__name__)` for local work.
crawler = FormCrawler(
headless=True,
browser_config=browser_config,
logger=logger,
)
instructions = {
"url": "https://example.com/form",
"browser_config": {
"browser_type": "camoufox",
"viewport_width": 1920,
"viewport_height": 1080
},
"steps": [
{
"name": "step_1",
"wait_for": "input[name='email']",
"timeout_ms": 15000,
"fields": [
{
"type": "text",
"selector": "input[name='email']",
"data_key": "email"
}
],
"next_action": {
"type": "click",
"selector": "button[type='submit']"
}
}
],
"final_page": {
"wait_for": ".success-message",
"timeout_ms": 60000
}
}
input_data = {
"email": "user@example.com"
}
result = await crawler.crawl(
url=instructions["url"],
input_data=input_data,
instructions=instructions,
debug_mode=DebugMode.ALL,
# Optional wall-clock cap on the entire crawl. Exceeding it raises
# CrawlerTimeoutError with a partial result attached. Omit or set
# to None to disable.
global_timeout_ms=300_000,
)
# The crawler returns every artifact in memory -- the caller owns the
# sink (disk, Azure Blob, stdout, ...). Nothing is written to disk on
# your behalf.
print(f"Final URL: {result.final_url}")
print(f"Steps completed: {result.steps_completed}")
print(f"Screenshots captured: {len(result.screenshots)}")
print(f"HTML bytes: {len(result.html)}")
print(f"Extracted data: {result.extracted_data}")
asyncio.run(main())
Logging & Telemetry
FormCrawler accepts any logger that matches the stdlib logging.Logger
shape (structural LoggerLike protocol in
crawling/utils.py): debug, info,
warning, error, exception, critical.
Production: AzureLogger
Pairing the crawler with AzureLogger from azpaddypy gives you, with no
code in the crawler itself:
- Application Insights export of all crawl log records via the OTel handler that azpaddypy attaches to the root logger; the crawler's module-level loggers propagate to root automatically.
- Trace context (
operation_Id/operation_ParentId) is set by the Azure Monitor exporter from the active OTel span — no per-record duplication. - Built-in OpenTelemetry spans on the crawler's hot paths so the App
Insights Application Map and Performance views show structure inside the
crawl rather than one opaque blob:
FormCrawler.crawl— root span; carriescrawl.url,crawl.headless,crawl.debug_mode,crawl.strict,crawl.steps_planned, plus post-successcrawl.steps_completedandcrawl.profiler_visited_count. On timeout / error it carriesoutcome=timeout|errorwithStatus.ERRORand the captured exception.FormCrawler.attempt— one span per restart-loop iteration (attempt.index,browser.type); restart paths addattempt.outcome=restartandattempt.restart_step.profiler.run— wraps the profiler phase (profiler.visit_count,profiler.in_memory,profiler.browser_type).ElementDiscovery.discover— wraps page element discovery (discovery.url,discovery.browser_type,discovery.explore_iframes, plus post-rundiscovery.total_elementsanddiscovery.iframes_count).
- Correlation IDs if the caller sets one via
AzureLogger.set_correlation_id(...)— it propagates through thecontextvars-backed OTel context into all crawler spans automatically.
from azpaddypy.mgmt.logging import AzureLogger, bootstrap_azure_monitor, trace_function
bootstrap_azure_monitor(service_name="crawler-api", service_version="0.5.1")
logger = AzureLogger(__name__)
# Optional: generate a correlation ID per request + emit a parent span
@trace_function(name="crawl_request")
async def handle_crawl(url: str, input_data: dict, instructions: dict):
crawler = FormCrawler(headless=True, browser_config=None, logger=logger)
return await crawler.crawl(
url=url, input_data=input_data, instructions=instructions,
)
The crawler does not import opentelemetry directly. Spans are produced
through a soft-import shim: when OTel is installed (the typical case in a
function-app that pulls in azpaddypy) spans flow to Application Insights;
when not, the shim is a zero-cost no-op so standalone CLI runs and minimal
test environments keep working unchanged.
Local / scripts: stdlib logger
If you don't need App Insights, a standard library logger is enough:
import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
crawler = FormCrawler(
headless=True,
browser_config=None,
logger=logging.getLogger("my_crawler"),
)
Any logger that satisfies the LoggerLike Protocol is accepted — there is no
hard runtime dependency on azpaddypy.
Core Concepts
The framework operates on two primary inputs:
- Instructions (instructions.json): Defines the form structure, selectors, navigation flow, and field types
- Data Points (input_data): Contains the actual values to fill into form fields
The crawler processes each step sequentially:
- Wait for the step's
wait_forselector to become visible - Fill all fields defined in the step using values from
input_data - Execute the
next_actionto navigate to the next step - Repeat until all steps are complete
- Wait for and capture the final page
Instructions Schema
Top-Level Structure
{
"url": "https://example.com/form",
"browser_config": { ... },
"cookie_consent": { ... },
"steps": [ ... ],
"final_page": { ... },
"post_final_page_steps": [ ... ],
"data_extraction": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | Starting URL for the form |
browser_config |
object | Yes | Browser engine and viewport settings |
cookie_consent |
object | No | Cookie banner handling configuration |
captcha |
object | No | CAPTCHA handling configuration (e.g., Cloudflare Turnstile) |
steps |
array | Yes | Ordered list of form steps |
final_page |
object | Yes | Configuration for the result page |
post_final_page_steps |
array | No | Steps executed on the result page after its wait/settle, before data_extraction (see Post Final Page Steps) |
data_extraction |
object | No | Configuration for extracting data from final page |
profiler |
object | No | Browser profile building configuration (visit sites to accumulate cookies before crawling) |
Browser Configuration
{
"browser_config": {
"browser_type": "camoufox",
"viewport_width": 1920,
"viewport_height": 1080
}
}
| Field | Type | Required | Description |
|---|---|---|---|
browser_type |
string | Yes | Browser engine: camoufox (anti-detect Firefox) or chromium (standard Playwright) |
viewport_width |
integer | Yes | Browser viewport width in pixels |
viewport_height |
integer | Yes | Browser viewport height in pixels |
blocked_url_patterns |
array | No | URL glob patterns to block via page.route() (e.g., **/analytics/**) |
url_rewrites |
object | No | Substring replacements applied to request URLs before they are sent (see URL Rewrites) |
url_rewrite_mode |
string | No | How rewrites are applied: redirect (default) or mirror (see URL Rewrites) |
Both engines render the configured width exactly. The height differs by engine: chromium sets it as the page's inner height, while camoufox sizes the browser window, leaving the page shorter by the height of the browser chrome. Camoufox ignores a Playwright viewport, so this is the only lever it offers.
URL Rewrites
Redirects matching requests to a different host. The main use case is a proxy that cannot reach a third-party host the page depends on, but which has a reachable mirror.
{
"browser_config": {
"browser_type": "chromium",
"viewport_width": 1920,
"viewport_height": 1080,
"url_rewrites": {
"://www.google.com/recaptcha/": "://www.recaptcha.net/recaptcha/"
}
}
}
Each key is matched as a substring of the request URL and replaced with its value. Requests are
routed through page.route(), so the page's own loader keeps working — including any onload
callback it registered on the original script tag.
url_rewrite_mode controls how the replacement is reached:
| Mode | Behaviour | Use when |
|---|---|---|
redirect (default) |
Sends the request to the replacement URL via route.continue_(url=...). The document's origin becomes the replacement host. |
Plain assets, and anything on chromium |
mirror |
Fetches the replacement and serves its content under the original URL via route.fulfill(). The origin is unchanged. |
The resource performs same-origin checks, or the target is firefox/camoufox |
Prefer redirect — it is a single request. Reach for mirror when a rewritten resource loads
same-origin sub-resources: firefox refuses to start a web worker from a different origin than its
document, so a redirected script that spawns one fails with a Security Error and never finishes
initialising. mirror keeps the origin intact and avoids the check entirely.
Notes:
- With
redirectthe replacement must keep the same protocol; cross-origin redirects are allowed. - Rewrites apply for the whole page lifetime, so a rewritten script's own follow-up requests are rewritten too when they match.
- Prefer either mode over an init script that patches URLs in JavaScript. Camoufox runs injected scripts in an isolated world that cannot touch page globals, so a JS-based rewrite silently does nothing there; routing works on both engines and leaves no page-visible trace.
Example — reCAPTCHA behind a proxy whose zone blocks google.com. Google serves reCAPTCHA from
recaptcha.net for exactly this situation, so rewriting the host lets the widget load and render
normally. On camoufox this needs "url_rewrite_mode": "mirror", because the reCAPTCHA frame spawns
a worker from a hardcoded google.com URL. Note this only restores reachability; whether the
challenge then passes still depends on the exit IP's reputation.
Cookie Consent Handling
The framework supports two modes for handling cookie consent banners:
Standard Mode (regular DOM elements):
{
"cookie_consent": {
"banner_selector": "dialog:has-text('cookies')",
"accept_selector": "button:has-text('Accept')"
}
}
Shadow DOM Mode (for Usercentrics, OneTrust, etc.):
{
"cookie_consent": {
"banner_selector": "#usercentrics-cmp-ui",
"shadow_host_selector": "#usercentrics-cmp-ui",
"accept_button_texts": ["Accept All", "Alle akzeptieren"]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
banner_selector |
string | Yes | CSS selector for the banner container |
accept_selector |
string | No | CSS selector for accept button (standard mode) |
shadow_host_selector |
string | No | CSS selector for shadow DOM host |
accept_button_texts |
array | No | Text patterns to match accept buttons in shadow DOM |
banner_settle_delay_ms |
integer | No | Wait time before checking for banner |
banner_visible_timeout_ms |
integer | No | Timeout for banner visibility |
accept_button_timeout_ms |
integer | No | Timeout for accept button visibility |
post_consent_delay_ms |
integer | No | Wait time after handling consent |
js_fallback_texts |
array | No | Custom text patterns for JS fallback button matching (overrides defaults) |
The JS fallback matches button text against: klar, akzept, accept, agree, ok, verstanden, zustimm. Set js_fallback_texts to override this list with site-specific patterns.
Step Definitions
Each step represents a form page or section:
{
"name": "personal_info",
"wait_for": "input[name='firstName']",
"timeout_ms": 15000,
"fields": [ ... ],
"next_action": { ... }
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique identifier for the step |
wait_for |
string | Yes | CSS selector to wait for before processing |
timeout_ms |
integer | Yes | Timeout in milliseconds for wait condition |
fields |
array | Yes | List of field definitions |
next_action |
object | Yes | Action to navigate to next step |
data_extraction |
array | No | Data to extract BEFORE field handling in this step |
post_field_extraction |
array | No | Data to extract AFTER field handling (for modal results, dynamic values) |
page_extraction |
object | No | Full page extraction run at the end of this step, same schema as data_extraction on the instructions (see Per-Step Page Extraction) |
optional |
boolean | No | Skip step gracefully if wait_for selector is not found (see Optional Steps) |
restart_crawl |
boolean | No | Trigger a full browser restart from the start URL when wait_for times out (see Restart Crawl). Requires max_restarts. |
max_restarts |
integer | No | Maximum number of full crawl restart attempts when restart_crawl is true (must be >= 1). Each step tracks its own restart budget. |
Restart Crawl
Steps marked with "restart_crawl": true raise an internal CrawlRestartError when the wait_for selector times out. The crawler then closes the browser, recreates the context (reusing any profiler storage state so cookie consent does not re-run), and replays the entire crawl from the start URL. Each step tracks its own restart budget against max_restarts; once exhausted, the original timeout error is raised.
{
"name": "results_page",
"wait_for": "[data-cy='quote-result']",
"timeout_ms": 15000,
"restart_crawl": true,
"max_restarts": 2,
"fields": [],
"next_action": { "type": "delay", "delay_ms": 0 }
}
restart_crawl takes priority over optional and strict. Use it for steps where the page may render broken or never finalize due to flaky SPA/backend behavior, where a fresh browser context is the most reliable recovery. Prefer retry_config on individual fields/actions for transient interaction failures; reserve restart_crawl for full-page load failures.
Optional Steps
Steps marked with "optional": true are skipped gracefully when the wait_for selector is not found within timeout_ms. The entire step (fields, data extraction, next_action) is skipped with an info log. This is useful for conditional workflow pages that only appear depending on prior input values or dynamic form behavior.
{
"name": "additional_driver_details",
"wait_for": "#additional-driver-form",
"timeout_ms": 5000,
"optional": true,
"fields": [ ... ],
"next_action": { "type": "click", "selector": "#next" }
}
The optional check takes precedence over the strict parameter. When a step is optional and the wait_for selector times out, the step is always skipped regardless of strict mode. Non-optional steps follow the existing behavior: raise CrawlerTimeoutError when strict=True, or log a warning and continue into the step when strict=False.
Tip: Use a shorter timeout_ms (e.g., 3000-5000ms) on optional steps to avoid waiting the full timeout when the step is absent.
Field Types
TEXT
For text inputs, email fields, phone numbers, and similar:
{
"type": "text",
"selector": "input[name='email']",
"data_key": "email"
}
TEXTAREA
For multi-line text areas:
{
"type": "textarea",
"selector": "textarea[name='message']",
"data_key": "message"
}
DROPDOWN / SELECT
For native <select> elements:
{
"type": "dropdown",
"selector": "select[name='country']",
"data_key": "country",
"type_config": {
"select_by": "text"
}
}
| type_config Parameter | Values | Description |
|---|---|---|
select_by |
text, value, index |
How to match the option |
option_visible_timeout_ms |
integer | Timeout in ms for option visibility |
RADIO
For radio button groups:
{
"type": "radio",
"selector": "input[type='radio'][value='${value}']",
"data_key": "payment_method"
}
Pattern A - Value-driven selector: Use ${value} placeholder in selector, data provides the value:
{
"type": "radio",
"selector": "input[type='radio'][value='${value}']",
"data_key": "gender"
}
// data: { "gender": "male" }
Pattern B - Boolean flags: Use explicit selectors with boolean data values:
{
"type": "radio",
"selector": "[role='radio']:has-text('Yes')",
"data_key": "accept_terms",
"force_click": true
}
// data: { "accept_terms": true } // clicks if truthy, skips if null
force_click is supported on radio, click_only, and click_select field types. When set, the click fallback chain tries force click before normal click.
CHECKBOX
For checkbox inputs:
{
"type": "checkbox",
"selector": "input[type='checkbox'][name='newsletter']",
"data_key": "subscribe_newsletter"
}
Data value true checks the box, false unchecks it, and null skips the field entirely (no interaction).
DATE
For date inputs with format conversion:
{
"type": "date",
"selector": "input[name='birthdate']",
"data_key": "birthdate",
"type_config": {
"format": "DD.MM.YYYY"
}
}
Supported formats (mapped to strftime internally):
| Format | Example | Description |
|---|---|---|
DD.MM.YYYY |
15.06.1985 | Day.Month.Year |
MM/DD/YYYY |
06/15/1985 | Month/Day/Year |
YYYY-MM-DD |
1985-06-15 | ISO format |
DD/MM/YYYY |
15/06/1985 | Day/Month/Year |
YYYY/MM/DD |
1985/06/15 | Year/Month/Day |
DD-MM-YYYY |
15-06-1985 | Day-Month-Year |
MM-DD-YYYY |
06-15-1985 | Month-Day-Year |
Data must be provided in ISO format (YYYY-MM-DD) in input_data. The type_config.format specifies the output format for typing into the field. Native <input type="date"> fields are auto-detected and use the value as-is, no type_config needed.
SLIDER
For range inputs:
{
"type": "slider",
"selector": "input[type='range'][name='coverage']",
"data_key": "coverage_amount"
}
FILE
For file upload fields:
{
"type": "file",
"selector": "input[type='file']",
"data_key": "document_path"
}
Data value should be the absolute file path.
COMBOBOX
For autocomplete/typeahead inputs:
{
"type": "combobox",
"selector": "input[aria-label='City']",
"data_key": "city",
"press_enter": true,
"type_config": {
"option_selector": ".autocomplete-option",
"type_delay_ms": 50,
"wait_after_type_ms": 500
}
}
| type_config Parameter | Description |
|---|---|
option_selector |
CSS selector for dropdown options (required) |
type_delay_ms |
Delay between keystrokes (simulates human typing) |
wait_after_type_ms |
Wait time for options to appear |
clear_before_type |
Clear field before typing |
option_visible_timeout_ms |
Timeout in ms for option visibility |
Breaking change in 1.1.6:
press_entermoved from the comboboxtype_configto the field itself, where it now also works fortext,date,iframe_fieldandclick_select. Move the key up one level; leaving it insidetype_configfails validation.
CLICK_SELECT
For custom dropdowns requiring click-then-select:
{
"type": "click_select",
"selector": ".custom-dropdown-trigger",
"data_key": "option_value",
"post_click_delay_ms": 300,
"type_config": {
"option_selector": ".dropdown-item:has-text('${value}')"
}
}
CLICK_ONLY
For elements that only need clicking (no data input):
{
"type": "click_only",
"selector": "button.expand-section"
}
With conditional clicking based on data:
{
"type": "click_only",
"selector": "button:has-text('${value}')",
"data_key": "selected_option"
}
IFRAME_FIELD
For fields inside iframes (alternative to iframe_selector):
{
"type": "iframe_field",
"selector": "input[name='card_number']",
"iframe_selector": "iframe#payment-frame",
"data_key": "card_number"
}
Common Field Parameters
| Parameter | Type | Description |
|---|---|---|
data_key |
string | Key in input_data to get value from |
selector |
string | CSS/Playwright selector for the element |
type_config |
object | Type-specific configuration (see field type sections above) |
iframe_selector |
string | Selector for parent iframe if field is embedded |
field_visible_timeout_ms |
integer | Timeout for field to become visible |
post_click_delay_ms |
integer | Wait after clicking the field |
skip_verification |
boolean | Skip value verification after filling |
force_click |
boolean | Use force click to bypass overlays (click_only, radio, and click_select) |
press_enter |
boolean | Press Enter after the interaction to commit the value (text, date, iframe_field, combobox, click_select) |
press_keys |
array of strings | Playwright key names pressed at page level after the field's interaction (any field type) |
optional |
boolean | Skip field gracefully if element is not found or interaction fails |
retry_config |
object | Retry configuration for transient failures (see Retry and Resilience) |
Pressing Keys
press_keys sends keys to the page once the field's own interaction has finished, in the order given. Its main use is dismissing a widget that swallows the next click — a date picker left open by a force_click, or a custom select with no close control:
{
"type": "click_select",
"selector": "#vehicle-fuel",
"data_key": "fuel",
"press_keys": ["Escape"],
"type_config": {
"option_selector": "ul.dropdown-content li"
}
}
The keys go to whatever the page currently focuses, as a real key press would, so the field's own element does not need to still exist. This is what separates it from press_enter, which presses on the field itself to commit a value; the two are independent and can be combined.
An overlay that outlives the last field of a step blocks the step's next_action. Attach the keys to that last field rather than adding a field for them.
Optional Fields
Fields marked with "optional": true are skipped gracefully when the element is not found on the page or when interaction fails. This is useful for elements that may or may not appear depending on dynamic page behavior, A/B tests, or conditional rendering that cannot be predicted by data alone.
{
"type": "click_only",
"selector": "#promotional-banner button.dismiss",
"data_key": "dismiss_promo",
"optional": true,
"field_visible_timeout_ms": 2000
}
How optional differs from null values:
| Mechanism | Element lookup | Use case |
|---|---|---|
Value = null in input_data |
No (skipped immediately) | Field exists but should not be filled for this data row |
"optional": true |
Yes (waits for visibility) | Field may or may not exist on the page |
When optional is set and the element is not found or interaction fails, the handler logs an info message and continues to the next field. No error is raised regardless of the strict mode.
Tip: Set a low field_visible_timeout_ms (e.g., 1000-2000ms) on optional fields to avoid waiting the full default timeout when the element is absent.
Note: For skipping entire steps (all fields + next_action), use Optional Steps instead.
Action Types
CLICK
Click a button or link:
{
"type": "click",
"selector": "button[type='submit']"
}
With iframe support:
{
"type": "click",
"selector": "button:has-text('Next')",
"iframe_selector": "iframe#form-frame"
}
WAIT
Wait for an element to appear:
{
"type": "wait",
"selector": ".loading-complete"
}
WAIT_HIDDEN
Wait for an element to disappear:
{
"type": "wait_hidden",
"selector": ".loading-spinner"
}
SCROLL
Scroll to an element:
{
"type": "scroll",
"selector": "#section-bottom"
}
DELAY
Wait for a fixed time:
{
"type": "delay",
"delay_ms": 2000
}
CONDITIONAL
Execute actions based on conditions:
{
"type": "conditional",
"condition": {
"type": "selector_visible",
"selector": ".error-message"
},
"actions": [
{
"type": "click",
"selector": "button.dismiss-error"
}
]
}
Condition types:
selector_visible: True if selector is visible on the pageselector_hidden: True if selector is NOT visible on the pagedata_equals: True ifinput_data[key]equalsvaluedata_exists: True ifinput_data[key]is truthy
SOLVE_RECAPTCHA
Clear a reCAPTCHA v2 image challenge ("select all images with…") that appears mid-flow, typically
after a submit. Place it where the challenge appears — usually as a step's next_action once the
submit button has been clicked as a click_only field.
{ "type": "solve_recaptcha" }
Scope
| Challenge | Handled |
|---|---|
| reCAPTCHA v2 image grid, 3x3 dynamic (clicked tiles are replaced; re-classified until none match) | yes |
| reCAPTCHA v2 image grid, static ("select all squares") | yes |
| reCAPTCHA v2 invisible, when it escalates to an image grid | yes |
| reCAPTCHA v2 audio challenge | no - reported as unsupported |
| reCAPTCHA v3 (score only, no challenge) | nothing to solve; the action succeeds |
| hCaptcha, Cloudflare Turnstile, Arkose/FunCaptcha | no - reported as unsupported |
An unsupported challenge raises CaptchaNotSolvedError naming the provider. It is never reported as
cleared, so a page guarded by hCaptcha fails loudly instead of failing later for an unrelated-looking
reason. If no challenge appears at all the action succeeds, which is the normal case for invisible
reCAPTCHA that passes silently.
Modes
default runs a small local YOLO classifier (13 reCAPTCHA traffic categories, ~3 MB, a few ms per
tile on CPU) with CLIP zero-shot as the fallback for categories it does not know, so prompts like
"tractors" or "boats" are still attempted. Requires the extra: pip install "azcrawlerpy[recaptcha]".
Weights are fetched to ~/.cache/azcrawlerpy/recaptcha/ on first use; override with
default_yolo_path or the AZCRAWLERPY_RECAPTCHA_YOLO environment variable.
custom plugs in your own model, either hosted or local:
{
"type": "solve_recaptcha",
"recaptcha": {
"mode": "custom",
"custom": { "endpoint": "https://solver.internal/classify", "headers": { "x-api-key": "…" } }
}
}
{ "type": "solve_recaptcha", "recaptcha": { "mode": "custom", "custom": { "model_path": "./models/own.pt" } } }
Endpoint contract - the crawler sends one request per grid:
// POST
{ "prompt": "Select all images with a bridge", "tiles": ["<base64 png>", "…"] }
// 200
{ "matches": [true, false, …], "confidences": [0.93, 0.02, …], "labels": ["Bridge", "Other", …] }
matches is required and must have one entry per tile; confidences and labels are optional.
A local model_path model resolves the prompt against its own class names, so its vocabulary
does not need to match the bundled one.
Non-English widgets and language maps
reCAPTCHA renders in the page's language (the browser locale, or the hl parameter the site
passes). Grid handling and button detection read the widget's DOM and work in any language, but the
category word in the prompt ("hydrant", "idrante", "Hydrant") must be mapped onto a label the
classifier knows. That map is prompt_labels. Without one, a category the bundled English map does
not recognise falls through to CLIP zero-shot in default mode, which then sees the untranslated
sentence and picks nothing, or is skipped in custom mode.
How the map is applied
- Keys are stems: lower-case substrings matched anywhere in the prompt, so one key covers singular,
plural and article variants (
"idrant"matches "un idrante" and "idranti"). - Longer keys are tried first, so
"autobus"wins over"auto"when both would match. - Values are the label to use. For the YOLO stage this must be one of the model's class names; for
the CLIP fallback it can be any English noun, which is also what CLIP is prompted with. The bundled
model's classes are
Bicycle,Bridge,Bus,Car,Chimney,Crosswalk,Hydrant,Motorcycle,Mountain,Palm,Stairs,Traffic Light. - Entries are merged over the built-in English map, so you only add the wording you need.
Building a map for a new language
- Run the flow once with the action in place. Every round logs the exact prompt it saw:
reCAPTCHA round: prompt='Seleziona tutti i riquadri con motocicli ...'. - For each category word, pick a stem short enough to cover its inflections but long enough not
to appear inside other words. Check candidates against the full prompt sentence, not just the
noun:
"camin"(chimney) must not match "camion" (truck), and"scale"(stairs) must not be a substring of any other wording the widget uses. - Map YOLO categories to the class names above; map anything else (tractors, boats, taxis, parking meters, trucks) to a plain English noun for CLIP.
- Re-run and confirm the log shows tiles being picked. A prompt that still logs
Category outside YOLO vocabularyfor a category YOLO does know means its stem is missing.
Italian, as served to a browser with an Italian locale:
{
"type": "solve_recaptcha",
"recaptcha": {
"prompt_labels": {
"attraversament": "Crosswalk",
"strisce pedonal": "Crosswalk",
"parchimetr": "Parking Meter",
"motocicl": "Motorcycle",
"bicicl": "Bicycle",
"semafor": "Traffic Light",
"montagn": "Mountain",
"autobus": "Bus",
"trattor": "Tractor",
"idrant": "Hydrant",
"collin": "Mountain",
"camion": "Truck",
"camin": "Chimney",
"scale": "Stairs",
"barc": "Boat",
"pont": "Bridge",
"palm": "Palm",
"taxi": "Taxi",
"moto": "Motorcycle",
"auto": "Car"
}
}
}
The same map works unchanged for a custom model whose class names match the values; use your own
class names as values otherwise.
Parameters
| Field | Default | Description |
|---|---|---|
mode |
default |
default (bundled YOLO + CLIP) or custom |
custom.endpoint / custom.model_path |
— | Exactly one, required for mode custom |
custom.headers / custom.timeout_ms |
— / 30000 | Endpoint options |
prompt_labels |
— | Extra prompt-stem to label pairs, merged over the built-in English map |
max_rounds |
18 | Rounds before CaptchaNotSolvedError |
min_confidence |
0.2 | YOLO probability of the requested class needed to click a tile (the threshold tuned in the ETH paper) |
clip_threshold |
0.5 | CLIP probability needed to click a tile |
appear_timeout_ms |
15000 | Wait for a challenge; none appearing counts as success |
round_delay_ms / dynamic_reload_delay_ms |
3500 / 4000 | Pauses while the widget re-renders |
default_yolo_path / default_clip_model |
— / openai/clip-vit-base-patch16 |
Override the bundled models |
What determines the success rate
The bundled classifier is the one from the ETH Zurich Breaking reCAPTCHAv2 paper (COMPSAC 2024), which reports a 100% solve rate on the Google demo widget. Their measurements show that the model is the smallest part of that result; what reCAPTCHA thinks of the session decides how many rounds it serves before accepting an answer:
| Condition (paper, median challenges per pass) | Without | With |
|---|---|---|
| Natural (Bezier-curve) mouse movement | 13 | 5 |
| Cookies and browsing history in the profile | 5 | 2 |
| Residential-looking IP (VPN) | flagged after ~20 runs | 100/100 passes |
Humans in the same study needed a median of 2 challenges, so a session that keeps being served rounds after a handful of correct answers is being judged on its reputation, not its answers. Practical consequences for a crawl:
- Keep
humanizeenabled in the browser config; Camoufox then moves the cursor along a curve for every tile click, which is the mouse-movement condition above. - A warmed profile (see Browser Profile Building) is the cookie/history condition. Dropping it costs rounds even with a perfect classifier.
- A datacenter exit IP is the dominant negative signal. The paper's bot passed every run on a residential-looking IP and was flagged within ~20 runs without one.
- The paper's threshold on the target-class probability is 0.2, which is the
min_confidencedefault. Its classifier scores 82% top-1 and 99.5% top-5 on the 13 categories, so the requested class is almost always among the likeliest few; that is why a refused empty Verify falls back to the single likeliest tile. - For 4x4 "select all squares" grids the paper segments the whole image with a COCO-pretrained YOLOv8 model and maps the mask onto cells, instead of classifying each crop. This action classifies crops, which is less exact on object boundaries; a segmentation stage is the next accuracy improvement if those grids dominate your failures.
Notes and limitations
- Only the visible challenge frame is solved. Pages rendering several widgets have several hidden frames holding blank tiles; those are ignored.
- Unknown categories are skipped (targets are sparse), which keeps the round loop alive rather than aborting on the first unfamiliar prompt.
- When no tile clears the confidence threshold but the widget answers "Please select all matching images", the likeliest tile is clicked instead of resubmitting the same empty grid every round.
- Dynamic grids are recognised by the widget marking clicked tiles while replacements load; the replacements are classified again until none match, then Verify is pressed.
- Solving is not the same as passing. A session reCAPTCHA distrusts receives visually degraded
tiles and effectively unlimited rounds; no classifier reliably beats that. The action gives up
after
max_roundsrather than looping forever, so the failure stays visible. Proxy reputation and session history matter at least as much as the model. - Static grids overlay one image, so each cell is classified as a crop - approximate by design.
- Classifiers are cached per configuration, so repeated challenges in one process reuse loaded weights.
- The bundled weights come from the ETH Zurich Breaking reCAPTCHAv2 research code, which ships
without a license file. Review terms before production use, or point
custom.model_pathat your own model.
Common Action Parameters
| Parameter | Type | Description |
|---|---|---|
selector |
string | Target element selector |
iframe_selector |
string | Selector for parent iframe |
delay_ms |
integer | Delay/timeout in ms (for delay, wait, wait_hidden actions) |
condition |
object | Condition definition (for conditional actions) |
actions |
array | Nested actions to execute if condition is met (for conditional actions) |
pre_action_delay_ms |
integer | Wait before executing action |
post_action_delay_ms |
integer | Wait after executing action |
retry_config |
object | Retry configuration for transient failures (see Retry and Resilience) |
Final Page Configuration
{
"final_page": {
"wait_for": ".result-container, .confirmation",
"timeout_ms": 60000,
"post_wait_delay_ms": 2000,
"screenshot_selector": ".result-panel"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
wait_for |
string | Conditional | CSS selector to wait for (required if wait_for_data_key not set) |
wait_for_data_key |
string | Conditional | Data key for exact text match (uses text="{value}" selector). Required if wait_for not set. |
timeout_ms |
integer | Yes | Timeout in milliseconds for waiting |
post_wait_delay_ms |
integer | No | Delay in ms after selector found, for SPA content to render (default: 0) |
screenshot_selector |
string | No | Element to screenshot (null for full page) |
Only one of wait_for or wait_for_data_key can be set (not both).
Post Final Page Steps
post_final_page_steps is an optional list of regular step definitions that run on the result page after its wait_for/post_wait_delay_ms settle and before data_extraction, the end screenshot, and the HTML capture. Use it to interact with the results (load more entries, switch coverage tabs) that a normal step can never reach because the result page only exists after all steps and CAPTCHA handling.
Each entry supports the full step semantics (wait_for, optional, restart_crawl, extraction, retries). Use {"type": "delay", "delay_ms": 0} as next_action when a step only extracts. Step extractions additionally support a repeat-click loop for load-more buttons:
{
"post_final_page_steps": [
{
"name": "load_all_results",
"wait_for": "div[data-test^='tarifkachel/']",
"timeout_ms": 60000,
"fields": [],
"post_field_extraction": [
{
"name": "tariff_count",
"selector": "div[data-test^='tarifkachel/']",
"click_before": "button:has-text('weitere Ergebnisse laden')",
"repeat_click_until_gone": true,
"max_repeat_clicks": 20,
"wait_after_click_ms": 1500
}
],
"next_action": { "type": "delay", "delay_ms": 0 }
}
]
}
| Field | Type | Required | Description |
|---|---|---|---|
repeat_click_until_gone |
boolean | No | Keep clicking click_before until it disappears, max_repeat_clicks is reached, or the extraction's selector match count stops growing after a click (no-progress guard). Set wait_after_click_ms high enough for new content to render, or the guard fires early. |
max_repeat_clicks |
integer | No | Safety cap on clicks when repeat_click_until_gone is true (must be >= 1) |
The repeat-click loop is available on any step extraction, but is most useful here. The whole loop runs inside the step's timeout_ms budget.
Per-Step Page Extraction
instructions.data_extraction runs once, after every step, so it only ever captures the UI state the last step left behind. When a results page changes in response to clicks — a coverage tab, a filter, a sort order — each state needs its own list snapshot. page_extraction puts a full DataExtractionConfig on a step and runs it at the end of that step, after post_field_extraction and before next_action, so it sees the DOM the step's own clicks produced.
{
"name": "screen_vk_sb500",
"wait_for": "[data-test='deckung/VK']",
"timeout_ms": 120000,
"optional": true,
"data_extraction": [
{ "name": "_switch_vk", "selector": "[data-test='deckung/VK']",
"click_before": "[data-test='deckung/VK']", "wait_after_click_ms": 6000 },
{ "name": "_load_more", "selector": "div[data-test^='tarifkachel/']",
"click_before": "button:has-text('weitere Ergebnisse laden')",
"repeat_click_until_gone": true, "max_repeat_clicks": 20 }
],
"page_extraction": {
"fields": {
"insurer_names": { "selector": "div[data-test^='tarifkachel/'] img[alt]", "attribute": "alt", "multiple": true }
}
},
"fields": [],
"next_action": { "type": "delay", "delay_ms": 2000 }
}
Results are namespaced under the step name, so several steps can extract the same field names without overwriting each other:
result.extracted_data["screen_vk_sb500"]["insurer_names"] # [... 98 items ...]
Because every extraction shares one flat result dict and instructions.data_extraction is merged over it last, Instructions rejects configs where a page_extraction step name would collide with another snapshot step, a step extraction name, or a final-page field name.
Failures follow the same restart_crawl > optional > strict chain as the other extraction slots, so a snapshot step marked optional: true cannot fail the crawl. Extraction cost scales with fields × matched elements and counts against the step's timeout_ms, so budget list-heavy snapshots accordingly — an exhausted budget on an optional step silently drops the snapshot.
Data Extraction Configuration
Extract structured data from the final page using CSS selectors:
{
"data_extraction": {
"fields": {
"tier_prices": {
"selector": ".price-value",
"attribute": null,
"regex": "([0-9]+[.,][0-9]{2})",
"multiple": true,
"iframe_selector": "iframe#form-frame"
},
"selected_price": {
"selector": "#total-amount",
"attribute": "data-value",
"regex": null,
"multiple": false
}
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
selector |
string | Yes | CSS selector to locate element(s) |
attribute |
string | No | Element attribute to extract (null for text content) |
regex |
string | No | Regex pattern to apply (uses first capture group if present) |
multiple |
boolean | Yes | True for list of all matches, False for first match only |
iframe_selector |
string | No | CSS selector for iframe if element is inside one |
pad_missing |
boolean | No | With multiple: true, append null for missing/non-matching values instead of dropping them, keeping list indices aligned with the matched elements (safe for nested_fields over sparse fields) |
The data_extraction config also supports nested_fields to combine flat extracted arrays into structured outputs (paired_dict or object_list). See docs/README.md for details.
Extracted data is available in the crawl result:
result = await crawler.crawl(...)
print(result.extracted_data)
# {'tier_prices': ['32,28', '35,26', '50,34'], 'selected_price': '35,26'}
Data Points (input_data)
The input_data dictionary provides values for form fields. Keys must match data_key values in the instructions.
Structure
{
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe",
"birthdate": "1985-06-15",
"country": "Germany",
"accept_terms": true,
"newsletter": false,
"premium_option": null
}
Value Types
| Type | Description | Example |
|---|---|---|
| String | Text values, dropdown selections | "John" |
| Boolean | Checkbox/radio toggle | true, false |
| Null | Skip this field entirely (no DOM interaction) | null |
| Integer/Float | Numeric inputs, sliders | 12000, 99.99 |
Note: Setting a value to null skips the field without any DOM interaction. This is different from "optional": true on the field definition, which attempts the interaction but tolerates failure. See Optional Fields.
Radio Button Patterns
Pattern A - Mutually exclusive options with value selector:
{
"gender": "male"
}
Selector uses ${value} placeholder: input[value='${value}']
Pattern B - Boolean flags for each option:
{
"option_a": true,
"option_b": null,
"option_c": null
}
Only the option with true gets clicked.
Date Handling
Dates in input_data should use ISO format (YYYY-MM-DD):
{
"birthdate": "1985-06-15",
"start_date": "2024-01-01"
}
The framework converts to the format specified in the field definition.
Element Discovery
The ElementDiscovery class scans web pages to identify interactive elements, helping build instructions.json files.
from pathlib import Path
from azcrawlerpy import ElementDiscovery
async def discover_elements():
discovery = ElementDiscovery(headless=False)
report = await discovery.discover(
url="https://example.com/form",
output_dir=Path("./discovery_output"),
cookie_consent={
"banner_selector": "#cookie-banner",
"accept_selector": "button.accept"
},
explore_iframes=True,
screenshot=True,
)
print(f"Found {report.total_elements} elements")
for text_input in report.text_inputs:
print(f"Text input: {text_input.selector}")
print(f" Suggested type: {text_input.suggested_field_type}")
for dropdown in report.selects:
print(f"Dropdown: {dropdown.selector}")
print(f" Options: {dropdown.options}")
for radio_group in report.radio_groups:
print(f"Radio group: {radio_group.name}")
for option in radio_group.options:
print(f" - {option.label}: {option.selector}")
Discovery Report Contents
text_inputs: Text, email, phone, password fieldstextareas: Multi-line text areasselects: Native dropdown elements with optionsradio_groups: Grouped radio buttonscheckboxes: Checkbox inputsbuttons: Clickable buttonslinks: Anchor elementsdate_inputs: Date picker fieldsfile_inputs: File upload fieldssliders: Range inputscustom_components: Non-standard interactive elementsiframes: Discovered iframes with their elements
AI Agent Guidance
This section provides instructions for AI agents tasked with creating instructions.json and input_data files.
Workflow for Creating Instructions
- Discovery Phase: Use
ElementDiscoveryto scan each page/step of the form - Mapping Phase: Map discovered elements to field definitions
- Flow Definition: Define step transitions and actions
- Data Schema: Create the input_data structure
Step-by-Step Process
1. Analyze the Form Structure
- Identify how many pages/steps the form has
- Note the URL pattern changes (if any)
- Identify what element appears when each step loads
2. For Each Step, Define:
{
"name": "<descriptive_step_name>",
"wait_for": "<selector_that_confirms_step_loaded>",
"timeout_ms": 15000,
"fields": [...],
"next_action": {...}
}
Naming conventions:
- Use snake_case for step names:
personal_info,payment_details - Use descriptive data_keys:
first_name,email_address,accepts_terms
3. Selector Priority
When choosing selectors, prefer in order:
[data-testid='...']or[data-cy='...']- Most stable[aria-label='...']or[aria-labelledby='...']- Accessible and stableinput[name='...']- Form field names:has-text('...')- Text content (use for buttons/labels)- CSS class selectors - Least stable, avoid if possible
4. Handle Dynamic Content
For AJAX-loaded content:
- Use
waitaction before interacting - Add
field_visible_timeout_msto field definitions - Use
post_click_delay_msfor fields that trigger updates
5. Radio Button Strategy
Option A - When radio values are meaningful:
{
"type": "radio",
"selector": "input[type='radio'][value='${value}']",
"data_key": "payment_type"
}
// data: { "payment_type": "credit_card" }
Option B - When you need individual control:
{
"type": "radio",
"selector": "[role='radio']:has-text('Credit Card')",
"data_key": "payment_credit_card",
"force_click": true
},
{
"type": "radio",
"selector": "[role='radio']:has-text('PayPal')",
"data_key": "payment_paypal",
"force_click": true
}
// data: { "payment_credit_card": true, "payment_paypal": null }
6. Iframe Handling
When elements are inside iframes:
{
"type": "text",
"selector": "input[name='card_number']",
"iframe_selector": "iframe#payment-iframe",
"data_key": "card_number"
}
Creating input_data
1. Analyze Required Fields
From the instructions, extract all unique data_key values:
data_keys = set()
for step in instructions["steps"]:
for field in step["fields"]:
if field.get("data_key"):
data_keys.add(field["data_key"])
2. Determine Value Types
| Field Type | Data Type | Example |
|---|---|---|
| text, textarea | string | "John Doe" |
| dropdown | string | "Germany" |
| radio (value-driven) | string | "option_a" |
| radio (boolean) | boolean/null | true or null |
| checkbox | boolean | true / false |
| date | string (ISO) | "1985-06-15" |
| slider | number | 50000 |
| file | string (path) | "/path/to/file.pdf" |
3. Handle Mutually Exclusive Options
For radio groups with boolean flags, only ONE should be true:
{
"employment_fulltime": true,
"employment_parttime": null,
"employment_selfemployed": null,
"employment_unemployed": null
}
4. Date Format
Always provide dates in ISO format in input_data:
{
"birthdate": "1985-06-15",
"policy_start": "2024-01-01"
}
The instructions specify the output format for the specific form.
Common Patterns
Multi-Step Wizard
{
"steps": [
{
"name": "step_1_personal",
"wait_for": "input[name='firstName']",
"fields": [...],
"next_action": { "type": "click", "selector": "button:has-text('Next')" }
},
{
"name": "step_2_address",
"wait_for": "input[name='street']",
"fields": [...],
"next_action": { "type": "click", "selector": "button:has-text('Next')" }
}
]
}
Form with Loading States
{
"next_action": {
"type": "click",
"selector": "button[type='submit']",
"post_action_delay_ms": 1000
}
}
Conditional Fields
{
"type": "conditional",
"condition": {
"type": "data_equals",
"key": "has_additional_driver",
"value": true
},
"actions": [
{
"type": "click",
"selector": "button:has-text('Add Driver')"
}
]
}
Retry and Resilience
The framework provides built-in retry and fallback mechanisms for handling transient failures during web interactions.
Retry Configuration
Add retry_config to any field, action, step extraction, or data extraction to enable automatic retry with exponential backoff:
{
"type": "text",
"selector": "input[name='email']",
"data_key": "email",
"retry_config": {
"max_attempts": 3,
"base_delay_ms": 500,
"backoff_multiplier": 1.5
}
}
| Parameter | Type | Required | Description |
|---|---|---|---|
max_attempts |
integer | Yes | Total attempts (1 = no retry, 2 = one retry, etc.) |
base_delay_ms |
integer | Yes | Base delay between retries in milliseconds |
backoff_multiplier |
float | No | Multiplier per retry (default: 1.5). Delay = base_delay_ms * backoff_multiplier^(attempt-1) |
Retryable errors include Playwright TimeoutError and Error (element not found, intercepted clicks, etc.).
Click Fallback Chain
All click interactions (click actions, click_only, radio, click_select fields) use an escalating fallback strategy when a click fails:
- Normal click (or force click if
force_first=True) - Force click (or normal click if
force_first=True) -- bypasses actionability checks - JS dispatchEvent -- fires a
MouseEventviaelement.dispatchEvent() - JS element.click() -- calls
element.click()viadocument.querySelector()
This handles common real-world issues like overlay interception, sticky headers, and elements that pass Playwright's visibility checks but fail to receive pointer events.
Value Verification
text and textarea fields verify the actual input value after filling. If the value written to the field does not match the expected value, a FieldInteractionError is raised. This catches silent data corruption from autofill interference, input masks, or JavaScript reformatting.
Set skip_verification: true on the field to disable this check for fields where the site intentionally transforms the input (e.g., phone number formatting).
ComboBox Degraded Retry
When retry_config is set on a combobox field, retries use degraded parameters to handle slow autocomplete responses:
- Typing delay scales by
1.5^(attempt-1)(slower typing gives autocomplete more time) - The field is always cleared before retyping on retry attempts
Error Handling and Diagnostics
The framework provides detailed error information when failures occur.
Exception Types
All CrawlerError subclasses can carry a partial_result attribute populated
via with_partial_result(). This is a CrawlResult holding whatever state
had been collected before the failure (screenshots, extracted_data,
steps_completed, profiler_visited_urls, platform_logs,
crawler_log, stdout_log, stderr_log, playwright_debug_log,
error_diagnostics). Catch the exception and read e.partial_result to
recover that data — see In-Memory Error Diagnostics
below.
| Exception | When Raised |
|---|---|
FieldNotFoundError |
Selector doesn't match any element (not raised for optional fields) |
FieldInteractionError |
Element found but interaction failed, or value mismatch after fill (not raised for optional fields) |
CrawlerTimeoutError |
A step's timeout_ms budget was exhausted (initial wait_for OR the rest of the step body — extractions, fields, action, retries), a restart budget was exhausted, OR the crawl's global_timeout_ms wall-clock cap was hit |
NavigationError |
Navigation action failed |
MissingDataError |
Required data_key not in input_data |
InvalidInstructionError |
Malformed instructions JSON |
UnsupportedFieldTypeError |
Unknown field type specified |
UnsupportedActionTypeError |
Unknown action type specified |
IframeNotFoundError |
Specified iframe not found |
DataExtractionError |
Data extraction from final page failed |
Timeout Budgets
The crawler enforces two independent wall-clock budgets. Both translate
their exhaustion into CrawlerTimeoutError (or CrawlRestartError when
restart is configured) and attach a partial result.
1. Per-step budget — step.timeout_ms
Hard upper bound on the total wall-clock duration of one step. Covers
wait_for, data_extraction, field handlers (including retries),
next_action (including retries and delays), post_field_extraction and
page_extraction. The same field is also used as the timeout for the initial
wait_for_selector call, so a step that uses its full budget waiting for
the selector leaves no time for the rest of the body. On exhaustion, the
priority chain restart_crawl > optional > strict decides the outcome:
restart_crawl=True→CrawlRestartError→ full browser restart (up tomax_restarts).optional=True→ step is skipped silently.strict=True→CrawlerTimeoutError.strict=False→ warning logged, step returns.
2. Crawl-level budget — crawler.crawl(..., global_timeout_ms=...)
Optional wall-clock cap on the entire crawl (profiler, navigation, all
steps and the final-page capture). None (the default) disables it. When
the deadline fires, the crawl task is cancelled and
CrawlerTimeoutError(step_name="<global_timeout>", selector="<global>", timeout_ms=...) is raised with a partial CrawlResult attached.
The partial carries screenshots, extracted_data, steps_completed,
profiler_visited_urls, platform_logs, crawler_log, stdout_log,
stderr_log and playwright_debug_log collected up to the moment of
cancellation. html is empty and final_url falls back to the start URL
because the live page is being torn down by the cancellation and cannot
be read.
from azcrawlerpy.crawling.exceptions import CrawlerTimeoutError
try:
result = await crawler.crawl(
url=url,
input_data=input_data,
instructions=instructions,
debug_mode=DebugMode.ALL,
global_timeout_ms=300_000, # 5 minutes
)
except CrawlerTimeoutError as e:
if e.step_name == "<global_timeout>":
# Global budget hit -- inspect partial_result for what was collected.
partial = e.partial_result
if partial is not None:
print(f"Timed out after {partial.steps_completed} step(s)")
print(f"Screenshots: {len(partial.screenshots)}")
else:
# Per-step budget or restart-budget exhaustion.
raise
Debug Mode
Enable debug mode to capture screenshots at various stages:
from azcrawlerpy import DebugMode
result = await crawler.crawl(
...,
debug_mode=DebugMode.ALL, # Capture all screenshots
)
| Mode | Description |
|---|---|
NONE |
No debug screenshots |
START |
Screenshot at form start |
END |
Screenshot at form end |
ALL |
Screenshots after every field and action |
AI Diagnostics
When errors occur with debug mode enabled, the framework captures:
- Current page URL and title
- Available
data-cyanddata-testidselectors - Visible buttons and input fields
- Similar selectors (fuzzy matching suggestions)
- Console errors and warnings
- Failed network requests
- XHR/fetch API responses (URL, status, method) for debugging SPA extraction failures
- HTML snippet of the form area
- Error screenshot (captured in-memory as bytes)
This information is included in the exception message and attached to the
returned/partial CrawlResult as error_diagnostics (a dict[str, Any]
equivalent to the content of the previous error_diagnostics.json file).
The caller persists it wherever they like.
In-Memory Error Diagnostics
Error diagnostics are fully available in-memory. The error screenshot is captured as bytes and accessible in two ways:
- On the exception's diagnostics:
exception.diagnostics.screenshot_bytes - As the last entry in the partial result:
exception.partial_result.screenshots[-1](aScreenshotmodel withlabel,imagebytes, and the pagehtmlat capture time)
try:
result = await crawler.crawl(
url=url,
input_data=input_data,
instructions=instructions,
debug_mode=DebugMode.ALL,
)
except CrawlerError as e:
# Access the error screenshot bytes directly from diagnostics
if e.diagnostics and e.diagnostics.screenshot_bytes:
error_screenshot = e.diagnostics.screenshot_bytes
# Or from the partial result (includes all screenshots taken during the crawl,
# each a Screenshot model pairing label, image bytes and page HTML)
if e.partial_result:
all_screenshots = e.partial_result.screenshots # includes error screenshot as last item
extracted_so_far = e.partial_result.extracted_data
steps_done = e.partial_result.steps_completed
Azure App Service Log Capture
When the crawler runs on an Azure App Service Plan target (Web App, Function App, or Web App / Function App in a container), debug_mode=DebugMode.ALL automatically captures the platform log tree so it can be attached to the run's output artifacts. This is where Playwright's own DEBUG=pw:api,pw:browser* stderr ends up in Azure, so it is often the only way to see low-level browser-launch failures in post-mortem.
When it activates (two gating conditions):
debug_mode=DebugMode.ALL, AND$HOMEis set and$HOME/LogFiles/is a directory.
If either gating check fails, the crawl is otherwise untouched and a clear reason is logged (see "Log output" below).
Sink:
The full $HOME/LogFiles/ tree is read into a dict[str, bytes] and
returned on CrawlResult.platform_logs. Keys are forward-slash relative
paths (e.g. "Application/Functions/Worker/worker.log"); values are the
raw file bytes. On graceful failure the same dict is attached to
CrawlerError.partial_result.platform_logs, so harnesses that catch
CrawlerError can still recover them. The crawler never writes to disk
— the caller owns the sink.
What gets collected:
The entire $HOME/LogFiles/ tree, recursively. No filtering. Files include:
Application/Functions/Worker/*.log(Python worker stdout/stderr on Functions)Application/Functions/Host/(Functions host logs)<instance>_default_docker.log(Web App container stdout/stderr)kudu/,Nginx/,docker.log, etc.
On Linux App Service that path is /home/LogFiles/; on Windows App Service it is typically C:\home\LogFiles\ or D:\home\LogFiles\. The detection uses $HOME so both work without configuration.
Usage example (harness uploads to its own storage):
result = await crawler.crawl(
url=url,
input_data=input_data,
instructions=instructions,
debug_mode=DebugMode.ALL, # activates platform log capture
)
# result.platform_logs is a dict[str, bytes]
for rel_path, content in result.platform_logs.items():
blob_client.upload_blob(
name=f"{run_id}/platform_logs/{rel_path}",
data=content,
overwrite=True,
)
Crash-safe:
The collection is wrapped in try / finally around the entire crawl body, so it runs on:
- Successful return
CrawlerTimeoutErrorfrom an exhausted restart budget- Any
CrawlerErrorwrapping a browser crash - Profiler-phase failures (the profiler is inside the
try) KeyboardInterrupt/ task cancellation
Per-file errors (Windows log rotation, permission issues, races) are logged as warnings and counted; they never mask the crawl's original exception.
Log output:
Every invocation emits one start line plus one outcome line via the configured logger (flows to Application Insights through azpaddypy):
| Situation | Level | Message |
|---|---|---|
| Helper invoked | INFO | Platform log capture starting |
$HOME not set (local dev, non-Azure) |
INFO | Platform log capture skipped: $HOME not set (not an Azure App Service target) |
$HOME/LogFiles missing |
INFO | Platform log capture skipped: '<path>' not found (not an Azure App Service target) |
$HOME/LogFiles exists but is a file |
WARNING | Platform log capture skipped: '<path>' exists but is not a directory |
Cannot stat $HOME/LogFiles (permissions) |
WARNING | Platform log capture skipped: cannot stat '<path>' error=<exc> |
| Source found but empty | WARNING | Platform log capture found source but copied nothing: source='<path>' (directory is empty or contains no regular files) |
| Files collected | INFO | Platform logs collected: source='<s>' files=<N> bytes=<B> errors=<E> |
Deployment requirements per target:
| Target | Works out of the box? | Notes |
|---|---|---|
| Azure Functions on App Service Plan | Yes | Functions host always writes to /home/LogFiles/Application/Functions/Worker/. Playwright Node stderr inherits that fd. |
| Web App for Containers on App Service Plan | Yes, with two app settings | WEBSITES_ENABLE_APP_SERVICE_STORAGE=true (default) so /home is the Azure Files mount, and App Service Logs → Filesystem enabled so stdout/stderr is written there. Enable via az webapp log config --docker-container-logging filesystem --level information. |
| Pure Azure Container Apps | No | Container Apps does not mount /home/LogFiles/; logs go to Log Analytics only. The helper correctly INFO-logs "not found" and skips. |
Local dev (docker run, native Python) |
No (by design) | $HOME/LogFiles/ doesn't exist; helper skips and logs the reason. |
Delivery:
The collected bytes land on CrawlResult.platform_logs on success. On graceful failure (any CrawlerError, including CrawlerTimeoutError from an exhausted restart budget or the global_timeout_ms wall-clock cap), they land on exception.partial_result.platform_logs.
Shared-worker noise:
/home/LogFiles/Application/Functions/Worker/*.log is shared across all concurrent function invocations on the same worker instance. The copied snapshot therefore includes any overlapping activity — there is no per-invocation filtering. This is an intentional "full copy always" trade for forensic completeness.
Browser Profile Building (Profiler)
The profiler visits random sites before the main crawl to accumulate cookies and storage, making the browser appear more natural. This is configured via the profiler field in instructions.json.
Profiler Modes
The profiler supports two storage modes:
Disk mode (storage_path set): Persists the browser profile to disk.
- Camoufox: saves as a Firefox user data directory (
{storage_path}.camoufox/) - Chromium: saves as a JSON file (
{storage_path}.chromium.json)
In-memory mode (storage_path omitted or null): No permanent files written.
- Chromium: returns storage state as a dict, passed directly to
browser.new_context(storage_state=dict) - Camoufox: uses a temporary directory (cleaned up on error, passed as
user_data_dirduring crawl)
Profiler Configuration
{
"profiler": {
"sites": [
{
"url": "https://www.google.de",
"cookie_consent": {
"banner_selector": "[role='dialog'], .cookie-banner",
"accept_selector": "button[id*='accept'], button[id*='agree']",
"js_fallback_texts": ["akzept", "accept", "zustimm"]
},
"browse_delay_ms": 2000
},
{
"url": "https://en.wikipedia.org",
"browse_delay_ms": 3000
}
],
"visit_count": 3,
"ignore_errors": true,
"storage_path": null,
"inter_site_delay_ms": 1000
}
}
ProfilerConfig Parameters
| Field | Type | Required | Description |
|---|---|---|---|
sites |
array | Yes | List of ProfilerSiteConfig objects to randomly select from |
visit_count |
integer | Yes | Number of sites to randomly visit (must be <= length of sites) |
ignore_errors |
boolean | Yes | If true, continue profiling when a single site visit fails |
storage_path |
string or null | No | Base path for profile storage. When null (default), runs in-memory mode |
bootstrap_path |
string or null | No | Existing profile to seed each run from (see Bootstrap Profiles) |
inter_site_delay_ms |
integer | No | Delay in ms between visiting each site |
Bootstrap Profiles
A profile with no history is a signal in itself: reputation systems score a browser that has never
existed before far lower than one with aged cookies. storage_path solves that by persisting the
profile, but it makes runs stateful — concurrent crawls then share and mutate one directory.
bootstrap_path keeps runs stateless. The seed is treated as read-only: it is copied into the
run's throw-away profile before the warmup, and every change the crawl makes stays in the copy.
{
"profiler": {
"visit_count": 3,
"ignore_errors": true,
"bootstrap_path": "./browser_profiles/it-seed",
"sites": [{ "url": "https://www.youtube.com/", "browse_delay_ms": 15000 }]
}
}
- Camoufox: point it at a
user_data_dirdirectory; it is copied withcopytree. - Chromium: point it at a storage state JSON file; it is passed as the context's
storage_state. - A missing or wrong-typed path logs a warning and is skipped, so a bad seed never fails a crawl.
Create a seed once, by hand, then reuse it: launch a browser with persistent_context=True and a
user_data_dir, browse normally for a while (signing in to accounts whose cookies you want the seed
to carry), and close it. Point bootstrap_path at that directory. Refresh it periodically — cookies
expire, and a stale seed loses its value.
Note the seed is shared state, not shared identity: each run still gets its own device, so a seed used across many concurrent runs presents the same cookies from different machines. Keep seeds per-target, and prefer several seeds over one when running at volume.
Fingerprint Consistency
Camoufox picks a new fingerprint on every launch. The profiler and the crawl are two launches, so
without intervention the crawl would present the profiler's cookies from a subtly different device —
navigator.hardwareConcurrency changing between the warmup and the crawl, for example.
When a profiler is configured on camoufox, the crawler pins one fingerprint_preset and passes it
to both launches, so warmup and crawl are the same machine. Presets are real captured devices and
are the mechanism camoufox documents for this; hand-built fingerprints trigger a leak warning and
are avoided deliberately. The preset follows browser_config.os, so it stays consistent with the
spoofed platform.
Nothing to configure — it applies automatically whenever profiler is set and browser_type is
camoufox. Without a profiler there is only one launch, so camoufox's per-launch fingerprint is
left alone.
ProfilerSiteConfig Parameters
Each site in the sites array supports per-site cookie consent handling:
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | Yes | URL to visit for profile building |
cookie_consent |
object | No | Cookie consent config for this specific site (same schema as top-level cookie_consent) |
browse_delay_ms |
integer | No | Time in ms to linger on the page after cookie consent handling |
ProfilerResult
The profiler returns a ProfilerResult with:
| Field | Type | Description |
|---|---|---|
visited_urls |
list[str] | URLs that were visited during profiling |
storage_state_path |
Path or None | Path to persisted profile (disk mode), or temp dir path (Camoufox in-memory) |
storage_state |
dict or None | In-memory storage state dict (Chromium in-memory mode only) |
The crawler automatically passes the profiler result to the browser context creation, so no manual wiring is needed.
Examples
Insurance Quote Form
instructions.json:
{
"url": "https://insurance.example.com/quote",
"browser_config": {
"viewport_width": 1920,
"viewport_height": 1080
},
"cookie_consent": {
"banner_selector": "#cookie-banner",
"accept_selector": "button:has-text('Accept')"
},
"steps": [
{
"name": "vehicle_info",
"wait_for": "input[name='hsn']",
"timeout_ms": 15000,
"fields": [
{
"type": "text",
"selector": "input[name='hsn']",
"data_key": "vehicle_hsn"
},
{
"type": "text",
"selector": "input[name='tsn']",
"data_key": "vehicle_tsn"
},
{
"type": "date",
"selector": "input[name='registration_date']",
"data_key": "first_registration",
"type_config": {
"format": "MM.YYYY"
}
}
],
"next_action": {
"type": "click",
"selector": "button:has-text('Continue')"
}
},
{
"name": "personal_info",
"wait_for": "input[name='birthdate']",
"timeout_ms": 15000,
"fields": [
{
"type": "date",
"selector": "input[name='birthdate']",
"data_key": "birthdate",
"type_config": {
"format": "DD.MM.YYYY"
}
},
{
"type": "text",
"selector": "input[name='zipcode']",
"data_key": "postal_code"
}
],
"next_action": {
"type": "click",
"selector": "button:has-text('Get Quote')"
}
}
],
"final_page": {
"wait_for": ".quote-result",
"timeout_ms": 60000,
"screenshot_selector": ".quote-panel"
}
}
data_row.json:
{
"vehicle_hsn": "0603",
"vehicle_tsn": "AKZ",
"first_registration": "2020-03-15",
"birthdate": "1985-06-20",
"postal_code": "80331"
}
Form with Iframes
{
"steps": [
{
"name": "embedded_form",
"wait_for": "iframe#form-frame",
"timeout_ms": 15000,
"fields": [
{
"type": "text",
"selector": "input[name='email']",
"iframe_selector": "iframe#form-frame",
"data_key": "email"
},
{
"type": "dropdown",
"selector": "select[name='plan']",
"iframe_selector": "iframe#form-frame",
"data_key": "selected_plan",
"type_config": {
"select_by": "text"
}
}
],
"next_action": {
"type": "click",
"selector": "button:has-text('Submit')",
"iframe_selector": "iframe#form-frame"
}
}
]
}
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file azcrawlerpy-1.3.0.tar.gz.
File metadata
- Download URL: azcrawlerpy-1.3.0.tar.gz
- Upload date:
- Size: 163.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c2ba8ca3f8364e15d2bed4dd4070fc2761f4f2a150b7f74ad5be3130aa1b7366
|
|
| MD5 |
b9ee83c518882161b437668b333b3ed8
|
|
| BLAKE2b-256 |
e38aa1f4b30cf1b1e5c437b85f3b963506cbe92a98196e1d12461eec503fab10
|
File details
Details for the file azcrawlerpy-1.3.0-py3-none-any.whl.
File metadata
- Download URL: azcrawlerpy-1.3.0-py3-none-any.whl
- Upload date:
- Size: 128.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3999b53d30c64c3e74af7d510307b33527615f03cd0d66477155f9b709dccec6
|
|
| MD5 |
a4d05d946cdaa82463dfd15858365a52
|
|
| BLAKE2b-256 |
decbfd0b7d27373978b50da12ad212b04047afbcd6a91f3fe2630ef6f5fd0738
|