🎉 Premium Proxies · 24-Hour Free TrialClaim Now
Comparisons

AgentQL vs Playwright Selectors: What Breaks First

AgentQL vs Playwright selectors tested against a real redesign: which selectors die on renamed classes, which on a new wrapper, and what each query costs.

S SparkProxy 5 27 min read
Share

The honest framing for AgentQL vs Playwright selectors is not accuracy. Both resolve the element you wanted most of the time. The difference shows up the morning after a redesign ships: one of them stops with a timeout you can grep for in a log, the other costs a network call per page and a bill you can forecast. So the useful question is which one breaks first, and which one breaks louder.

To answer it with something other than opinion, I took a real catalogue page, applied the four changes a redesign actually makes, and ran fourteen selectors against every variant. Every count below came out of Chromium 151.0.7922.34 driven by playwright-python 1.63.0 on 9 October 2026. Every AgentQL figure comes from AgentQL's own docs and pricing page, read the same day, in USD. Where AgentQL makes a resilience claim I could not measure without a paid key, it is quoted as their claim and labelled.

What a semantic query asks for, and where it resolves

A Playwright locator is a description of a place. article.product_pod h3 a says: inside an article carrying this class, find a heading, then the link inside it. The browser walks that path. If any step of the path is gone, zero elements come back.

An AgentQL query is a description of a thing. Its syntax wraps everything in braces, names fields, marks repeats with [], and takes free-text hints in parentheses. From AgentQL's query reference:

{
    products[] {
        name
        price(integer)
    }
}

The parentheses are the interesting part. AgentQL calls them semantic context, and the docs give this example of nesting plus a description together:

{
    footer {
        social_media_links(The icons that lead to Facebook, Snapchat, etc.)[]
    }
}

One documented constraint on that: "You can't include new lines in your semantic context." The hint has to fit on one line.

The structural difference that matters more than the syntax is where resolution happens. Playwright resolves a locator inside the page, in the renderer, with no network involved. AgentQL resolves a query on AgentQL's servers. Their architecture page states that "AgentQL uses both a page's HTML structure and the accessibility tree to understand its content and the relationships between its elements," and that "AgentQL utilizes several public LLMs, including GPT-4, Llama, and Gemini as well as our proprietary model, to generate initial results." A model cannot read your page unless your page reaches the model, so each query is an HTTP request carrying markup out of your process.

The Python surface makes the split visible. agentql.wrap() casts a Playwright page, then two methods do the work: query_data() returns a plain dict, and query_elements() returns an AQLResponseProxy. There is a third, get_by_prompt(), documented as returning a "Playwright Locator for the found element or None if no matching elements were found." That return type is the hinge for the caching section later, because it means AgentQL hands you back a Playwright object once the remote work is done.

import agentql
from playwright.sync_api import sync_playwright

PRODUCTS = """
{
    products[] {
        name
        price(integer)
        availability(whether the item is in stock)
    }
}
"""

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = agentql.wrap(browser.new_page())
    page.goto("https://books.toscrape.com/")
    data = page.query_data(query=PRODUCTS, mode="fast")
    print(len(data["products"]), "rows")
    browser.close()

That block is transcribed from AgentQL's quick start and Page reference, not run against a live key, so treat it as the documented shape rather than a measured result. Setup is pip3 install agentql then agentql init; the JavaScript path is npm install agentql plus a global agentql-cli, and reads AGENTQL_API_KEY from the environment.

The redesign, applied one change at a time

The fixture is the books.toscrape.com homepage, 51,294 bytes, fetched on 9 October 2026. It has twenty product cards shaped like this:

<article class="product_pod">
    
...
<p class="star-rating Three">...</p> <h3><a href="..." title="A Light in the Attic">A Light in the ...</a></h3>

£51.77

In stock

</article>

A redesign is rarely one thing. It is a design-system migration that renames classes, a layout change that reorders the card, a new grid wrapper, and a copy pass. So I wrote each of those as a separate transform and generated a variant per change plus one with all four applied:

RENAME = {
    "product_pod": "c-card",
    "price_color": "c-card__price",
    "instock": "c-card__stock",
    "availability": "is-available",
    "product_price": "c-card__meta",
    "star-rating": "c-rating",
}


def m_rename(s):
    for el in s.find_all(class_=True):
        el["class"] = [RENAME.get(c, c) for c in el.get("class")]
    return s


def m_reorder(s):
    # the price block moves above the title
    for art in s.select("article"):
        meta = art.find("div", class_=lambda c: c and (
            "product_price" in c or "c-card__meta" in c))
        h3 = art.find("h3")
        if meta and h3:
            h3.insert_before(meta.extract())
    return s


def m_wrapper(s):
    # a new span appears between the heading and its link
    for h3 in s.select("h3"):
        a = h3.find("a")
        if not a:
            continue
        span = s.new_tag("span")
        span["class"] = ["c-card__titlewrap"]
        span.append(a.extract())
        h3.append(span)
    return s


def m_copy(s):
    for t in s.find_all(string=re.compile(r"In stock")):
        t.replace_with(t.replace("In stock", "Available now"))
    for b in s.select("button"):
        if "Add to basket" in b.get_text():
            b.string = "Add to bag"
    return s

Six variants came out of that: v0 untouched, v1 renamed, v2 reordered, v3 wrapped, v4 recopied, v5 all four at once. I added a seventh, v6, for a change so mundane nobody writes it in a changelog: the star rating stops being a

and becomes a

` holding an inline SVG. Then each variant got loaded from `file://` and queried with the same fourteen selectors: five hand-written CSS, three hand-written XPath, five Playwright built-ins, and one deliberately sloppy `nth-of-type` to stand in for the selector somebody copied out of DevTools. ## Which selector died on which change {#death-matrix} Counts, not opinions. Zero means the selector returned nothing. An asterisk means the count changed from the untouched page. | Selector | v0 | v1 renamed | v2 reordered | v3 wrapper | v4 copy | v5 all four | v6 svg rating | |---|---|---|---|---|---|---|---| | `article.product_pod h3 a` | 20 | **0** | 20 | 20 | 20 | **0** | 20 | | `.product_pod .price_color` | 20 | **0** | 20 | 20 | 20 | **0** | 20 | | `ol.row > li:nth-child(1) > article > h3 > a` | 1 | 1 | 1 | **0** | 1 | **0** | 1 | | `article > h3 > a` | 20 | 20 | 20 | **0** | 20 | **0** | 20 | | `article h3 ~ div` | 20 | 20 | **0** | 20 | 20 | **0** | 20 | | `article p:nth-of-type(1)` | 40 | 40 | 40 | 40 | 40 | 40 | **20\*** | | `//article[contains(@class,"product_pod")]//p[contains(@class,"price_color")]` | 20 | **0** | 20 | 20 | 20 | **0** | 20 | | `//article[contains(@class,"product_pod")]/div[2]/p[1]` | 20 | **0** | 20 | 20 | 20 | **0** | **0** | | `//h3/following-sibling::div[1]` | 20 | 20 | **0** | 20 | 20 | **0** | 20 | | `get_by_role("link", name="A Light in the Attic")` | 1 | 1 | 1 | 1 | 1 | 1 | 1 | | `get_by_role("listitem")` | 75 | 75 | 75 | 75 | 75 | 75 | 75 | | `get_by_text("In stock")` | 20 | 20 | 20 | 20 | **0** | **0** | 20 | | `get_by_role("button", name="Add to basket")` | 20 | 20 | 20 | 20 | **0** | **0** | 20 | | `get_by_role("button")` | 20 | 20 | 20 | 20 | 20 | 20 | 20 | Read the `v5` column first. Eight of the nine hand-written selectors returned zero against the fully redesigned page. The ninth is the sloppy `article p:nth-of-type(1)`, which kept all 40 matches, and that sounds like a win until you reach the `v6` column. Three of the five Playwright built-ins survived `v5`. That is the headline, and it is also misleading if you stop there, because the per-change columns show the eight failures have nothing in common with each other. ## Renamed classes break the most selectors at once {#renamed-classes} `v1` changed nothing but class names. Four selectors went to zero in one commit: both CSS class selectors, the class-matching XPath, and the positional XPath that happened to be anchored on a class at the top. The five that lean on tag structure instead of class names did not notice. This is the cheapest break for a site to cause and the most common. A design-system migration from hand-rolled classes to BEM, or a move to Tailwind, or a build that starts emitting hashed CSS module names, renames every class on the page without any visible change at all. Your crawl went to zero rows and the page looks identical in a screenshot. The defence is not a cleverer class selector. It is picking an axis the redesign is not touching. Playwright's locator guidance says it plainly: "To make tests resilient, we recommend prioritizing user-facing attributes and explicit contracts such as `page.getByRole()`." Role is derived from the element's semantics and ARIA, so `get_by_role("button")` held at 20 through every variant including `v5`. The link locator keyed on an accessible name held through all seven. We worked through the attribute-anchoring side of this, including which attributes actually stay put, in [XPath and CSS selectors that survive redesigns](https://www.sparkproxy.io/blog/xpath-and-css-selectors-web-scraping). ## A new wrapper breaks the ones renames spared {#new-wrapper} `v3` inserted one `` between each `
Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Changed copy is where Playwright's semantics end

v4 is the column that should temper any enthusiasm for built-in locators. "In stock" became "Available now", "Add to basket" became "Add to bag". No markup changed at all. get_by_text("In stock") went from 20 matches to zero, and so did get_by_role("button", name="Add to basket").

Playwright's semantic locators are semantic about two axes, role and accessible name. Role is genuinely structural and held everywhere. Accessible name is a string, and a string is content, so pinning one is no more durable than pinning a class. Rewriting a stock badge is a copy ticket a content editor files, not an engineering change, and it will break a getByText scraper on a page whose DOM is byte-identical.

This is the precise gap AgentQL is selling into. A query that says availability(whether the item is in stock) is asking about meaning, and meaning did not change between "In stock" and "Available now". Whether AgentQL resolves that correctly on this fixture I cannot tell you, because resolving it requires a paid key and a network call, and I am not going to report an unmeasured number as a result. What I can show is my own stand-in paying the price of the same problem: when I wrote a locator to find the stock paragraph without AgentQL, I had to feed it re.compile(r"In stock|Available now"), which means I had to already know both strings. A semantic engine's pitch is that you do not.

One more Playwright behaviour to keep in the frame, from the locators doc: "Locators are strict. This means that all operations on locators that imply some target DOM element will throw an exception if more than one element matches." Counting with .count() is exempt. That strictness is the loudness. A locator that suddenly matches two things raises instead of silently taking the first.

AgentQL's resilience claim, and its release dates

AgentQL's own words, from its query-language concepts page, with no measurement from me attached:

  • Queries are described as self-healing: "in the face of dynamic content and changing page structures, AgentQL still returns the same results."
  • "Elements are found based on their meaning and context, not just their position in the DOM, making them more: Stable even when website layouts change over time."
  • "a single AgentQL query could extract listing data from both Redfin and Zillow, and continue to function even after site updates, A/B tests, and even redesigns."

Those are vendor claims. They are also the right claims to make for this product category, and the v1/v3 inversion above is the reason the category exists. Treat the specific durability as unverified until you have run your own targets against your own key.

Maintenance, from registries and the GitHub API on 9 October 2026, since "actively developed" is a claim that needs dates:

SignalAgentQLPlaywright
Latest Python release`agentql` 1.19.0, uploaded 2026-05-13`playwright` 1.63.0 installed here
Latest JS release`agentql` 1.18.1, published 2026-02-21`playwright` 1.64.0, published 2026-10-07
Nightly channelnone published`next` tag 1.65.0-alpha-2026-10-09
GitHub stars1,480 on `tinyfish-io/agentql`97,336 on `microsoft/playwright`
Forks / open issues177 / 146,565 / 167
Repo created2024-02-202019-11-15
Last push to default branch2026-10-082026-10-09
LicenceMITApache-2.0

Read that carefully rather than as a scoreboard. AgentQL is maintained, not abandoned: the repo took commits on 1, 2 and 8 October 2026. The five most recent were a secrets-scanner workflow edit, three dependency bumps (urllib3 2.7.0 to 2.8.0, axios 1.18.1 to 1.20.0, brace-expansion 1.1.18 to 1.1.21) and a CODEOWNERS change, so housekeeping rather than feature work. The PyPI package has shipped 47 releases since 0.3.0 on 2024-03-25. What the dates do say is that the cadence slowed: the newest Python release is from 13 May 2026, roughly five months before this post, and the JavaScript package sits a minor version behind at 1.18.1 from February 2026. Playwright cut 1.64.0 on 7 October 2026, two days before this post, and publishes a nightly alpha every day. If your team writes JavaScript, note that version skew explicitly, because features in the 1.19.0 release notes such as the TF_BROWSER remote-browser profile are Python-only until the JS package catches up.

The round trip you buy with every query

The REST surface is a single POST:

curl -X POST https://api.agentql.com/v1/query-data \
  -H "X-API-Key: $AGENTQL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "{ products[] { name price(integer) } }",
    "url": "https://books.toscrape.com/",
    "params": { "mode": "fast", "wait_for": 0, "is_scroll_to_bottom_enabled": false }
  }'

The response is {"data": {...}, "metadata": {"request_id": "...", "screenshot": null}}. mode defaults to "fast", which the docs describe as a mode that "sacrifices some depth of analysis for speed"; "standard" is the one that "ensures a thorough response that may take slightly longer." No latency figures are published for either, which is fair, because they would depend on the page.

I can measure one component of it. Five unauthenticated POSTs to https://api.agentql.com/v1/query-data from this host returned HTTP 401 in 715, 727, 731, 780 and 1,171 ms, median 731 ms, with the TLS handshake alone taking 456 to 899 ms. That is a rejection, not a query, so read it as a floor on per-call latency from this network rather than as AgentQL's performance. Real calls add page fetch and model inference on top.

Now the comparison that makes the floor concrete. Resolving locators inside the page, 50 warm iterations each over the same twenty cards:

Operationp50p95
`page.locator("article.product_pod h3 a").all_inner_texts()`1.49 ms2.28 ms
`page.get_by_role("link").all_inner_texts()`1.83 ms2.19 ms

1.83 ms against a 731 ms floor is a factor of roughly 400, and the floor is the optimistic end. In a loop that touches one page per iteration, a per-page semantic query is not a small constant, it is the dominant term. Two published limits bound it harder than latency does: the Starter tier allows 10 API calls per minute and Professional allows 50. Fifty calls a minute is a hard ceiling of 3,000 pages an hour no matter how many workers you run, so a 100,000-page crawl needs at minimum 2,000 minutes, about 33 hours, of rate-limited query time alone.

What a query costs when you run 100,000 of them

Listed on agentql.com/pricing on 9 October 2026, in USD:

TierMonthlyIncluded callsOverage per callRate limitRemote browser
Free trialn/a300n/a10/min1 hr, 1 concurrent session
Starter$050/month$0.0210/min10 hrs, then $0.12/hr, 5 concurrent
Professional$9910,000/month$0.01550/min500 hrs, then $0.10/hr, 100 concurrent
EnterpriseCustomnot listednot listednot listednot listed

One arithmetic walk, mine not theirs, assuming one query per page: 100,000 pages on Professional is 10,000 included calls plus 90,000 overage calls at $0.015, so $1,350 plus the $99 subscription, $1,449 for the month. On Starter the same crawl is 50 free calls plus 99,950 at $0.02, which is $1,999, so the $99 tier pays for itself somewhere around 6,600 pages.

Two caveats that matter more than the arithmetic. First, the pricing page does not define what counts as one API call for a list query, so whether products[] returning twenty rows bills as one call or twenty is not something I can tell you from published material; the only per-item billing AgentQL documents is for documents, where "the query_document function consumes 1 API call per image...and 1 API call for each page within a PDF." Ask before you model it. Second, remote browser hours are billed separately from calls, so if you use AgentQL's hosted browser (POST /v1/tetra/sessions, which returns a cdp_url like wss://[session_id].tetra.agentql.com) you have two meters running.

Playwright's selector cost, for completeness, is $0.00 and 1.8 ms. The cost of Playwright selectors is engineer time after a redesign, and that is a real number, just not one on an invoice.

Resolve once, reuse many: the cache nobody ships for you

Here is the architecture that makes the economics work, and it is not in either project's docs.

A site does not have 100,000 layouts. It has two or three page templates and 100,000 URLs. If you query semantically once per URL, you pay 100,000 times for three answers. So resolve semantically on a cache miss, derive a cheap local selector from whatever came back, and reuse that selector until it stops matching.

The hinge is get_by_prompt() returning a Playwright Locator. AgentQL's Page reference lists query_elements, query_data, get_by_prompt, wait_for_page_ready_state and enable_stealth_mode, and none of them exposes the CSS or XPath that AgentQL resolved internally. So derive one yourself from the element the Locator points at:

DERIVE = r"""el => {
  if (el.id) return '#' + CSS.escape(el.id);
  for (const a of ['data-testid', 'data-test', 'data-qa', 'itemprop']) {
    const v = el.getAttribute(a);
    if (v) return `[${a}="${v}"]`;
  }
  const cls = [...el.classList].filter(c => !/\d/.test(c) && c.length > 2);
  return el.tagName.toLowerCase() + (cls.length ? '.' + cls.map(CSS.escape).join('.') : '');
}"""


def template_key(page):
    """Cheap DOM fingerprint. Changes when a redesign ships, not when content does."""
    return page.evaluate(
        "() => ['article','section','li','h3','p','span','div']"
        ".map(t => t + ':' + document.querySelectorAll(t).length).join('|')"
    )


def prices(page, cache):
    key = template_key(page)
    hit = cache.get(key)
    if hit:
        loc = page.locator(hit["selector"])
        if loc.count() == hit["n"]:
            return loc.all_inner_texts(), "cache"

    # cache miss: pay for exactly one semantic resolution
    semantic = page.get_by_prompt("the price of each product in the listing")
    if semantic is None:
        raise LookupError(f"semantic query found nothing on template {key}")
    selector = semantic.evaluate(DERIVE)
    loc = page.locator(selector)
    cache[key] = {"selector": selector, "n": loc.count()}
    return loc.all_inner_texts(), "resolved " + selector

I ran that pattern against the fixture, substituting a local locator for get_by_prompt() because the documented return type is the same Locator either way. Four page loads, across the untouched page and the fully redesigned one:

RunPageOutcome
1`v0`cache miss, resolved, derived `p.price_color`, 20 matches
2`v0`cache hit on `p.price_color`, no semantic call
3`v5`fingerprint changed (`span:0` to `span:20`), miss, derived `p.c-card__price`, 20 matches
4`v0`cache hit again, both templates now stored

Two semantic resolutions for two template shapes, then free forever. Scale that and the Professional tier's 10,000 included calls stops being a crawl budget and becomes a redesign budget, which is a much easier number to live inside. The fingerprint is deliberately coarse, tag counts rather than a hash of the markup, so that a page with a different number of products still hits the same cache entry while a page with a new wrapper element does not.

Two failure modes to design for. The loc.count() == hit["n"] check is what makes staleness loud; drop it and a selector that now matches nothing returns an empty list that looks like an empty page. And if you key on something content-dependent, cache entries will multiply until every URL has its own, at which point you are paying per page again with extra steps.

The fetch layer under both approaches

Neither tool addresses the problem that arrives before selection: being allowed to load the page at all. AgentQL's remote browser accepts a browser_profile of "light" or "stealth" and a proxy object, and the Python SDK exposes enable_stealth_mode(webgl_vendor, webgl_renderer, nav_user_agent). Playwright has --proxy-server and per-context proxies. Both are fingerprint and routing concerns rather than selector concerns, and we covered the detection side separately in headless browser detection and the proxy wiring in web scraping with Playwright and proxies.

Where the fetch layer interacts with this comparison is the js_scenario idea: if the only reason you need a browser is a click or a scroll before the markup you want exists, you may not need a selector engine in your own process at all. SparkProxy's Scraping API runs the browser and the interaction server-side, at https://scrape.sparkproxy.io/api/v1 with an X-API-Key header:

curl -G "https://scrape.sparkproxy.io/api/v1" \
  -H "X-API-Key: $SPARKPROXY_KEY" \
  --data-urlencode "url=https://www.sparkproxy.io/pricing" \
  --data-urlencode "render_js=true" \
  --data-urlencode "proxy_type=premium" \
  --data-urlencode "format=md" \
  --data-urlencode 'js_scenario=[{"wait":1200},{"click":"button.load-more"},{"wait":800}]'

Four parameter facts worth having straight before you cost it out. render_js is on by default and bills 5 credits; set render_js=false for static HTML and the same request bills 1. js_scenario adds 5 credits, requires render_js=true, and caps at 50 steps. country_code adds 5. format governs content conversion only, one of html, md, mdx, json, screenshot or pdf, so format=json is not a response envelope; json_response=true is the only switch that produces one, carrying job_id, status_code, duration_ms, credits_used and a body field that arrives base64-encoded, which has to be decoded before any marker match runs against it. A batch requires render_js=false and the whole batch bills a single credit. A new account opens with 1,000 free credits, and the credit pricing breakdown has the per-parameter table.

Underneath all of it the routing is a plan, not a meter. Entry pricing for SparkProxy residential reads "Starting from $75/month" on sparkproxy.io, checked 9 October 2026, quoted in USD. Plans are packs rather than per-gigabyte tiers: unlimited bandwidth is included, the pool is 1M+ premium IPs in 80+ countries, rotation is either random or on a 5-minute timer, endpoints speak HTTP, HTTPS and SOCKS5, auth is IP whitelist or user:pass, the port runs at 1 Gbps, and the trial lasts 24 hours without a card. Because the billing unit is the plan and its concurrency rather than traffic consumed, the retry storm that follows somebody else's redesign does not arrive as a bandwidth invoice. Term discounts are 5% at three months, 10% at six and 15% at twelve.

AgentQL vs Playwright selectors, sourced side by side

AgentQLPlaywright locators
What you writebraced query naming fields, `[]` for repeats, free-text hints in parenthesesa locator method plus a string or regex
Resolved whereAgentQL's servers, over HTTPSinside the page, in the renderer
Uses an LLMyes, "GPT-4, Llama, and Gemini as well as our proprietary model" per their docsno
Measured resolution costnot measured; 731 ms median round-trip floor to the API from this host1.83 ms p50 for `get_by_role("link")` over 20 cards
Survives renamed classesclaimed, unverified hereyes for role locators, no for CSS class selectors
Survives a new wrapper elementclaimed, unverified hereyes for role and text locators, no for child-path selectors
Survives changed copythe explicit pitch, unverified hereno for `getByText` and name-pinned roles, yes for bare roles
Failure signaldocumented as `None` from `get_by_prompt()`zero count, or a strict-mode exception on ambiguity
Price$0 Starter / $99 Professional per month, $0.02 and $0.015 per call overfree
Throughput ceiling10 or 50 API calls per minute by tierwhatever your browsers do
Latest release read todayPyPI 1.19.0 on 2026-05-13, npm 1.18.1 on 2026-02-21npm 1.64.0 on 2026-10-07
Licence and starsMIT, 1,480 starsApache-2.0, 97,336 stars

Sources: agentql.com/pricing, docs.agentql.com (query language, under the hood, REST reference, Python Page reference, fast and standard modes, release notes), playwright.dev/docs/locators, the PyPI and npm registries, and the GitHub API, all read 9 October 2026. The agentql.com nameservers resolved to Route 53 and both the marketing site and docs returned 200 on the same date.

Which one to reach for

Three rules that fall out of the matrix rather than out of preference.

Write role locators first, and pin names only when you must. Both bare-role locators in the battery, get_by_role("button") and get_by_role("listitem"), held their counts through all seven variants including the copy pass. The moment you add name="Add to basket" you have traded structural durability for a content dependency, and content is the cheapest thing on a page to change.

Reach for a semantic query where the field is defined by meaning and the page shape is out of your control. Cross-site extraction is the clearest case: one query against twenty competitor catalogues beats twenty selector sets, and the per-call price is trivially cheaper than twenty maintainers. Polling a handful of known pages on a schedule is the clearest case against it, because you will pay 731 ms and a call for an answer that has not changed since yesterday.

Then cache the resolution, not the result. The measured pattern above turned a per-page cost into a per-template cost, two semantic calls for two layouts. That is the move that makes the Professional tier's 10,000 calls a comfortable allowance instead of a crawl budget.

The last thing to carry, and the reason none of this is really about accuracy: a loud failure is usually cheaper than a quiet wrong match. In the browser-automation layer, loud is the default, and the matrix shows why. A dead locator returns zero, strict mode throws on ambiguity, and get_by_prompt() is documented to hand back None. Those all stop a pipeline. The quiet case is the one to engineer against, and I measured one: article p:nth-of-type(1) matched 40 elements on the untouched page and 20 on v6, and its first match changed from an empty string to the price, because the

it used to land on became a

. Same query, same shape of answer, different field. No exception, no zero row count, nothing to alert on. Any selector whose correctness depends on ordinal position can do that to you, whichever engine resolves it, which is why the cache check above compares the match count and not just its truthiness. Pick the approach whose breakage you can see in a log, then add the assertion that turns the invisible case into a visible one.

Frequently asked questions

FAQ

On top of it. The Python and JavaScript SDKs wrap a Playwright page, agentql.wrap() casts it, and get_by_prompt() returns a Playwright Locator. You keep Playwright for navigation, waiting and clicking, and use AgentQL only for the find-the-element step.

AgentQL claims they do, describing queries as self-healing and saying elements are "found based on their meaning and context, not just their position in the DOM". That is the vendor's claim and I did not measure it here. What I did measure is that eight of the nine hand-written CSS and XPath selectors in my battery returned zero against a fully redesigned page, so the problem the claim addresses is real.

On the pricing page read 9 October 2026, in USD: Starter is $0/month with 50 free calls then $0.02 per call, and Professional is $99/month with 10,000 calls included then $0.015 per call. The page does not define whether a list query returning twenty rows bills as one call or twenty, so confirm that before you model a large crawl.

get_by_role() without a name. Both bare-role locators in my battery held the same count through renamed classes, a DOM reorder, a new wrapper, a copy rewrite and all four combined. Adding name="..." reintroduces a content dependency that a copy change breaks on byte-identical markup.

Cache the resolved selector rather than the data. Derive a CSS selector from the element get_by_prompt() hands back, key it on a coarse DOM fingerprint, and re-query semantically only when the fingerprint changes or the cached selector stops matching the expected count. In my test that was two semantic resolutions for two template shapes instead of one per page.

Yes, with a slowing cadence. The tinyfish-io/agentql repo took commits on 2 and 8 October 2026 and has 1,480 stars under MIT, but the newest PyPI release is 1.19.0 from 2026-05-13 and the npm package is still on 1.18.1 from 2026-02-21, so JavaScript users are a minor version behind Python.

Special Discount · 20% off

Get 20% off your first month

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Save up to 15% more on quarterly, half-yearly and yearly plans

Claim Discount

About the Author

SparkProxy's Technical Team runs the proxy networks (datacenter, ISP and residential) and the Scraping API behind sparkproxy.io, and maintains the crawlers that consume them. That is why every number above is either measured on our own hardware and labelled as such, or lifted from a vendor's own documentation with the date it was read. Technical questions reach us at support@sparkproxy.io; the API parameters cited here are documented at https://www.sparkproxy.io/docs/scraping-api/.

Keep reading

Related articles

Insomniac Browser Alternatives, Scored by Layer

Insomniac Browser alternatives scored by which identity layer each one really covers, with prices verified in October 2026 and a measured three-profile test.

SparkProxy·Comparisons