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.
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: Scraping at scale? Skip the blocks. Fast, unblockable datacentre proxies with unlimited bandwidth. 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 This is the precise gap AgentQL is selling into. A query that says 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 AgentQL's own words, from its query-language concepts page, with no measurement from me attached: Those are vendor claims. They are also the right claims to make for this product category, and the Maintenance, from registries and the GitHub API on 9 October 2026, since "actively developed" is a claim that needs dates: 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 ( The REST surface is a single POST: The response is I can measure one component of it. Five unauthenticated POSTs to Now the comparison that makes the floor concrete. Resolving locators inside the page, 50 warm iterations each over the same twenty cards: 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. Listed on 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 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. 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 I ran that pattern against the fixture, substituting a local locator for 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 Neither tool addresses the problem that arrives before selection: being allowed to load the page at all. AgentQL's remote browser accepts a Where the fetch layer interacts with this comparison is the Four parameter facts worth having straight before you cost it out. 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. Sources: 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, 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 On top of it. The Python and JavaScript SDKs wrap a Playwright page, 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. Cache the resolved selector rather than the data. Derive a CSS selector from the element Yes, with a slowing cadence. The 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 plansv0 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 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").getByText scraper on a page whose DOM is byte-identical.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..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
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.Signal AgentQL Playwright 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 channel none published `next` tag 1.65.0-alpha-2026-10-09 GitHub stars 1,480 on `tinyfish-io/agentql` 97,336 on `microsoft/playwright` Forks / open issues 177 / 14 6,565 / 167 Repo created 2024-02-20 2019-11-15 Last push to default branch 2026-10-08 2026-10-09 Licence MIT Apache-2.0 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
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 }
}'
{"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.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.Operation p50 p95 `page.locator("article.product_pod h3 a").all_inner_texts()` 1.49 ms 2.28 ms `page.get_by_role("link").all_inner_texts()` 1.83 ms 2.19 ms What a query costs when you run 100,000 of them
agentql.com/pricing on 9 October 2026, in USD:Tier Monthly Included calls Overage per call Rate limit Remote browser Free trial n/a 300 n/a 10/min 1 hr, 1 concurrent session Starter $0 50/month $0.02 10/min 10 hrs, then $0.12/hr, 5 concurrent Professional $99 10,000/month $0.015 50/min 500 hrs, then $0.10/hr, 100 concurrent Enterprise Custom not listed not listed not listed not listed 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. Resolve once, reuse many: the cache nobody ships for you
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
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:Run Page Outcome 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 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
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.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}]'
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. AgentQL vs Playwright selectors, sourced side by side
AgentQL Playwright locators What you write braced query naming fields, `[]` for repeats, free-text hints in parentheses a locator method plus a string or regex Resolved where AgentQL's servers, over HTTPS inside the page, in the renderer Uses an LLM yes, "GPT-4, Llama, and Gemini as well as our proprietary model" per their docs no Measured resolution cost not measured; 731 ms median round-trip floor to the API from this host 1.83 ms p50 for `get_by_role("link")` over 20 cards Survives renamed classes claimed, unverified here yes for role locators, no for CSS class selectors Survives a new wrapper element claimed, unverified here yes for role and text locators, no for child-path selectors Survives changed copy the explicit pitch, unverified here no for `getByText` and name-pinned roles, yes for bare roles Failure signal documented 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 over free Throughput ceiling 10 or 50 API calls per minute by tier whatever your browsers do Latest release read today PyPI 1.19.0 on 2026-05-13, npm 1.18.1 on 2026-02-21 npm 1.64.0 on 2026-10-07 Licence and stars MIT, 1,480 stars Apache-2.0, 97,336 stars 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
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.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 Frequently asked questions
FAQ
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.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.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.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.
Related articles
Roxy Browser Alternatives for Cross-Border Sellers
Roxy Browser alternatives picked for cross-border selling: five vendors priced from their own pages, plus the four account layers no browser can touch.
Indigo Browser Alternatives: Check the Paper Trail
Indigo browser alternatives ranked on one test: does the vendor publish a dated record of what shipped? Plus which product indigobrowser.com actually is.
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.
