<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Extract by Zyte</title>
    <description>The latest articles on DEV Community by Extract by Zyte (extractdata).</description>
    <link>https://dev.to/extractdata</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F11159%2F9b0ab14b-3550-4e5e-b996-02b33c0912fa.jpg</url>
      <title>DEV Community: Extract by Zyte</title>
      <link>https://dev.to/extractdata</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/extractdata"/>
    <language>en</language>
    <item>
      <title>A data scientist's guide to stress-free product scraping</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/a-data-scientists-guide-to-stress-free-product-scraping-2on5</link>
      <guid>https://dev.to/extractdata/a-data-scientists-guide-to-stress-free-product-scraping-2on5</guid>
      <description>&lt;p&gt;It’s one of the fastest-growing job categories in business - data scientist positions are &lt;a href="https://www.bls.gov/ooh/math/data-scientists.htm#:~:text=27%25-,Employment%20of%20data%20scientists%20is%20projected%20to%20grow%2034%20percent,force%2C%20such%20as%20to%20retire." rel="noopener noreferrer"&gt;expected to swell 34%&lt;/a&gt; by 2034.&lt;/p&gt;

&lt;p&gt;Retail and e-commerce are embracing data science like no other industry because, in a competitive environment, &lt;a href="https://www.zyte.com/blog/how-price-extraction-is-fuelling-insights-for-modern-retailers/" rel="noopener noreferrer"&gt;price analytics&lt;/a&gt; has become a needle-mover.&lt;/p&gt;

&lt;p&gt;As a data scientist, your job is to find patterns, build models, and generate insights. To do that, you first need to reliably acquire web data. Competitor pricing, product specifications, consumer reviews - you name it, data scientists need it.&lt;/p&gt;

&lt;p&gt;The reality for data-gatherers, however, is that you spend 80% of your time scratching your head at 403 Forbidden responses, managing proxy pools, and angrily rewriting broken selectors when a target website adds a new field and changes up its CSS.&lt;/p&gt;

&lt;p&gt;But the days of simply firing off a GET request and peacefully parsing DOM elements are now largely over. The modern web is hostile to automated scripts, and traditional scraping playbooks are fundamentally broken.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Let’s look at why the classic approach now fails, the hidden costs of modern workarounds, and how you can get back to actual data science using automated extraction.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Beautiful Soup vs. the modern web
&lt;/h2&gt;

&lt;p&gt;Let me show you the traditional approach to web scraping. It's the first thing you learn in a web data tutorial: you grab the requests library to fetch the page and BeautifulSoup to parse it.&lt;/p&gt;

&lt;p&gt;Here is what that looks like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://ecommerce-site.com/target-product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Status Code: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Title: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You expect to get a 200 OK response and a neatly formatted product name. Instead, you get a 403 Forbidden and a title that says &lt;em&gt;"Just a moment..."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Welcome to the brick wall. Your script didn't fail because your code was wrong; it failed because the server looked at your fingerprint. You didn’t present a browser fingerprint, and because you didn’t render the page with JavaScript you were instantly categorised as a bot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Down the rabbit hole
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Proxies and infrastructure: You realise your IP might be being blocked, so you look into residential proxies. You quickly discover you have to build complex rotation logic, handle retries, and manage bans manually.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Headless browsers: Because the site relies heavily on JavaScript to render the actual product price, requests aren't enough. So, you spin up Selenium or Playwright. Suddenly, your resource consumption skyrockets, and your scraping speed plummets to a crawl.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;HTML parsing: Even if you successfully manage the bans, you still have to write custom parsers for hundreds of different e-commerce layouts. And the moment a site updates its UI, your scraper breaks again.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;LLMs aren’t as helpful as you’d think: You decide to feed the raw HTML into an LLM to extract the data. It works to a degree, but there are context size problems, you can’t verify the data quality, and when you calculate the token costs for 100,000 product pages you realise you’ll likely need a second mortgage.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why bandwidth pricing is a trap (and why proxies aren't enough)
&lt;/h2&gt;

&lt;p&gt;Let’s be honest, web scraping isn’t free anymore and to get data at scale, proxies are mandatory. But there is a massive misconception that simply buying a proxy pool will solve all your problems.&lt;/p&gt;

&lt;p&gt;Traditional proxy providers charge by the gigabyte (ie, for &lt;em&gt;bandwidth&lt;/em&gt;). This sounds fine until you realise what you are actually paying for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Failed requests&lt;/strong&gt;: When a site throws a CAPTCHA or blocks your IP, you still pay for the bandwidth of that failed response.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Retries&lt;/strong&gt;: If it takes your scraper 10 attempts to successfully fetch a specific product page, you are paying for all nine failures.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Useless data&lt;/strong&gt;: You are paying to download heavy CSS, tracking scripts, and images you don't even need for your dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  One call does it all
&lt;/h2&gt;

&lt;p&gt;This is where &lt;a href="https://www.zyte.com/zyte-api/" rel="noopener noreferrer"&gt;Zyte API&lt;/a&gt; saves you. It is crucial to understand that Zyte API is &lt;em&gt;way&lt;/em&gt; more than just a proxy manager. While it does manage massive pools of residential and datacenter proxies under the hood, it is an all-in-one extraction engine. It handles the &lt;a href="https://www.zyte.com/zyte-api/headless-browser/" rel="noopener noreferrer"&gt;browser rendering&lt;/a&gt;, manages the &lt;a href="https://docs.zyte.com/zyte-api/usage/features.html#zapi-sessions" rel="noopener noreferrer"&gt;sessions&lt;/a&gt;, manages the &lt;a href="https://www.zyte.com/zyte-api/unblocking/" rel="noopener noreferrer"&gt;anti-bot challenges&lt;/a&gt;, and takes all the pain away from accessing publicly available web data.&lt;/p&gt;

&lt;p&gt;Because Zyte controls the whole pipeline, we offer a different pricing model: You only pay for successful requests. If Zyte has to rotate through five different IP addresses, spin up a headless browser, and retry a request multiple times to succeed, &lt;em&gt;you don't pay for the retries&lt;/em&gt;. You only pay when the API returns a clean 200 OK with your data.&lt;/p&gt;

&lt;p&gt;This predictability allows you to accurately forecast your costs (eg, 100,000 product pages equals a fixed cost), eliminating the unpredictable &lt;a href="https://www.zyte.com/blog/7-myths-about-web-scraping-apis-busted/" rel="noopener noreferrer"&gt;bandwidth tax of modern scraping&lt;/a&gt;. There’s still some nuance in which websites you want the data from, but we consistently come top in &lt;a href="https://www.zyte.com/blog/zyte-leads-proxyway-2025-web-scraping-api-report/" rel="noopener noreferrer"&gt;impartial benchmarks and tests&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zyte API and AI Extraction
&lt;/h2&gt;

&lt;p&gt;As a data scientist, you don't want to be a proxy manager, and you certainly don't want to be coding hundreds of HTML parsers. You just want structured data to do your job.&lt;/p&gt;

&lt;p&gt;Zyte API acts as a single endpoint that abstracts away all the infrastructure headaches. But the real magic for e-commerce data is its &lt;a href="https://www.zyte.com/zyte-api/ai-extraction/" rel="noopener noreferrer"&gt;AI Extraction&lt;/a&gt; feature.&lt;/p&gt;

&lt;p&gt;By simply passing 'product': True in your payload, Zyte uses behind-the-scenes AI and ML models to read the page data, identify key e-commerce information into our extensive pre-defined schema, and return a clean JSON object automatically.&lt;/p&gt;

&lt;p&gt;And if you need more, we also have a &lt;a href="https://docs.zyte.com/zyte-api/usage/extract/custom-attributes.html" rel="noopener noreferrer"&gt;custom attributes&lt;/a&gt; feature for you to use.&lt;/p&gt;

&lt;p&gt;In all this, you don’t need to specify CSS selectors, you don’t have to budget any spend on LLM tokens, and you don’t have to perform any browser management.&lt;/p&gt;

&lt;p&gt;All of this costs you time. Time that could be spent working on your data analysis and providing value to your clients or employer. As e-commerce is fast-moving, up-to-date data is crucial, and a simple website change that would break your pipeline is handled by our AI Extraction tool, making sure you always get a &lt;a href="https://docs.zyte.com/zyte-api/usage/reference.html#operation/extract/response/200/product" rel="noopener noreferrer"&gt;consistent schema&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What does the modern, stress-free approach look like? It’s just one API call:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;python&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;b64decode&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;api_response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.zyte.com/v1/extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://books.toscrape.com/catalogue/worlds-elsewhere-journeys-around-shakespeares-globe_972/index.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpResponseBody&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;productOptions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extractFrom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpResponseBody&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;desktop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;followRedirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;http_response_body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;httpResponseBody&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api_response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;product&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of wrestling with Beautiful Soup to find the right div, you get back instant e-commerce data in a dependable &lt;a href="https://docs.zyte.com/zyte-api/usage/reference.html#operation/extract/response/200/product" rel="noopener noreferrer"&gt;JSON schema&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Focus on data, not the extraction process
&lt;/h2&gt;

&lt;p&gt;The landscape of web scraping has shifted. Building homegrown infrastructure to try to manage anti-bot systems is a losing game of whack-a-mole that drains your time, budget, and sanity. It’s a specialist skill that we have in droves, so let us help.&lt;/p&gt;

&lt;p&gt;By offloading this to our all-in-one solution,&amp;nbsp; &lt;a href="https://www.zyte.com/zyte-api/" rel="noopener noreferrer"&gt;Zyte API&lt;/a&gt;, you bypass the infrastructure bottlenecks and unpredictable bandwidth taxes entirely.&lt;/p&gt;

&lt;p&gt;You stop building brittle scrapers and get back to what you were actually hired to do: building models and analysing data.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why is product scraping often considered stressful for data scientists?
&lt;/h3&gt;

&lt;p&gt;Product scraping can be stressful because e-commerce websites are notoriously dynamic. Frequent layout changes, aggressive bans, and the sheer volume of data can lead to broken spiders and inconsistent datasets, requiring constant manual maintenance.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does AI help in making web scraping stress-free?
&lt;/h3&gt;

&lt;p&gt;AI-powered tools (like Zyte API's AI extraction) eliminate the need for manual selector maintenance. Instead of writing and fixing CSS or XPath selectors every time a website changes its design, AI can automatically identify and extract product names, prices, and descriptions by "understanding" the page structure, significantly reducing developer workload.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.zyte.com/zyte-api/ai-extraction/" rel="noopener noreferrer"&gt;AI extraction&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Should data science teams build their scraping tools in-house or outsource them?
&lt;/h3&gt;

&lt;p&gt;While building in-house gives you total control, it often leads to high maintenance costs as you scale. For many teams, using a managed API is less stressful, as it allows data scientists to focus on analysing the data rather than the technical complexities of unblocking websites and maintaining infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free trial for Zyte API
&lt;/h3&gt;

&lt;p&gt;Yes, we offer a no credit card &lt;a href="https://www.zyte.com/zyte-api/" rel="noopener noreferrer"&gt;free trial&lt;/a&gt; with generous credit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/learn/a-data-scientists-guide-to-stress-free-product-scraping/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>datascience</category>
      <category>webscraping</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to parse HTML tables into structured data (CSV/Excel)</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/how-to-parse-html-tables-into-structured-data-csvexcel-26p9</link>
      <guid>https://dev.to/extractdata/how-to-parse-html-tables-into-structured-data-csvexcel-26p9</guid>
      <description>&lt;p&gt;You found the data you need. It's sitting right there in a neat HTML table. The problem is, it's stuck in a webpage — and you need it in a spreadsheet.&lt;/p&gt;

&lt;p&gt;Most developers assume this is a five-minute job. Then they open the page source, see a tangle of &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;tbody&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; tags — some with colspan attributes, some with nested tables inside them — and suddenly the five minutes becomes an afternoon.&lt;/p&gt;

&lt;p&gt;In this guide, you'll learn three things: how HTML tables are actually structured (so the parsing makes sense), how to extract clean tabular data using Python, and how to export it to CSV or Excel without losing formatting or running into encoding nightmares.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HTML tables are harder to parse than they look
&lt;/h2&gt;

&lt;p&gt;Here is a question worth answering before you write a single line of code: why do so many developers get tripped up by HTML table parsing when the data looks so simple on screen?&lt;/p&gt;

&lt;p&gt;The answer is that what you see in the browser and what's in the markup are often very different things. Browsers do a lot of silent repair work — closing unclosed tags, inferring missing elements, normalising malformed attributes. Your parser does not.&lt;/p&gt;

&lt;p&gt;A table that renders beautifully in Chrome might have no &lt;code&gt;&amp;lt;tbody&amp;gt;&lt;/code&gt; element at all (browsers add it implicitly), or it might have &lt;code&gt;colspan&lt;/code&gt; and &lt;code&gt;rowspan&lt;/code&gt; attributes that merge cells visually but leave gaps in the underlying data structure.&lt;/p&gt;

&lt;p&gt;There are three common structural issues you'll encounter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merged cells.&lt;/strong&gt; When a cell spans multiple columns or rows using &lt;code&gt;colspan&lt;/code&gt; or &lt;code&gt;rowspan&lt;/code&gt;, the raw HTML contains fewer cells than the rendered table suggests. A naive row-by-row parser will produce rows of unequal length, and your resulting CSV will be misaligned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested tables.&lt;/strong&gt; Some websites use tables for layout purposes — there may be a table inside a table cell. You need to make sure you're targeting the right table and not accidentally extracting layout scaffolding alongside your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing or inconsistent headers.&lt;/strong&gt; Not every HTML table uses a &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; element. Some use the first &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt; of the &lt;code&gt;&amp;lt;tbody&amp;gt;&lt;/code&gt; as a header row. Some have no headers at all. Your code needs to handle all three.&lt;/p&gt;

&lt;p&gt;Understanding these issues upfront means you can choose the right tool for the job, rather than debugging cryptic misalignment errors halfway through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up your environment
&lt;/h2&gt;

&lt;p&gt;Before writing any parsing code, install the two libraries you'll need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;requests beautifulsoup4 pandas openpyxl lxml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick note on what each does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;requests&lt;/code&gt; fetches the raw HTML from a URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;beautifulsoup4&lt;/code&gt; parses the HTML into a navigable tree&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pandas&lt;/code&gt; gives you a DataFrame, which handles table manipulation and export&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openpyxl&lt;/code&gt; is the engine pandas uses to write &lt;code&gt;.xlsx&lt;/code&gt; files&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lxml&lt;/code&gt; is a fast HTML parser that Beautiful Soup can use under the hood&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're working with a page that requires JavaScript to render its tables, &lt;code&gt;requests&lt;/code&gt; alone won't be enough — we'll cover that case later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simplest approach: pandas read_html
&lt;/h2&gt;

&lt;p&gt;For straightforward tables on static pages, pandas has a built-in function that does most of the work for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/data-table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# read_html returns a list of all tables found on the page
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; table(s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Inspect the first one
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;head&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pd.read_html()&lt;/code&gt; returns a list of DataFrames — one for each &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; element it finds on the page. If the page has three tables, you'll get a list of three DataFrames.&lt;/p&gt;

&lt;p&gt;To export the one you want to CSV:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To export to Excel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach works well when the table is simple and the page is static. When it doesn't work — because the table is dynamically rendered, you need more control over which table to target, or the structure is malformed — you'll need to go a level deeper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Targeting a specific table with Beautiful Soup
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pd.read_html()&lt;/code&gt; is convenient but not always precise. If the page has multiple tables and you need a specific one, Beautiful Soup lets you target it by its attributes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/data-table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lxml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Target a table by its ID
&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results-table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# Or by a class name
&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data-grid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# Parse it with pandas
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By converting the specific &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; element back to a string and passing it to &lt;code&gt;pd.read_html()&lt;/code&gt;, you get the precision of Beautiful Soup with the convenience of pandas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing tables manually when the structure is complex
&lt;/h2&gt;

&lt;p&gt;Sometimes you need full control — for example, when a table has &lt;code&gt;colspan&lt;/code&gt; or &lt;code&gt;rowspan&lt;/code&gt; attributes that pandas doesn't handle well by default, or when you want to map each cell to a specific header.&lt;/p&gt;

&lt;p&gt;Here is a function that handles merged cells correctly by tracking which cells have already been "filled" by a spanning cell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_table_with_spans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="c1"&gt;# Track cells occupied by rowspan/colspan
&lt;/span&gt;    &lt;span class="n"&gt;occupied&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row_idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;col_idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;td&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;th&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
            &lt;span class="c1"&gt;# Skip columns already occupied by a spanning cell
&lt;/span&gt;            &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;occupied&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;row_idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_idx&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
                &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;occupied&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;row_idx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_idx&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
                &lt;span class="n"&gt;col_idx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

            &lt;span class="n"&gt;cell_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;colspan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;colspan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;rowspan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rowspan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

            &lt;span class="c1"&gt;# Fill occupied tracking dict
&lt;/span&gt;            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rowspan&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;colspan&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="n"&gt;occupied&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;row_idx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;col_idx&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cell_text&lt;/span&gt;

            &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cell_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;col_idx&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;colspan&lt;/span&gt;

        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/complex-table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lxml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_table_with_spans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach fills in the gaps left by spanning cells, so every row in your output has the same number of columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling pages that require JavaScript
&lt;/h2&gt;

&lt;p&gt;If the table you need is rendered dynamically — meaning it doesn't appear in the raw HTML response but only after JavaScript executes — then &lt;code&gt;requests&lt;/code&gt; will return an empty or incomplete page.&lt;/p&gt;

&lt;p&gt;You have two options here.&lt;/p&gt;

&lt;p&gt;The first is to check whether the data comes from an API. Open your browser's developer tools, go to the Network tab, filter by XHR or Fetch requests, and reload the page. If the table data is loaded via an API call, you may be able to hit that endpoint directly with &lt;code&gt;requests&lt;/code&gt; and get the data as JSON — which is even easier to work with than HTML.&lt;/p&gt;

&lt;p&gt;The second option is to use a browser automation tool like Playwright or Selenium to render the page before parsing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/dynamic-table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_selector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Wait until the table is in the DOM
&lt;/span&gt;    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lxml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For sites with robust anti-bot protection, Playwright alone may not be enough. Zyte API handles browser rendering and unblocking as part of the same request, removing the need to manage a browser instance yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting cleanly to CSV and Excel
&lt;/h2&gt;

&lt;p&gt;Once you have your DataFrame, a few export settings are worth knowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For CSV:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# Don't write row numbers
&lt;/span&gt;    &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8-sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# utf-8-sig adds a BOM so Excel opens it correctly
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;utf-8-sig&lt;/code&gt; encoding is worth noting: plain &lt;code&gt;utf-8&lt;/code&gt; CSVs often display garbled characters when opened in Excel on Windows because Excel expects a byte order mark (BOM) to know the file is UTF-8. Using &lt;code&gt;utf-8-sig&lt;/code&gt; adds that marker automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For Excel:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sheet_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# Name the sheet something meaningful
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're writing multiple tables to the same Excel file, use an &lt;code&gt;ExcelWriter&lt;/code&gt; object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ExcelWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sheet_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Table 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sheet_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Table 2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Handling common problems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Duplicate column names.&lt;/strong&gt; If a table has two columns with the same header, pandas will automatically append suffixes (&lt;code&gt;.1&lt;/code&gt;, &lt;code&gt;.2&lt;/code&gt;) to disambiguate. You can rename columns afterwards with &lt;code&gt;df.columns = ["col_a", "col_b", ...]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixed data types in columns.&lt;/strong&gt; HTML tables store everything as strings. A column of numbers will be read as &lt;code&gt;object&lt;/code&gt; type in pandas. Use &lt;code&gt;pd.to_numeric(df["column"], errors="coerce")&lt;/code&gt; to convert, which will turn any non-numeric values into &lt;code&gt;NaN&lt;/code&gt; rather than raising an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty rows or header rows in the body.&lt;/strong&gt; Some tables repeat header rows partway through (common in long tables that span multiple printed pages). You can drop rows that duplicate your header with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Column Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Column Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rate limiting and bot detection.&lt;/strong&gt; If you're scraping multiple pages of tables, sending too many requests too quickly will often get you blocked. Add delays between requests with &lt;code&gt;time.sleep()&lt;/code&gt;, rotate user agents, or use Zyte API's proxy capabilities to handle this transparently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it all together
&lt;/h2&gt;

&lt;p&gt;Here is a complete script that fetches a page, finds the right table, handles encoding, and exports to both CSV and Excel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scrape_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lxml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;table_class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;table_class&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No matching table found on page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scrape_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;table_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results-table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8-sig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_excel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.xlsx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sheet_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Exported &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows and &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; columns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to do when the table is too complex to parse reliably
&lt;/h2&gt;

&lt;p&gt;Some tables are genuinely difficult: deeply nested structures, cells that contain embedded HTML, tables that are actually images, or pages where the same data appears across dozens of paginated tables.&lt;/p&gt;

&lt;p&gt;At that point, the question becomes whether the cost of maintaining a custom parser is worth it. Zyte API's automatic extraction can extract structured data from pages without you having to write or maintain selectors — which is particularly useful when table structures change between scraping runs or across different pages of the same site.&lt;/p&gt;

&lt;p&gt;For most standard use cases, though, the combination of Beautiful Soup and pandas described here will get you where you need to go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Parsing a table is often just the first step. Once you have clean structured data, you'll likely want to store it somewhere durable, schedule the scrape to run regularly, or combine it with data from multiple pages.&lt;/p&gt;

&lt;p&gt;If the website you're scraping uses JavaScript-heavy rendering or has anti-bot measures in place, take a look at our guides on &lt;a href="https://www.zyte.com/learn/web-scraping-dynamic-websites-with-zyte-api/" rel="noopener noreferrer"&gt;web scraping dynamic websites with Zyte API&lt;/a&gt; and &lt;a href="https://www.zyte.com/learn/from-basic-to-advanced-ways-of-managing-bans-in-web-scraping/" rel="noopener noreferrer"&gt;managing bans in web scraping&lt;/a&gt;. If you want to run this at scale, our guide to &lt;a href="https://www.zyte.com/learn/large-scale-web-scraping-with-python/" rel="noopener noreferrer"&gt;large-scale web scraping with Python&lt;/a&gt; covers the infrastructure decisions worth making before you scale up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/learn/how-to-parse-html-tables-into-structured-data-csv-excel/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webscraping</category>
      <category>pandas</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Inside Scrapy's RemoteControl extension</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Tue, 06 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/inside-scrapys-remotecontrol-extension-4702</link>
      <guid>https://dev.to/extractdata/inside-scrapys-remotecontrol-extension-4702</guid>
      <description>&lt;p&gt;Scrapy 2.19 added an extension called &lt;a href="https://github.com/scrapy/scrapy/blob/master/scrapy/extensions/remote_control.py" rel="noopener noreferrer"&gt;&lt;code&gt;RemoteControl&lt;/code&gt;&lt;/a&gt;, and it's on by default, which means every crawl you start on the asyncio reactor is already running a small HTTP server that will execute Python inside the crawl for anything holding the right token. &lt;a href="https://github.com/scrapy/scrapy-mcp-official" rel="noopener noreferrer"&gt;Scrapy MCP&lt;/a&gt; is the client it was built for, and the &lt;a href="https://www.zyte.com/blog/announcing-scrapy-mcp/" rel="noopener noreferrer"&gt;announcement post&lt;/a&gt; covers that side, but the extension doesn't need MCP at all. This post is about the piece underneath: how it starts, how it's secured, what the code you send can reach, and what you can actually do with it on a running crawl, from reading queues and testing selectors to fixing throughput without a restart. It ends with the traps, methods that look like reads but change state, and what changes once the crawl runs through &lt;a href="https://scrapy-zyte-api.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;scrapy-zyte-api&lt;/a&gt;. Every number below came from a real crawl, and every snippet works the same whether an agent sends it through Scrapy MCP or you send it with &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. How RemoteControl works&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RemoteControl&lt;/code&gt; is listed in Scrapy's default extensions and controlled by the &lt;code&gt;REMOTE_CONTROL_ENABLED&lt;/code&gt; setting, which defaults to &lt;code&gt;True&lt;/code&gt;. It needs asyncio support, so on a crawl without it the extension switches itself off with a warning rather than failing the crawl.&lt;/p&gt;

&lt;p&gt;When the engine starts, the extension starts an &lt;a href="https://docs.aiohttp.org/" rel="noopener noreferrer"&gt;aiohttp&lt;/a&gt; server bound to &lt;code&gt;127.0.0.1&lt;/code&gt; on a random free port, generates a random bearer token, and only then writes a job file describing the crawl. You can see it happen in the crawl's own log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run scrapy crawl books &lt;span class="nt"&gt;-o&lt;/span&gt; books.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[scrapy.extensions.remote_control] INFO: Remote control HTTP server listening on port 33093 (job 276910-b678ab03dbe24747a1fc0df7d3404417)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux the job file lands in &lt;code&gt;~/.local/state/scrapy/job_files/&lt;/code&gt;, named after the process ID plus a random suffix, and the &lt;code&gt;REMOTE_CONTROL_JOBS_DIR&lt;/code&gt; setting moves it somewhere else. The directory is created with &lt;code&gt;700&lt;/code&gt; permissions and the file is written atomically with &lt;code&gt;600&lt;/code&gt;, because the token inside it is the only thing standing between a local process and code execution in your crawl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;276910&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;33093&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"f_lW...pkkXI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"project"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp_demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scrapy_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.19.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1790316836.763313&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the engine stops, the extension deletes the job file first and then shuts the server down. That only happens on a clean shutdown, though. A crawl that's killed or crashes leaves its file behind, and I found 23 of them on my machine from earlier runs, every one pointing at a process that no longer existed. Anything reading these files, Scrapy MCP's &lt;code&gt;list_jobs()&lt;/code&gt; included, has to check the process is still alive before trusting one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two endpoints
&lt;/h3&gt;

&lt;p&gt;The server has two routes, and both check the &lt;code&gt;Authorization: Bearer&lt;/code&gt; header on every request, comparing it with &lt;code&gt;hmac.compare_digest&lt;/code&gt;. Without the token you get nowhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:33093/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"error": "unauthorized"}   (HTTP 401)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With it, &lt;code&gt;GET /status&lt;/code&gt; describes the crawl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://127.0.0.1:33093/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"pid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;276910&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"project"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp_demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scrapy_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.19.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1790316836.761879&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;POST /execute&lt;/code&gt; takes a JSON body with &lt;code&gt;code&lt;/code&gt; and an optional &lt;code&gt;timeout_sec&lt;/code&gt;, runs the code inside the crawl, and returns whatever it printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"code": "print(crawler.stats.get_value(\"response_received_count\"))"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://127.0.0.1:33093/execute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"traceback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"elapsed_sec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;status&lt;/code&gt; is one of &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt; (the code raised, and &lt;code&gt;traceback&lt;/code&gt; has the details), &lt;code&gt;compile_error&lt;/code&gt; (it didn't parse), or &lt;code&gt;timeout&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What your code can see
&lt;/h3&gt;

&lt;p&gt;The code is compiled with top-level &lt;code&gt;await&lt;/code&gt; allowed and run with three names already defined: &lt;code&gt;crawler&lt;/code&gt;, the live &lt;code&gt;Crawler&lt;/code&gt; instance; &lt;code&gt;stash&lt;/code&gt;, a dictionary that persists between calls; and &lt;code&gt;print&lt;/code&gt;, swapped for a version that writes into the response instead of the crawl's stdout. It runs on the crawl's own event loop, inside the same process, so there's no sandbox and no copy of the state. A &lt;code&gt;print()&lt;/code&gt; reads the actual &lt;code&gt;Crawler&lt;/code&gt; that's driving the actual requests, and a line that mutates something mutates the real crawl.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stash&lt;/code&gt; is what lets separate calls build on each other. Store a reading in one call, and a later call can pick it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;stash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_received_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_received_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;then:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;t0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;now:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;then: 5 now: 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's cleared when the crawl stops.&lt;/p&gt;

&lt;h3&gt;
  
  
  Limits, and the one that bites
&lt;/h3&gt;

&lt;p&gt;Each call gets a timeout, 30 seconds by default and capped at 600 by &lt;code&gt;REMOTE_CONTROL_TIMEOUT_DEFAULT&lt;/code&gt; and &lt;code&gt;REMOTE_CONTROL_TIMEOUT_MAX&lt;/code&gt;, and output is truncated past 64 KB, tracebacks past 16 KB. An &lt;code&gt;await asyncio.sleep(5)&lt;/code&gt; with &lt;code&gt;timeout_sec&lt;/code&gt; set to 1 comes back as &lt;code&gt;{"status": "timeout", ..., "elapsed_sec": 1.001}&lt;/code&gt;, exactly as you'd hope.&lt;/p&gt;

&lt;p&gt;The catch is that the timeout can only cancel code at an &lt;code&gt;await&lt;/code&gt;. Synchronous code runs to the end, and while it runs it holds the event loop that the whole crawl shares. The same test with &lt;code&gt;time.sleep(4)&lt;/code&gt; instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"traceback"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"elapsed_sec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ignored the one-second timeout, and a &lt;code&gt;/status&lt;/code&gt; request I sent a second into it took three seconds to answer, because nothing else on that loop could run, the crawl included. Keep snippets short, prefer &lt;code&gt;await asyncio.sleep()&lt;/code&gt; to &lt;code&gt;time.sleep()&lt;/code&gt;, and don't loop over something large synchronously on a crawl you care about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Scrapy MCP fits
&lt;/h3&gt;

&lt;p&gt;Scrapy MCP is a thin layer over all of this: &lt;code&gt;list_jobs()&lt;/code&gt; reads the job files, &lt;code&gt;status(job_id)&lt;/code&gt; calls &lt;code&gt;/status&lt;/code&gt;, &lt;code&gt;execute(job_id, code)&lt;/code&gt; calls &lt;code&gt;/execute&lt;/code&gt;, and &lt;code&gt;inspection_reference()&lt;/code&gt; gives the agent a guide to Scrapy's internals so it knows what to ask for. Everything from here on is code sent to &lt;code&gt;/execute&lt;/code&gt;, so it works the same from either.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flcyk58fguki3g8a944lz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flcyk58fguki3g8a944lz.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. Inspecting progress, queues, and active components&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once attached, the scheduler's queue classes and the downloader's per-domain slots are live objects, not documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheduler&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pqclass:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pqclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mqclass:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mqclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dqclass:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dqclass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dqclass&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;downloader&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delay=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;concurrency=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transferring=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transferring&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queue=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pqclass: DownloaderAwarePriorityQueue
mqclass: ScrapyRequestQueue
dqclass: ScrapyRequestQueue
pending: 0
books.toscrape.com delay= 3.0 concurrency= 1 transferring= 0 queue= 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://github.com/scrapy/scrapy/blob/master/scrapy/pqueues.py" rel="noopener noreferrer"&gt;&lt;code&gt;DownloaderAwarePriorityQueue&lt;/code&gt;&lt;/a&gt; is the current default. Reading its actual source: it picks the slot with the fewest active downloads, and when several slots are tied on that count, it round-robins among just those tied slots rather than always picking the same one. "Round-robin" alone undersells it, the active-download count is what decides most of the time; the round-robin only breaks ties. &lt;code&gt;pending: 0&lt;/code&gt; here just means the scheduler had already handed everything to the downloader, not that the crawl is idle.&lt;/p&gt;

&lt;p&gt;The same live-object approach works for enabled middleware. Settings tell you what's configured; the actual &lt;code&gt;middleware.middlewares&lt;/code&gt; tuple tells you what's installed, as real instances, in the order they run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;downloader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;middlewares&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;downloader middlewares:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;downloader middlewares: ['OffsiteMiddleware', 'RobotsTxtMiddleware', 'HttpAuthMiddleware',
'DownloadTimeoutMiddleware', 'DefaultHeadersMiddleware', 'UserAgentMiddleware',
'RetryMiddleware', 'MetaRefreshMiddleware', 'HttpCompressionMiddleware',
'RedirectMiddleware', 'CookiesMiddleware', 'HttpProxyMiddleware', 'DownloaderStats']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already know which class you're after, &lt;code&gt;crawler.get_downloader_middleware(Cls)&lt;/code&gt; (and the matching &lt;code&gt;get_spider_middleware&lt;/code&gt;, &lt;code&gt;get_item_pipeline&lt;/code&gt;, &lt;code&gt;get_extension&lt;/code&gt;, &lt;code&gt;get_addon&lt;/code&gt;) hands back that one instance directly, or &lt;code&gt;None&lt;/code&gt; if it isn't enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scrapy.downloadermiddlewares.robotstxt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RobotsTxtMiddleware&lt;/span&gt;
&lt;span class="n"&gt;mw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_downloader_middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;RobotsTxtMiddleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots middleware instance:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;robots middleware instance: &amp;lt;scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware object at 0x7f420566c980&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;3. Testing a selector against a live response&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The demo project's &lt;code&gt;stalled&lt;/code&gt; spider fetches pages fine but never yields an item, and it holds its last response on &lt;code&gt;self.last_response&lt;/code&gt;, which Scrapy doesn't do for you, so that a selector can be tested against real, already-fetched HTML, with no extra request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item_scraped_count:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item_scraped_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_received_count:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_received_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;sel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_response&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;used selector, matches:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article.product-pod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate selector, matches:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article.product_pod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;item_scraped_count: None
response_received_count: 9
used selector, matches: 0
candidate selector, matches: 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine responses in, zero items, and the crawl still looks healthy from the outside since pagination doesn't depend on the broken selector. The candidate selector matching 20 elements against the same live response is the actual fix, confirmed before touching the spider's source at all: &lt;code&gt;article.product-pod&lt;/code&gt; should be &lt;code&gt;article.product_pod&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your own spider won't have &lt;code&gt;last_response&lt;/code&gt; unless you add it, which is one line at the top of &lt;code&gt;parse()&lt;/code&gt;: &lt;code&gt;self.last_response = response&lt;/code&gt;. Without it, the same &lt;code&gt;trackref&lt;/code&gt; module section 5 uses can still hand you a response that's in memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scrapy.utils.trackref&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_oldest&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_oldest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HtmlResponse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;candidate selector, matches:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;article.product_pod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;response: &amp;lt;200 https://books.toscrape.com/catalogue/page-1.html&amp;gt;
candidate selector, matches: 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns the oldest response still referenced rather than the latest, and &lt;code&gt;None&lt;/code&gt; if nothing is holding one, so check the URL it prints before trusting the result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn06z2vh3pbpjjdo6z8ux.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn06z2vh3pbpjjdo6z8ux.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. Diagnosing and fixing throughput without restarting&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;books&lt;/code&gt; spider sets &lt;code&gt;DOWNLOAD_DELAY = 3.0&lt;/code&gt; and &lt;code&gt;CONCURRENT_REQUESTS_PER_DOMAIN = 1&lt;/code&gt; against a static site with no rate limit of its own. Measuring real throughput means reading a stat twice with a sleep in between, not trusting a setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_received_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_received_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pages/min:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before: 7 after: 10 pages/min: 18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's one call with an &lt;code&gt;await&lt;/code&gt; inside it, which fits comfortably in the default 30-second timeout and doesn't hold up the crawl while it waits. For a longer window, keep the first reading in &lt;code&gt;stash&lt;/code&gt; and take the second in a later call, as in section 1.&lt;/p&gt;

&lt;p&gt;Eighteen pages a minute. Rather than stop the crawl, edit &lt;code&gt;custom_settings&lt;/code&gt;, and restart it, patch the live slot directly. Slots are keyed by domain, so swap in one of the keys the section 2 snippet printed for your own crawl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;downloader&lt;/span&gt;
&lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;slots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;books.toscrape.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;before patch, delay=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;after patch, delay=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before patch, delay= 3.0
after patch, delay= 0.25
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same measurement again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before: 23 after: 51 pages/min: 168
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighteen to a hundred sixty-eight, on a crawl that never stopped running. It finished entirely before I got to the next thing I wanted to try, which is its own kind of answer: sometimes the fix really is one attribute.&lt;/p&gt;

&lt;p&gt;One thing worth being clear about: this is a live experiment, not a lasting fix. &lt;code&gt;slot&lt;/code&gt; is recreated the next time the crawl restarts, so &lt;code&gt;slot.delay = 0.25&lt;/code&gt; only holds for this run. If the new number is actually correct, put it in the spider's &lt;code&gt;custom_settings&lt;/code&gt; or the project's &lt;code&gt;settings.py&lt;/code&gt; afterward. What you did live was confirm the number before committing to it, not replace the need to commit to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;5. Checking what's still allocated&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Scrapy tracks live instances of &lt;code&gt;Request&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;, &lt;code&gt;Selector&lt;/code&gt;, &lt;code&gt;Item&lt;/code&gt;, and &lt;code&gt;Spider&lt;/code&gt; through &lt;a href="https://docs.scrapy.org/en/2.19/topics/leaks.html" rel="noopener noreferrer"&gt;&lt;code&gt;scrapy.utils.trackref&lt;/code&gt;&lt;/a&gt;. On a long-running crawl where memory keeps climbing, this tells you what's actually still referenced, instead of guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scrapy.utils.trackref&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;print_live_refs&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;
&lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StringIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect_stdout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print_live_refs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Live References

BooksSpider                         1   oldest: 10s ago
HtmlResponse                        5   oldest: 9s ago
Request                             6   oldest: 10s ago
Selector                            5   oldest: 9s ago
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RemoteControl only swaps out &lt;code&gt;print&lt;/code&gt; inside the snippet you send, and &lt;code&gt;print_live_refs()&lt;/code&gt; calls the real one, so without the redirect its output goes to the crawl's own stdout instead of back to you. A count that keeps growing across snapshots, especially &lt;code&gt;Response&lt;/code&gt;, usually means something is holding a reference too long: most often a response pinned in &lt;code&gt;request.meta&lt;/code&gt;, &lt;code&gt;cb_kwargs&lt;/code&gt;, or a callback closure that never lets go.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7quv1rugzwti5tuqmehc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7quv1rugzwti5tuqmehc.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;6. Mutation traps, and the edge of what's safe to patch&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above was either a plain attribute read or, in section 4, changing one number on a live object and watching the effect. A few methods look exactly the same from the outside but change the crawl's state the moment you call them, purely to "check" something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;df.request_seen(req)&lt;/code&gt; records that fingerprint. Call it just to check whether a URL was already seen, and the crawl now skips that URL for real.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scheduler.next_request()&lt;/code&gt; doesn't peek at the next request, it dequeues it, handing it to whoever called the method instead of the engine. Use &lt;code&gt;len(scheduler)&lt;/code&gt; for a count.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stats.set_value()&lt;/code&gt; / &lt;code&gt;stats.inc_value()&lt;/code&gt;, &lt;code&gt;engine.pause()&lt;/code&gt; / &lt;code&gt;unpause()&lt;/code&gt;, and popping anything off &lt;code&gt;mqs&lt;/code&gt;, &lt;code&gt;dqs&lt;/code&gt;, or a slot's own queue are all mutations dressed as inspection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat any method whose name suggests it advances, records, or pops something as off-limits unless mutating is actually the goal, and prefer reading an attribute over calling a method when both are available.&lt;/p&gt;

&lt;p&gt;Section 4 changed a number on a live object; going further, into replacing a callback's actual code while the crawl runs, is a sharper and much less forgiving version of the same idea. Two things worth knowing before attempting it, verified against Scrapy 2.19.0, kept short because neither was reproduced live here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where a queued request's callback comes from depends on which queue it's in.&lt;/strong&gt; A request sitting in the scheduler's in-memory queue holds the literal bound method it was created with, so reassigning that method on the spider afterward doesn't change requests already queued in memory. A request in a disk queue (&lt;code&gt;JOBDIR&lt;/code&gt; set) is serialized by name and only resolved with &lt;code&gt;getattr(spider, name)&lt;/code&gt; when it's dequeued, so it does pick up a reassigned method. Same "queued" request, two different behaviors, depending on which queue it landed in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swapping a callback's compiled code can defeat Scrapy's own source check.&lt;/strong&gt; By default (&lt;code&gt;WARN_ON_GENERATOR_RETURN_VALUE&lt;/code&gt;, on unless you turn it off), Scrapy calls &lt;code&gt;inspect.getsource()&lt;/code&gt; on a generator callback once, caching the result so it isn't repeated on every call. If you replace that callback's &lt;code&gt;__code__&lt;/code&gt; with one compiled from a string sent to &lt;code&gt;/execute&lt;/code&gt;, its filename is &lt;code&gt;&amp;lt;execute&amp;gt;&lt;/code&gt;, the name RemoteControl compiles every snippet under, which is absent from &lt;code&gt;linecache&lt;/code&gt;, so &lt;code&gt;inspect.getsource()&lt;/code&gt; raises &lt;code&gt;OSError&lt;/code&gt; for it. Scrapy's check only catches &lt;code&gt;IndentationError&lt;/code&gt;, and because the error happens before the result gets cached, that cache never kicks in either, the lookup, and the failure, repeat on every call to that callback. The exception itself is loud: it surfaces as a logged &lt;code&gt;spider_exceptions/OSError&lt;/code&gt; each time. What's lost quietly is whatever items or requests that call to the callback would have yielded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither of these is a reason to avoid &lt;code&gt;/execute&lt;/code&gt;. They're a reason to keep patching scoped to changing a value on an object you can already see, the way section 4 did, and to treat replacing a function's actual code as a different, much riskier category of change.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;7. When the crawl runs through scrapy-zyte-api&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above assumes a fairly plain Scrapy crawl, fetching directly. Point the same inspection at a crawl running &lt;a href="https://scrapy-zyte-api.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;scrapy-zyte-api&lt;/a&gt;, and a couple of specifics change. Detectable from the live state: &lt;code&gt;ZYTE_API_KEY&lt;/code&gt; in settings, &lt;code&gt;ScrapyZyteAPIHTTPDownloadHandler&lt;/code&gt; and &lt;code&gt;ScrapyZyteAPIHTTPSDownloadHandler&lt;/code&gt; registered for &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt; in &lt;code&gt;downloader.handlers&lt;/code&gt; if you use the scrapy-zyte-api add-on, or &lt;code&gt;ScrapyZyteAPIDownloadHandler&lt;/code&gt; for both if you configured the settings by hand, and &lt;code&gt;ScrapyZyteAPIDownloaderMiddleware&lt;/code&gt; in the enabled middleware list from section 2.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CONCURRENT_REQUESTS&lt;/code&gt; still applies. It's passed straight through as the connection pool size for the underlying &lt;a href="https://www.zyte.com/zyte-api/" rel="noopener noreferrer"&gt;Zyte API&lt;/a&gt; client, so the setting you already know still governs overall concurrency. What's genuinely different is the delay: Zyte API requests run on their own &lt;code&gt;zyte-api@&amp;lt;domain&amp;gt;&lt;/code&gt; slot, and whether that slot's &lt;code&gt;delay&lt;/code&gt; gets zeroed out depends on &lt;code&gt;ZYTE_API_PRESERVE_DELAY&lt;/code&gt;, which defaults to the &lt;em&gt;opposite&lt;/em&gt; of whatever &lt;code&gt;AUTOTHROTTLE_ENABLED&lt;/code&gt; is set to. With AutoThrottle off, the delay is preserved and behaves like an ordinary slot. With AutoThrottle on, the delay is zeroed so AutoThrottle's own dynamic adjustment can drive it instead of a static &lt;code&gt;DOWNLOAD_DELAY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Retries and throttling also happen a layer lower than usual, inside the &lt;code&gt;python-zyte-api&lt;/code&gt; client, before a Scrapy response exists at all, so read &lt;code&gt;scrapy-zyte-api/*&lt;/code&gt; stats rather than assuming &lt;code&gt;retry/count&lt;/code&gt; tells the whole story. Two different families of those stats answer two different questions: &lt;code&gt;error_ratio&lt;/code&gt; and &lt;code&gt;429&lt;/code&gt; count every attempt, including ones that were retried and then succeeded, so they describe how much friction the crawl is hitting. &lt;code&gt;fatal_errors&lt;/code&gt; and &lt;code&gt;success_ratio&lt;/code&gt; describe the final outcome after retries. A request throttled twice and then fine on the third try shows up in both: a real signal of load in the first family, and a completed, successful response in the second. Read both; they're not answering the same question.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkwuxnjeozlo664pt1sg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnkwuxnjeozlo664pt1sg.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Takeaway&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;RemoteControl is a small piece of code, two endpoints, a token and a job file, but it turns every running crawl into something you can ask questions of. Reading a live crawl through it is safe and genuinely useful: the scheduler's real queue classes, the downloader's actual slot state, a selector tested against a response already in memory, a stat sampled twice. Changing one number on a live object, the way section 4 did, is a live experiment you still have to commit to a setting afterward if it's right. Replacing a callback's actual code is a different, sharper category of change, with failure modes that depend on which queue a request happens to be sitting in. Know which one you're doing, and keep synchronous snippets short, because while they run the crawl doesn't.&lt;/p&gt;

&lt;p&gt;Further reading: the &lt;a href="https://github.com/scrapy/scrapy/blob/master/scrapy/extensions/remote_control.py" rel="noopener noreferrer"&gt;RemoteControl source&lt;/a&gt;, &lt;a href="https://docs.scrapy.org/en/2.19/topics/agents.html" rel="noopener noreferrer"&gt;Using Scrapy with coding agents&lt;/a&gt;, the &lt;a href="https://docs.scrapy.org/en/2.19/topics/extensions.html#remote-control-extension" rel="noopener noreferrer"&gt;Remote Control extension docs&lt;/a&gt;, &lt;a href="https://github.com/scrapy/scrapy-mcp-official" rel="noopener noreferrer"&gt;scrapy/scrapy-mcp-official&lt;/a&gt;, and the &lt;a href="https://scrapy-zyte-api.readthedocs.io/en/latest/" rel="noopener noreferrer"&gt;scrapy-zyte-api docs&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/blog/scrapy-mcp-live-crawl-inspection/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>scrapy</category>
      <category>python</category>
      <category>webscraping</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Running Playwright at scale: connecting to the Zyte CDP browser</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Mon, 05 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/running-playwright-at-scale-connecting-to-the-zyte-cdp-browser-71a</link>
      <guid>https://dev.to/extractdata/running-playwright-at-scale-connecting-to-the-zyte-cdp-browser-71a</guid>
      <description>&lt;p&gt;We've just released the Zyte CDP browser, so this post covers what it is, what it's good for, and, just as important, when you shouldn't use it. If you've got existing Playwright or Puppeteer scripts and you're tired of babysitting browser infrastructure, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What is CDP?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;CDP is the Chrome DevTools Protocol, a way of connecting to and controlling a running browser, and it's neither new nor exotic. Chrome DevTools itself uses this protocol every time you inspect network requests or run JavaScript in the console on an open page.&lt;/p&gt;

&lt;p&gt;As web scrapers, we rarely touch CDP directly - instead we use Playwright or Puppeteer as a wrapper around the underlying CDP commands. I'll stick to Playwright here, and through it we can launch browsers, connect to them, run actions on pages, and everything else the job needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The problem with local browsers&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The standard workflow looks like this: your script downloads a Chrome binary, launches it, and connects to it. This works fine on your laptop, but it falls apart the moment you need scale.&lt;/p&gt;

&lt;p&gt;Browsers are resource-heavy, they eat memory, and running them in any real volume is an infrastructure project in its own right. You end up managing containers, memory limits, zombie processes, and crash recovery instead of writing scrapers, and if your scrapers already run in Docker or Kubernetes, the browser pods are the biggest and least reliable things in the cluster.&lt;/p&gt;

&lt;p&gt;The fix is to run the browsers somewhere else, connecting to a remote browser on separate infrastructure while keeping your scripts exactly as they are. That's what the Zyte CDP browser is, browsers running on our infrastructure with your Playwright scripts driving them over CDP.&lt;/p&gt;

&lt;p&gt;You also get our web scraping technology bundled in, so ban solving and fingerprinting come with the browser and you're not solving those problems yourself either.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;An important distinction before you start&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If all you need is rendered HTML and you don't have something already build, don't use this, use browserHtml with Zyte API instead. It's simpler and it's the right tool for that job.&lt;/p&gt;

&lt;p&gt;The CDP browser makes sense when you have a Playwright script that performs many different actions, needs fine control over the browser, or runs through a flow where state has to carry from one step to the next. Zyte API actions can already handle some of this, but they're much more limited, and direct CDP access removes those limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why we built it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This didn't come out of a product roadmap meeting, it came out of a run of customer conversations where the same few problems kept coming up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code nobody wanted to rewrite.&lt;/strong&gt; One team scraping real estate listings had built their whole dynamic pipeline on Playwright and Puppeteer, and moving to Zyte API meant translating every one of those scripts into API calls or the Actions model. They looked at the size of that job and didn't want to do it, which is fair enough. With a CDP endpoint the scripts stay as they are, the connection line changes and nothing else does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second provider.&lt;/strong&gt; A job-market data startup was already running on another vendor's hosted browser over CDP, and it was failing outright on certain drag-and-drop challenges. They didn't want to switch, they wanted a fallback for when the first option broke, and since the interface is CDP either way, swapping providers is a config change. They also wanted the browser to work out which proxy tier a site needs on its own, datacenter by default and residential only when the site forces it, which is how Zyte API already handles bans and the CDP browser inherits it.&lt;/p&gt;

&lt;p&gt;Their sessions never needed to live longer than five minutes, by the way, they just wanted to connect, extract, and disconnect. Not every CDP use case is a long-running one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart Proxy Manager users.&lt;/strong&gt; SPM supported browser automation, and Zyte API's proxy mode didn't cover everything those users relied on, so with SPM being retired in 2026, people running Puppeteer or Playwright through it needed somewhere to go that didn't mean a rewrite. The CDP browser is that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fingerprinting.&lt;/strong&gt; Websites look at hardware footprint, mouse movement, and keyboard timing, and a stock headless script advertises itself as a bot and gets banned on the first request. Running a customised browser on our side, with our fingerprint and IP handling behind it, takes that maintenance off your plate. I'm not going to claim it gets you everything, but it does mean the browser isn't the weakest link in your stack anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Connecting Playwright to the Zyte browser&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the simplest possible example, where you connect with a CDP connection string and authenticate with your Zyte API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;b64encode&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.async_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;async_playwright&lt;/span&gt;

&lt;span class="n"&gt;ZYTE_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;CDP_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://browser.zyte.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;AUTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ZYTE_API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect_over_cdp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;CDP_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Basic &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AUTH&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;connect_over_cdp&lt;/code&gt; is a standard Playwright method, and if you check its docstring it describes connecting via the Chrome DevTools Protocol, which is exactly what's happening here. Puppeteer's &lt;code&gt;puppeteer.connect&lt;/code&gt; takes the same kind of websocket endpoint if that's your tool.&lt;/p&gt;

&lt;p&gt;If you already have a Playwright script, this connection block is the only part you change to move from a local or other remote browser onto our infrastructure, and everything after the connection stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Interactions are where it gets useful&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Rendering a page over CDP works, but the value really shows up when your script interacts with pages, because &lt;code&gt;page.click&lt;/code&gt;, &lt;code&gt;page.fill&lt;/code&gt;, waiting for selectors, anything Playwright exposes, all of it runs against the remote browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#search-input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web scraping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#search-button&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_selector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.results .item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all_text_contents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script connects to our infrastructure, runs the whole sequence, and returns the data, and the more complicated your actions, the more this pays off compared to expressing everything through API action definitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Blocking images to cut data transfer&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;One thing I do a lot is block images, since they're usually dead weight in a scraping job and blocking them saves a surprising amount of data transfer and loading time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.{png,jpg,jpeg,gif,webp,svg}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's one line, and worth doing by default unless you need the images.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Screenshots&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Playwright's &lt;code&gt;page.screenshot&lt;/code&gt; runs against the remote browser like everything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;screenshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Raw CDP commands&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Playwright covers most needs, but a direct CDP session gives you access to browser internals that Playwright doesn't wrap, such as overriding the browser's timezone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cdp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_cdp_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cdp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Emulation.setTimezoneOverride&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timezoneId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;America/New_York&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can change a lot about the browser this way, and this level of control is the reason to have a direct CDP connection at all, since a request/response API can't give it to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Using it with Scrapy&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you're working in Scrapy, the scrapy-playwright extension normally downloads and launches a local browser, which is exactly the pattern I warned about at the start, so point it at our infrastructure with the CDP URL instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;&lt;span class="n"&gt;PLAYWRIGHT_CDP_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://browser.zyte.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; 

&lt;span class="n"&gt;DOWNLOAD_HANDLERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have existing Playwright scripts doing complicated actions and you want off local browsers, in a lot of cases it really is this straightforward, you add the CDP URL and the work moves to a remote browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Giving AI agents browser access&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Claude wrote all the example code in this post's companion video, and that's the point here, because if you give an agent the CDP connection string and an API key, it can write its own Playwright scripts for a task and run them against a remote browser.&lt;/p&gt;

&lt;p&gt;That means the machine your agent runs on can be tiny, since it doesn't need to run browsers itself when we handle that off-site. The agent writes code, the browser lives on our infrastructure, and you skip the whole problem of provisioning browser-capable compute for agent workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When to use it, when not to&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Use the CDP browser when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have existing Playwright scripts with many different actions
&lt;/li&gt;
&lt;li&gt;You need fine control over the browser, including raw CDP commands
&lt;/li&gt;
&lt;li&gt;Your flow spans several steps and the site has to remember state between them, such as a solved challenge, a login, or a filled form
&lt;/li&gt;
&lt;li&gt;You're moving off Smart Proxy Manager or another hosted browser and don't want to rewrite anything
&lt;/li&gt;
&lt;li&gt;You want agents to drive real browsers without hosting them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Skip it when you just need rendered HTML, because Zyte API with browserHtml is the better option there.&lt;/p&gt;

&lt;p&gt;Links to pricing, access, and the community are below, and if you build something with this, come tell us what you used it for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.zyte.com/zyte-api/headless-browser/" rel="noopener noreferrer"&gt;CDP Homepage&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.zyte.com/zyte-api/usage/cdp.html" rel="noopener noreferrer"&gt;CDP Docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.zyte.com/join-community/" rel="noopener noreferrer"&gt;Join our community&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/blog/running-playwright-at-scale-connecting-to-the-zyte-cdp-browser/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>playwright</category>
      <category>puppeteer</category>
      <category>automation</category>
    </item>
    <item>
      <title>One Scrapy Spider, Three Browser Setups: Playwright, Patchright, and Zyte</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Sun, 04 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/one-scrapy-spider-three-browser-setups-playwright-patchright-and-zyte-2m2d</link>
      <guid>https://dev.to/extractdata/one-scrapy-spider-three-browser-setups-playwright-patchright-and-zyte-2m2d</guid>
      <description>&lt;p&gt;I wanted to see how much difference the browser makes when the spider stays the same. So I ran one Scrapy spider against a test catalogue I'd built to challenge automated browsers, using stock Playwright, Patchright, and a remote browser on Zyte.&lt;/p&gt;

&lt;p&gt;Stock Playwright reached a challenge page. Patchright and Zyte returned products. Here's how I configured each setup, and what this small test does, and doesn't, tell us.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The shared setup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Every scrapy-playwright setup starts the same way, in &lt;code&gt;settings.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DOWNLOAD_HANDLERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;TWISTED_REACTOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;twisted.internet.asyncioreactor.AsyncioSelectorReactor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That wires Playwright into Scrapy's download machinery, but it doesn't send every request through a browser. You opt in per-request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;scrapy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auto.hylnd7.com/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_include_page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;errback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;playwright_include_page&lt;/code&gt; hands you the live Playwright &lt;code&gt;Page&lt;/code&gt; object in the callback. That matters more than it looks. The &lt;code&gt;response&lt;/code&gt; Scrapy gives you is a snapshot taken once, and it won't update as the page keeps running JavaScript. If you need to wait on something, wait on the page, not the response.&lt;/p&gt;

&lt;p&gt;I built the test catalogue myself, specifically to challenge automated browsers. It's not a stand-in for a typical e-commerce site, and the results below are what happened against one page I deliberately hardened, not a general ranking of these browsers.&lt;/p&gt;

&lt;p&gt;I also ran every stage headful (&lt;code&gt;PLAYWRIGHT_LAUNCH_OPTIONS = {"headless": False}&lt;/code&gt;, under &lt;code&gt;xvfb-run&lt;/code&gt; on a headless server), since headless mode is one of the easiest things for a site to detect. The spider and selectors below stay identical across all three stages; only the browser setup changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8t8i6q5mmpbog79uh0w8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8t8i6q5mmpbog79uh0w8.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Stage one: Playwright's local browser (the default)&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Nothing extra to configure. &lt;code&gt;PLAYWRIGHT_BROWSER_TYPE = "chromium"&lt;/code&gt; and Playwright launches a Chromium binary on the machine running Scrapy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PLAYWRIGHT_BROWSER_TYPE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chromium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PLAYWRIGHT_LAUNCH_OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headless&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the callback, we wait for the content we actually need, then hand the rendered HTML back to ordinary Scrapy selectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;playwright_page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_selector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.product-card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;PlaywrightTimeoutError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No product cards appeared; saving the browser result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;rendered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scrapy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HtmlResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.product-card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upside is that there's nothing to set up. No account, no extra service, no network hop to another provider. You install a browser binary and go, which is exactly why it's still the default.&lt;/p&gt;

&lt;p&gt;The downside is that even running headful, a stock Chromium driven by Playwright still has a fingerprint, and this test catalogue checks for it. Here's what came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no_products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Security Check"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auto.hylnd7.com/"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero products. The browser reached the site, Playwright didn't error, and Scrapy happily returned a 200, but the page it rendered was a challenge screen, not the catalogue. This is the trap with browser scraping. An empty result and a blocked result look identical unless you actually inspect what loaded. That's why the spider saves a screenshot on every run: trust the pixels, not the exit code. Running headful wasn't enough on its own here.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Stage two: swap the browser with &lt;code&gt;PLAYWRIGHT_BROWSER_PROVIDER&lt;/code&gt;&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is the new part. scrapy-playwright added a &lt;a href="https://github.com/scrapy-plugins/scrapy-playwright/blob/main/docs/pluggable-browser-providers.md" rel="noopener noreferrer"&gt;pluggable provider interface&lt;/a&gt;, and its docs now ship example classes for two popular open-source stealth browsers: &lt;a href="https://github.com/Kaliiiiiiiiii-Vinyzu/patchright" rel="noopener noreferrer"&gt;Patchright&lt;/a&gt; (a patched Chromium that removes Playwright's automation tells) and &lt;a href="https://github.com/daijro/camoufox" rel="noopener noreferrer"&gt;Camoufox&lt;/a&gt; (a hardened Firefox build with the same goal). I only ran Patchright for this test; Camoufox is worth trying against your own targets with the same hook.&lt;/p&gt;

&lt;p&gt;Add a small provider class to your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# browser_demo/providers.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncExitStack&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PatchrightProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Local Chromium provider, following scrapy-playwright&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s documented example.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AsyncExitStack&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;patchright.async_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;async_playwright&lt;/span&gt;
        &lt;span class="n"&gt;driver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enter_async_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;async_playwright&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;launch_browser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser_type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launch_options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aclose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And one settings line switches to it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PLAYWRIGHT_BROWSER_PROVIDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;browser_demo.providers.PatchrightProvider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spider, the selectors, and the &lt;code&gt;wait_for_selector&lt;/code&gt; call don't change. You can change browsers without changing your parsing code. Same headful setting as stage one, same spider. Here's what came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Home - Auto Parts Demo Store"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auto.hylnd7.com/"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five products. Patchright got through this test. I still had to run the browser, install its dependencies, and supply the network connection myself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19vlxxip8p185pfd31kn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19vlxxip8p185pfd31kn.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Stage three: connect to a browser on Zyte over CDP&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;CDP is the Chrome DevTools Protocol, the same protocol Playwright already speaks to control a browser. The difference here is &lt;em&gt;where&lt;/em&gt; that browser is running. Instead of launching one, you connect to one that's already alive on Zyte's infrastructure, and you don't need a custom &lt;code&gt;PLAYWRIGHT_BROWSER_PROVIDER&lt;/code&gt; class to do it. The default provider that scrapy-playwright ships with already knows how to connect over CDP, so it's two settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;w3lib.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;basic_auth_header&lt;/span&gt;

&lt;span class="n"&gt;PLAYWRIGHT_LAUNCH_OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;   &lt;span class="c1"&gt;# nothing to launch, the browser already exists
&lt;/span&gt;&lt;span class="n"&gt;PLAYWRIGHT_CDP_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://browser.zyte.com/?ttl=60&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;PLAYWRIGHT_CDP_KWARGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;basic_auth_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ascii&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentication is Basic auth with your Zyte API key as the username and an empty password. The &lt;code&gt;ttl&lt;/code&gt; query parameter bounds how long the remote session stays reserved, so set it to whatever your crawl actually needs. When &lt;code&gt;PLAYWRIGHT_CDP_URL&lt;/code&gt; is set, the default provider's &lt;code&gt;launch_browser()&lt;/code&gt; calls Playwright's own &lt;code&gt;browser_type.connect_over_cdp()&lt;/code&gt; instead of launching a local binary, without adding a hosted service beyond Zyte itself.&lt;/p&gt;

&lt;p&gt;Zyte returned five products too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Home - Auto Parts Demo Store"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auto.hylnd7.com/"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real catalogue title, no challenge page, the same result as headful Patchright. Zyte's infrastructure handles the browser fingerprint, session behavior, and proxy routing on its side.&lt;/p&gt;

&lt;p&gt;One detail worth keeping if you go this route: closing the page normally only disconnects Playwright's client from the remote browser, it doesn't end the session. To actually close it, send the CDP command directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;STAGE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zyte&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_browser_cdp_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Browser.close&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also turn off automatic restarts, so a deliberate close doesn't just spin up another remote browser behind your back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PLAYWRIGHT_RESTART_DISCONNECTED_BROWSER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser fingerprint, session handling, and proxy management become Zyte's problem instead of mine, and no browser binary to install or launch option to tune. The catch is that it's a paid, external dependency with an account and business verification requirement, and this comparison changed more than one variable at once: the browser and the network location both moved. So it isn't proof that CDP alone is what got past the check.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lxk7fi2ajl53g54k48k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3lxk7fi2ajl53g54k48k.png" alt="image" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A cost that doesn't show up in these numbers: running several contexts at once&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above ran one page at a time. I also tried scraping the whole catalogue instead of just the featured five, which meant opening several browser contexts concurrently to speed it up. One context at a time was fine; a handful running together locked up hard enough that I had to kill the process, no errors or timeouts, just stuck.&lt;/p&gt;

&lt;p&gt;This isn't the first time I've run into this. Running a real browser, headful, with several contexts going at once on a server, tends to surface exactly this kind of resource contention, whether that's rendering, memory, or something lower in the stack, and it rarely shows up until you actually push concurrency. It's the part of "just run more browsers" that's easy to underestimate.&lt;/p&gt;

&lt;p&gt;This is specifically a local-browser problem, not a Zyte CDP one. Zyte's browser infrastructure is built to take as many concurrent connections as you throw at it; the contention above comes from cramming multiple contexts onto one machine, which is exactly the part of the job CDP hands off to Zyte instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Takeaway&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;On this test catalogue, stock Playwright reached a challenge page, while Patchright and Zyte returned five products each. The spider and selectors stayed the same across all three runs.&lt;/p&gt;

&lt;p&gt;The provider hook makes it easier to try another local browser, Patchright or Camoufox, without touching your extraction code. Connecting over CDP lets you move the browser to Zyte, with the cost and account requirements that brings. These results come from one target I built myself, but the useful part is how little extraction code had to change: none.&lt;/p&gt;

&lt;p&gt;The concurrency hang above is the part I'd weigh most heavily if I were deciding between these options for anything beyond a single-page test. Running your own browser is easy to demo and harder to operate at load.&lt;/p&gt;

&lt;p&gt;If you want the fuller case for the Zyte CDP browser, including raw CDP commands and giving agents browser access, I covered that in &lt;a href="https://www.zyte.com/blog/running-playwright-at-scale-connecting-to-the-zyte-cdp-browser/" rel="noopener noreferrer"&gt;Running Playwright at scale: connecting to the Zyte CDP browser&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/blog/scrapy-playwright-stealth-and-zyte-cdp/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>scrapy</category>
      <category>playwright</category>
      <category>python</category>
    </item>
    <item>
      <title>I built scraping agents for 30 days - here’s what I learned</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Sat, 03 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/i-built-scraping-agents-for-30-days-heres-what-i-learned-3ic9</link>
      <guid>https://dev.to/extractdata/i-built-scraping-agents-for-30-days-heres-what-i-learned-3ic9</guid>
      <description>&lt;p&gt;&lt;em&gt;The autonomous web data pipeline is dawning. Going deep on the frontier's state of the art surprised me, humbled me, and changed how I work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The industry is abuzz with &lt;a href="https://www.zyte.com/blog/dawn-of-the-autonomous-data-pipeline/" rel="noopener noreferrer"&gt;talk about autonomous web data-gathering&lt;/a&gt; agents. But what's the reality? &lt;/p&gt;

&lt;p&gt;For the last 30 days, I did one thing almost exclusively: I built scraping systems with AI agents, from the ground up, across real targets, with real deadlines. Not prototypes designed to impress in a demo, not isolated experiments running against a toy website, but production-grade pipelines that needed to ship and keep running.&lt;/p&gt;

&lt;p&gt;I went in with genuine curiosity and came out with something more useful than a hot take: a messy, hard-won picture of where agents genuinely changed my work, where they burned my time, and what I had to unlearn about how I thought development with AI would feel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjnyu5biqf9epc5vxnvt8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjnyu5biqf9epc5vxnvt8.png" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest surprise: Agents really can build pipelines
&lt;/h2&gt;

&lt;p&gt;I did not go into this expecting agents to handle the full build cycle. My assumption, going in, was that they would be useful for the boring middle parts: generating boilerplate, suggesting selectors, maybe scaffolding a spider I would then finish myself.&lt;/p&gt;

&lt;p&gt;What I found instead was that, &lt;strong&gt;with the right tools and domain-specific context in place, an agent could take a target URL and produce a working, tested pipeline without me writing the bulk of the code&lt;/strong&gt;. Not just a rough scaffold that got me 60% of the way there - a working pipeline, with selectors, page objects, item definitions, and a passing test suite.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The first time this happened cleanly I actually went back and **I checked the output, because I did not quite believe it&lt;/em&gt;&lt;em&gt;. The spider ran, the items validated, and the tests passed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had been expecting to spend the entire afternoon on it, but it took 45 minutes, and most of that was me reviewing the output rather than the agent producing it. That shift, from writing to reviewing, is the one I keep coming back to when I think about what actually changed over these 30 days.&lt;/p&gt;

&lt;p&gt;The caveat I discovered quickly, though, is that the words "right tools" are doing enormous work in that sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first few sessions where I pointed a general-purpose agent at a blank project and a URL were humbling&lt;/strong&gt;, with hallucinated selectors, invented conventions, and code that looked plausible - until it completely fell apart on real pages.&lt;/p&gt;

&lt;p&gt;It was only once I built out the proper scaffolding around the agent, a structured project to conform to, Zyte-specific skills and tooling, and well-defined context about how we build at Zyte, that the results became something I could trust. If you want a concrete look at that scaffolding, &lt;a href="https://www.zyte.com/blog/web-scraping-copilot-1-0-vs-code/" rel="noopener noreferrer"&gt;Web Scraping Copilot&lt;/a&gt;, the VS Code extension I was leaning on during this month, has since shipped as 1.0, built around exactly this idea of structure around the agent rather than a bare prompt.&lt;/p&gt;

&lt;p&gt;Zyte's own research into &lt;a href="https://www.zyte.com/blog/agentic-web-scraping/" rel="noopener noreferrer"&gt;what makes agents succeed or fail at web scraping&lt;/a&gt; tracks closely with what I experienced: specialized tooling and context engineering are what separate useful agents from expensive chaos.&lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest time sink: Tool design
&lt;/h2&gt;

&lt;p&gt;I did not anticipate that the biggest investment of the 30 days would be in building tools rather than in prompting or orchestration, but that is exactly what happened.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I ended up constructing a set of CLI wrappers around my most-used services - primarily &lt;a href="https://www.zyte.com/zyte-api/" rel="noopener noreferrer"&gt;Zyte API&lt;/a&gt; and related infrastructure - because **the raw responses were not shaped for an agent to reason with efficiently,&lt;/em&gt;* and I kept watching agents get confused by the volume and structure of what came back.*&lt;/p&gt;

&lt;p&gt;Each tool I built was designed so that the data coming back to the agent was precisely what it needed for the task and nothing else: no raw HTML dumps, no verbose JSON payloads with 40 fields when three were relevant, no response structures that required the agent to do significant parsing just to understand what it had received.&lt;/p&gt;

&lt;p&gt;The difference this made was not subtle. Before those tools, I was watching agents latch onto the wrong signal and carry that error confidently through the rest of their work. After, the reasoning was noticeably cleaner and the output was dramatically more reliable.&lt;/p&gt;

&lt;p&gt;What I came to understand is that &lt;strong&gt;the bottleneck was never the model, it was the quality of the interface between the agent and its tools&lt;/strong&gt;, and I had been underinvesting in that interface completely. The work of building good tools felt like plumbing at the time, but, looking back, it was the most valuable engineering I did across the entire 30 days.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One of my CLI wrappers — the agent gets exactly what it needs, nothing else&lt;/span&gt;
zyte-fetch &lt;span class="nt"&gt;--url&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/products"&lt;/span&gt; &lt;span class="nt"&gt;--extract&lt;/span&gt; product &lt;span class="nt"&gt;--fields&lt;/span&gt; &lt;span class="s2"&gt;"name,price,sku"&lt;/span&gt;
&lt;span class="c"&gt;# Returns clean JSON with exactly three fields, not 300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The mistake I made on day four that I am still thinking about
&lt;/h2&gt;

&lt;p&gt;Around day four, an agent built a working spider in under 10 minutes and I shipped it without setting up the review loop I had been meaning to establish.&lt;/p&gt;

&lt;p&gt;The spider ran cleanly, the logs looked fine, and I moved on to the next thing. Three hours later, I had 400 ingested records where the price field was either missing or formatted as a raw string with the currency symbol still attached.&lt;/p&gt;

&lt;p&gt;It was all because I had not defined what a valid item actually looked like. The agent had made assumptions that were reasonable given what it had to work with, but wrong.&lt;/p&gt;

&lt;p&gt;That experience reframed something for me. I had been thinking of good project structure as a professional habit, something worth doing but not load-bearing in the way that, say, a working selector is. What I discovered is that, when an agent is generating and modifying code on your behalf, your project structure becomes the contract the agent conforms tol; gaps in that contract become gaps in the output. The agent will not fill them with good judgment; it will fill them with plausible guesses.&lt;/p&gt;

&lt;p&gt;After day four, I started treating typed item definitions, schema validation, fixture-based tests, and clear module boundaries as prerequisites rather than nice-to-haves, and the reliability of the agent's output improved noticeably as a result. Not because the agent got smarter, but because it had less room to go wrong. I later built an actual automated version of this instinct, a Scrapy pipeline that asks a cheap AI model whether each scraped field still looks real and stops the crawl when too many don't, in &lt;a href="https://www.zyte.com/blog/stopping-a-crawl-when-fields-stop-looking-real/" rel="noopener noreferrer"&gt;Stopping a crawl when the data stops looking real&lt;/a&gt;. That project exists because of exactly this day-four mistake.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# What my page objects ended up looking like after I tightened the schema contracts
&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;web_poet&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scrapy_poet&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;handle_urls&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zyte_common_items&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Product&lt;/span&gt;


&lt;span class="nd"&gt;@handle_urls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example.co.uk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WebPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Returns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;

    &lt;span class="nd"&gt;@field&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h1::text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@field&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;price&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.price::text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@field&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[data-sku]::attr(data-sku)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The speed is real, and I had to learn not to trust it blindly
&lt;/h2&gt;

&lt;p&gt;By week two I was consistently going from a target domain to a deployed, running spider in under an hour on familiar site patterns, which is a compression of my previous workflow that I still find slightly disorienting to think about. The velocity is genuinely there, and I do not want to understate it.&lt;/p&gt;

&lt;p&gt;What I had to learn, more slowly than I would like to admit, is that the speed applies uniformly regardless of output quality. An agent does not slow down when the extraction logic it has produced is brittle, does not flag that it is making assumptions about a selector pattern it has only seen once, and does not hesitate when the approach it is taking will break the moment the target site makes a minor update. It ships at full speed no matter what, which means the pressure to slow down and check has to come entirely from you.&lt;/p&gt;

&lt;p&gt;The adjustment I eventually made was to stop treating agent sessions like batch processes I could kick off and return to, and to start treating them more like working alongside a very fast colleague who needed regular check-ins. Short cycles, deliberate review points, and a habit of actually reading the output before deploying it. That felt slower in the moment. Looking back at the pipelines I shipped with that approach versus the ones I did not, it was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I stopped thinking of human-in-the-loop as a limitation
&lt;/h2&gt;

&lt;p&gt;My initial instinct was to treat the moments where I had to step in and verify something as signs that the agent was not good enough yet, as temporary friction that would disappear as the tooling matured. I do not think that anymore.&lt;/p&gt;

&lt;p&gt;Over the 30 days I set up explicit checkpoints in my agent workflows where the agent would surface what it had done, what it was about to do, and where it had uncertainty, and I would review before it proceeded. Setting those up felt like admitting defeat at first. What I actually found was that they caught a meaningful number of decisions I would have made differently, and that the pipelines built with those checkpoints were more reliable and easier to debug than the ones I had let run freely. The checkpoints were not a workaround for agent limitations. They were part of the system design, and removing them would have made the system worse.&lt;/p&gt;

&lt;p&gt;I built lightweight observability into every pipeline I shipped as well: item counts per run, field completeness rates, schema validation pass rates, and error ratios. Without those signals I found I genuinely could not tell the difference between a healthy run and one that was quietly degrading, and that gap between "it ran" and "it worked" is exactly where the expensive surprises live. The &lt;a href="https://www.zyte.com/web-scraping-copilot/" rel="noopener noreferrer"&gt;Web Scraping Copilot&lt;/a&gt; is built around the same idea, keeping AI-assisted development fast while keeping the developer in a position to actually know what is happening.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F53qp0elzvfsxrbdunoaf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F53qp0elzvfsxrbdunoaf.png" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The moment that genuinely impressed me, and the one that humbled me
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The most impressive thing I saw over the 30 days was **an agent diagnosing and repairing a broken spider after a target site updated its markup, without me touching a line of code&lt;/em&gt;&lt;em&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The site had changed its product card structure, the extraction started failing, the agent detected the failures, inspected the new HTML, identified where the selectors had drifted, updated the page object, ran the tests against a new fixture, and confirmed the fix. The whole loop took a few minutes. I watched it happen in the logs.&lt;/p&gt;

&lt;p&gt;For that class of problem, layout drift and minor structural changes, I came away genuinely convinced that &lt;strong&gt;agents are already good enough to handle maintenance work I used to dread&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;The ability to detect and repair that kind of breakage automatically is one of the clearest wins I found across the whole 30 days. What I didn't have yet at the time was a clean way to give the agent that live access without wiring it up myself: I ended up building most of that diagnostic loop from scratch. Scrapy MCP now gives an agent exactly that kind of live connection to a running crawl out of the box, discovering jobs, reading engine state, and adjusting behaviour while it runs rather than only after a failure. I've written about &lt;a href="https://www.zyte.com/blog/scrapy-mcp-live-crawl-inspection/" rel="noopener noreferrer"&gt;what it actually lets you touch&lt;/a&gt; in more detail since.&lt;/p&gt;

&lt;p&gt;The humbling counterpart came when I hit a target with unusual banning behavior. The agent could see that requests were failing but had no reliable way to diagnose why or adapt its approach in a way that actually worked.&lt;/p&gt;

&lt;p&gt;That required me to dig into how &lt;a href="https://www.zyte.com/zyte-api/unblocking/" rel="noopener noreferrer"&gt;Zyte API's unblocking&lt;/a&gt; was interacting with the site's specific defenses, and it required the kind of accumulated domain knowledge that does not live in a model. The Zyte post on &lt;a href="https://www.zyte.com/blog/why-ai-agents-struggle-with-web-scraping/" rel="noopener noreferrer"&gt;why AI agents struggle with web scraping&lt;/a&gt; maps this ceiling out clearly if you want to understand the mechanics of why access problems are so much harder for agents than structural ones. My experience matched what they describe almost exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually do differently now
&lt;/h2&gt;

&lt;p&gt;After 30 days, the honest version of what changed is not a framework for when to use agents. It is &lt;strong&gt;a set of instincts I did not have before&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I now reach for an agent immediately when I am starting something new on an unfamiliar domain, because &lt;strong&gt;the exploration work that used to eat half my day happens in minutes&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;But I do not reach for one when I am working on a target with unusual access patterns, because I have learned that the agent will spin confidently and expensively without making real progress.
&lt;/li&gt;
&lt;li&gt;I invest time in tool design before I invest time in prompting, because the tool design pays dividends across every session that follows while the prompting only helps the one in front of me.
&lt;/li&gt;
&lt;li&gt;And I treat every agent session as collaborative rather than delegative, staying in the loop rather than handing off and walking away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 30 days did not resolve the question of whether agents are the future of scraping development, because I do not think that question has a clean answer yet. What they gave me is a much clearer sense of where they already earn their place in the workflow, which is more useful than a verdict.&lt;/p&gt;

&lt;p&gt;For a sense of where this is all heading at a systems level, Zyte's piece on the &lt;a href="https://www.zyte.com/blog/dawn-of-the-autonomous-data-pipeline/" rel="noopener noreferrer"&gt;dawn of the autonomous data pipeline&lt;/a&gt; is worth reading - the modular, agent-driven architectures they describe feel like a natural extension of what I was building toward by the end of the month, even if I was not thinking about it in those terms at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want to try Zyte API for free?
&lt;/h2&gt;

&lt;p&gt;start here&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/blog/i-built-scraping-agents-for-30-days-heres-what-i-learned/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
    <item>
      <title>Supercharging web scraping with Claude skills</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Fri, 02 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/extractdata/supercharging-web-scraping-with-claude-skills-27jg</link>
      <guid>https://dev.to/extractdata/supercharging-web-scraping-with-claude-skills-27jg</guid>
      <description>&lt;p&gt;As &lt;strong&gt;web scraping&lt;/strong&gt; becomes more complex, the tools we use to navigate that complexity need to become smarter.&lt;/p&gt;

&lt;p&gt;I recently took some time to explore how Claude skills can be integrated into a &lt;strong&gt;web scraping&lt;/strong&gt; workflow. My goal was to see if I could improve the process and make data extraction significantly easier for myself.&lt;/p&gt;

&lt;p&gt;What I found was that by building a specialized set of instructions, I could transform Claude from a simple coding assistant into a powerful scraping partner, especially for getting data quickly and providing a minimum viable product of the &lt;strong&gt;web scraping&lt;/strong&gt; code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Claude skills?
&lt;/h2&gt;

&lt;p&gt;At its core, a Claude skill is a list of instructions in a folder that the AI can call upon whenever it receives a message from you.&lt;/p&gt;

&lt;p&gt;Whenever you send a prompt, Claude checks whether any available skill is appropriate to follow. If so, Claude pulls these instructions into its context.&lt;/p&gt;

&lt;p&gt;In itself, the skill is nothing more than a Markdown file. However, that file can also contain code excerpts and the skill folder can contain whole code files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# My Skill

A short description of what this skill does.

## When to use
Use this skill when the user asks you to [trigger condition].

## Instructions
1. Run `script.py` with the provided input
2. Return the output to the user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows Claude to call on specific actions like running Python code, execute external scripts, or process data in a highly structured way that is repeatable and consistent.&lt;/p&gt;

&lt;p&gt;Once it is called upon, the power of a skill lies in its ability to remain within the conversation context. This means you can query the output further, ask for modifications, or work with the extracted data in real-time without leaving the chat interface. It turns Claude into a specialized environment tailored for your specific technical needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing skills to MCP servers
&lt;/h2&gt;

&lt;p&gt;It is important to understand the difference between a skill and a Model Context Protocol (MCP) server. While both expand what the AI can do, they serve different purposes.&lt;/p&gt;

&lt;p&gt;An MCP server is generally more like a piece of external tooling designed for a specific, broad task - like connecting to a database or a file system.&lt;/p&gt;

&lt;p&gt;Skills, on the other hand, are more lightweight and flexible. They often consist of a single script or a focused set of instructions. Because they stay within the context of the AI, they are easier to iterate on. If you need a script to behave slightly differently for a specific website, you can adjust the skill or the prompt instantly. It provides a more agile way to handle the varying nature of web data.&lt;/p&gt;

&lt;p&gt;Skills are also more specific to you and can be easily created and deleted, whereas an MCP server is designed to provide a specific purpose across many platforms and is often provided by companies for their products as integrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Top four scraping skills
&lt;/h2&gt;

&lt;p&gt;Here are my favorite skills that I have been trying.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Fetcher
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Automating HTML acquisition
&lt;/h3&gt;

&lt;p&gt;The first step in any scraping project is getting the raw data. Traditionally, this is a manual, repetitive process. Given how many websites block requests, including directly from your large language model (LLM), you’d need to find another way.&lt;/p&gt;

&lt;p&gt;To solve this I built this skill, it uses a Python script integrated with &lt;strong&gt;Zyte API&lt;/strong&gt; to fetch the HTML of any URL you provide.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F150ija7rtiu8jkzf6gid.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F150ija7rtiu8jkzf6gid.png" alt="Screenshot 2026 03 11 At 9.48.36  A M" width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this makes life easier&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Less manual work.&lt;/strong&gt; You no longer have to manually copy and paste code. You give Claude a link, and it handles the retrieval, right inside the chat.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Avoiding blocks.&lt;/strong&gt; By using a professional API within the skill, you reduce the risk of your request being blocked and being banned.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; You can move from identifying a target to analyzing its code in seconds. This allows for a much faster exploration phase when you are starting a new project.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. AI Parser
&lt;/h3&gt;

&lt;h3&gt;
  
  
  &amp;nbsp;Cleaning data with AI extraction
&lt;/h3&gt;

&lt;p&gt;Raw HTML is often messy. It is filled with navigation menus, footer links, tracking scripts, and styling blocks that have nothing to do with the data you actually want. This extra noise is more than just a distraction - it consumes valuable tokens/context and can make it harder for an AI to focus on the relevant content.&lt;/p&gt;

&lt;p&gt;My second skill utilizes Zyte’s &lt;strong&gt;AI-powered automatic extraction&lt;/strong&gt; to return only the main page content. This uses a machine learning model to strip away everything except the main header, body text, and footer. This greatly aids induction into your LLM’s context and provides more accurate results and less hallucination on answers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F968agghw0itw5zbd3mmg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F968agghw0itw5zbd3mmg.png" alt="Screenshot 2026 03 11 At 9.48.53  A M" width="772" height="587"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How cleaning data saves time&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Token efficiency.&lt;/strong&gt; By removing the junk, you save a massive amount of space in Claude's context window. This allows you to process much longer articles or even multiple pages at once without hitting limits.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Better accuracy.&lt;/strong&gt; LLMs perform better when they are given high-quality, relevant data. By removing the script tags and images, you ensure the AI focuses only on the information that matters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reduced processing time.&lt;/strong&gt; You don't have to wait for the AI to read through thousands of lines of irrelevant CSS. The "purified" text is ready for immediate analysis.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3*&lt;em&gt;.&lt;/em&gt;* SelectorGen
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Generating resilient selectors
&lt;/h3&gt;

&lt;p&gt;Once you have the HTML, you need to write selectors to tell your script exactly where the data lives. Writing these by hand is tedious and can be extremely brittle. If you pick a selector that is too specific, it may break if the site makes a tiny layout change, or even cause you to rewrite all your selectors.&lt;/p&gt;

&lt;p&gt;I built a selector generator skill based on the &lt;strong&gt;Parsel&lt;/strong&gt; library. &lt;strong&gt;Parsel&lt;/strong&gt; is the HTML parser behind &lt;strong&gt;Scrapy&lt;/strong&gt;, and it is incredibly efficient at navigating HTML structures.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5gfvl1uzv5s8h0k5i7s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw5gfvl1uzv5s8h0k5i7s.png" alt="Screenshot 2026 03 11 At 9.49.00  A M" width="769" height="583"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is powerful&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multiple backups.&lt;/strong&gt; This skill is programmed to find multiple selectors for the same data point. If your primary selector fails, you have backup options ready to go.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consistency.&lt;/strong&gt; It outputs code in a repeatable, standardized style. This makes it very easy to manage your codebase because every scraper you build follows the same logic.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ease of use.&lt;/strong&gt; You can copy a snippet of the page's HTML source from your browser and ask the skill to write the selectors for you. It removes the guesswork and the need to manually test dozens of different CSS paths.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4.&lt;/strong&gt; Extruct HTML
&lt;/h3&gt;

&lt;h3&gt;
  
  
  Extracting hidden data
&lt;/h3&gt;

&lt;p&gt;Sometimes, the data you want isn't in the visible HTML at all. Many modern websites store their information in structured formats like JSON-LD or schema metadata hidden inside script tags. This data is often much cleaner and more reliable than the visible text.&lt;/p&gt;

&lt;p&gt;I wrote a skill using a library called &lt;strong&gt;Extruct&lt;/strong&gt; to target this specific metadata. It ignores the HTML tags and goes straight for the structured data objects.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcew2iiozdghw08yy5si6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcew2iiozdghw08yy5si6.png" alt="Screenshot 2026 03 11 At 9.49.08  A M" width="768" height="579"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is useful&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Structural stability.&lt;/strong&gt; Websites change their visual layout all the time, but they rarely change their JSON-LD structure because that is what Google uses for search rankings. This makes your scrapers much more durable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero parsing logic.&lt;/strong&gt; Instead of writing complex rules to find a price or a product name, the skill simply hands you a clean JSON object.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Precision.&lt;/strong&gt; It eliminates the risk of accidentally scraping "related products" or "sponsored content" because those items are rarely included in the primary schema metadata.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Building a workflow by chaining skills
&lt;/h3&gt;

&lt;p&gt;The real strength of these skills is that they do not have to work in isolation. You can build a repository of these tools within Claude and chain them together to create a full automated pipeline.&lt;/p&gt;

&lt;p&gt;The chaining can work in two ways depending on how much you want to automate. The simplest approach is manual, where you prompt Claude through each step in sequence, reviewing the output before moving on. This gives you full control and is useful when you're exploring an unfamiliar site.&lt;/p&gt;

&lt;p&gt;For a more automated flow, you can embed logic directly into the skill instructions themselves, for example, telling the hidden data skill to automatically pass its output to the selector generator if no JSON-LD is found. This creates a more hands-off pipeline that handles common decision points without you needing to intervene.&lt;/p&gt;

&lt;p&gt;In practice, most workflows are a mix of both. The early exploration phase tends to be manual, while the more repetitive extraction steps can be automated once you know what to expect from a site.&lt;/p&gt;

&lt;p&gt;I prefer the manual approach, so a typical workflow might look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;First, you get the HTML.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Next, you ask if there is any hidden JSON data.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If there is, you are done. If not, you pass that HTML to generate a set of resilient selectors.&amp;nbsp;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Finally, you can have Claude write a full Python script incorporating those selectors and the original fetching logic, giving you a working Python script you can use to carry on testing, or expand on to production code.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlq5na0623m4q6ol50ok.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlq5na0623m4q6ol50ok.png" alt="Screenshot 2026 03 11 At 9.49.23  A M" width="682" height="715"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This chained approach makes the entire process repeatable. You aren't just getting a one-off answer; you are building a system that can be applied to almost any website with minimal adjustments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Web scraping&lt;/strong&gt; will always require some level of manual oversight, but using Claude skills allows you to focus on the high-level strategy rather than the low-level grind. By automating the fetching, cleaning, and selector generation, you can be effective in your project much quicker.&lt;/p&gt;

&lt;p&gt;These &lt;strong&gt;four&lt;/strong&gt; skills have made my workflow significantly more efficient. They allow me to build scrapers that are more resilient and easier to maintain. Hopefully, you can find some benefit in these methods as you build out your own data extraction tools.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.zyte.com/blog/supercharging-web-scraping-with-claude-skills/" rel="noopener noreferrer"&gt;Zyte blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>ai</category>
      <category>claude</category>
      <category>python</category>
    </item>
    <item>
      <title>Zyte MCP: Powerful web data gathering for your agent</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Thu, 01 Oct 2026 17:10:18 +0000</pubDate>
      <link>https://dev.to/extractdata/zyte-mcp-powerful-web-data-gathering-for-your-agent-93g</link>
      <guid>https://dev.to/extractdata/zyte-mcp-powerful-web-data-gathering-for-your-agent-93g</guid>
      <description>&lt;p&gt;&lt;a href="https://www.zyte.com/blog/why-ai-agents-struggle-with-web-scraping/" rel="noopener noreferrer"&gt;AI agents&lt;/a&gt; are increasingly where engineers plan, write and debug. But their work can often be thwarted at the first protected web page. The agent times out, mistakes a consent screen for the content or falls back on stale training data.&lt;/p&gt;

&lt;p&gt;Even when it gets the page, a web data team still has to jump between code, documentation, dashboards and logs to run and diagnose the crawl.&lt;/p&gt;

&lt;p&gt;Today, we’re introducing &lt;strong&gt;Zyte MCP Server&lt;/strong&gt;: a hosted Model Context Protocol server that makes &lt;a href="https://www.zyte.com/zyte-api" rel="noopener noreferrer"&gt;Zyte API&lt;/a&gt; and &lt;a href="https://www.zyte.com/scrapy-cloud" rel="noopener noreferrer"&gt;Scrapy Cloud&lt;/a&gt; available to MCP-compatible AI and coding agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We are exposing all of Zyte, inside your coding agent.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Zyte MCP makes possible
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.zyte.com/blog/claude-skills-vs-mcp-vs-web-scraping-copilot" rel="noopener noreferrer"&gt;Model Context Protocol, or MCP&lt;/a&gt;, gives AI applications a standard way to discover and invoke external tools. Zyte MCP uses that interface to bring reliable web access, &lt;a href="https://www.zyte.com/zyte-api/ai-extraction/" rel="noopener noreferrer"&gt;structured extraction&lt;/a&gt; and crawl operations into environments such as Claude Code, Cursor, Codex, GitHub Copilot and VS Code.&lt;/p&gt;

&lt;p&gt;Zyte MCP is not a new extraction engine. Under the hood, Zyte API still retrieves and extracts the data, while Scrapy Cloud runs spiders. MCP changes where you use those capabilities: inside the agent where the engineering work is already happening. Your agent will be able to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.zyte.com/zyte-api" rel="noopener noreferrer"&gt;&lt;strong&gt;Fetch protected and JavaScript-heavy pages&lt;/strong&gt;&lt;/a&gt; through Zyte’s access infrastructure.&lt;/li&gt;
&lt;li&gt;Return &lt;strong&gt;clean Markdown&lt;/strong&gt;, with &lt;a href="https://www.zyte.com/zyte-api" rel="noopener noreferrer"&gt;browser rendering, screenshots and geolocation&lt;/a&gt; available where needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search the web&lt;/strong&gt; and consume structured results.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.zyte.com/zyte-api/ai-extraction/" rel="noopener noreferrer"&gt;&lt;strong&gt;Extract structured fields&lt;/strong&gt;&lt;/a&gt; instead of processing a whole page.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.zyte.com/scrapy-cloud" rel="noopener noreferrer"&gt;&lt;strong&gt;Start, stop and monitor Scrapy Cloud jobs&lt;/strong&gt;&lt;/a&gt; from existing deployed spider versions.&lt;/li&gt;
&lt;li&gt;Inspect &lt;strong&gt;job states, statistics, logs and item samples&lt;/strong&gt; to diagnose problems.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create and manage recurring schedules.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Ask about &lt;strong&gt;usage, current spend and success rate by domain&lt;/strong&gt; in natural language.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no separate MCP fee. Usage is metered through Zyte API and Scrapy Cloud as it is today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Agents get the page they actually need
&lt;/h3&gt;

&lt;p&gt;Built-in browsing works for ordinary pages; it is less dependable when a site uses &lt;a href="https://www.zyte.com/zyte-api" rel="noopener noreferrer"&gt;anti-bot protection or requires browser rendering&lt;/a&gt;. Zyte MCP gives the agent a specialist web-access tool, so an answer can be grounded in the current page rather than a refusal, consent notice or model memory.&lt;/p&gt;

&lt;p&gt;The connection’s real power comes from the underlying &lt;a href="https://www.zyte.com/blog/zyte-leads-proxyway-2025-web-scraping-api-report/" rel="noopener noreferrer"&gt;Zyte API, independently rated the industry’s #1 solution for unblocking and speed&lt;/a&gt;. With &lt;a href="https://www.zyte.com/zyte-api/unblocking/" rel="noopener noreferrer"&gt;320,000 access strategies and adaptive unblocking&lt;/a&gt; under the hood, Zyte API has the highest success rate in the game, now available through fluid, conversational interaction via MCP.&lt;/p&gt;

&lt;h3&gt;
  
  
  The work after the fetch stays in the agent
&lt;/h3&gt;

&lt;p&gt;A fetch-only tool helps with prototypes. Production web data also needs jobs to be run, monitored and diagnosed.&lt;/p&gt;

&lt;p&gt;Zyte MCP lets an agent inspect Scrapy Cloud state, logs, statistics and item samples in the same conversation where an engineer reviews the code. That shortens the path from “this crawl looks wrong” to an evidence-based diagnosis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Clean content uses fewer tokens
&lt;/h3&gt;

&lt;p&gt;Raw HTML contains navigation, scripts, styling and repeated page furniture that add cost without helping the model reason.&lt;/p&gt;

&lt;p&gt;Zyte’s MCP can return clean Markdown instead of messy HTML.&lt;/p&gt;

&lt;p&gt;In an internal benchmark of 35 HTML cleanup and HTML-to-Markdown tools plus three hosted services across 299 real pages, Zyte API’s shipped Markdown implementation retained 95% of the main content while removing 85% of unwanted page chrome and reducing HTML token volume by about 97%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five ways to use it
&lt;/h2&gt;

&lt;p&gt;With Zyte MCP Server enabled in your agent as a connector, you can use Zyte fluidly and conversationally, with prompts like these:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Ground research in the live web
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;“Search for the latest official sources about [topic]. Fetch the five best pages, including any ordinary browsing cannot read, and cite a URL for every claim.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent can search for sources, retrieve clean content and synthesise an answer from the pages themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Extract current product data
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;“Extract the name, price, currency, availability and SKU from this product URL. Return JSON and flag any field that is missing from the page.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent receives structured fields rather than parsing noisy HTML in its context window.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Diagnose a failed crawl
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;“Find the latest incomplete run for this spider. Compare it with the previous successful job, inspect the relevant errors and show three affected item samples.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent gathers the evidence from Scrapy Cloud and helps distinguish an access problem from a spider or coverage problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Turn a working spider into a recurring job
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;“Start this deployed spider with these arguments. If the item sample is correct, schedule it for 06:00 every weekday.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent carries out the routine operational steps through structured tools, subject to the user’s approval controls — developers stay in control throughout.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Understand where your spend is going
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;“Where did most of my spend go in the last billing period? Break it down by product and by domain, and highlight any anomalies.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent can pull usage and billing signals into a quick summary, so you can spot cost drivers and unexpected spikes without manually digging through dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose it with the rest of your stack
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.zyte.com/blog/claude-skills-vs-mcp-vs-web-scraping-copilot" rel="noopener noreferrer"&gt;MCP-enabled tools&lt;/a&gt; can work together around one outcome. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pair &lt;strong&gt;GitHub and Zyte MCP&lt;/strong&gt; to review a spider change, start the deployed version and inspect the result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zyte already has &lt;a href="https://www.zyte.com/agentic-web-data/" rel="noopener noreferrer"&gt;plugins&lt;/a&gt; for &lt;a href="https://www.zyte.com/agentic-web-data/" rel="noopener noreferrer"&gt;Claude Code, Codex, Visual Studio Code and GitHub, plus a range of agent-neutral agent skills&lt;/a&gt;.&lt;/strong&gt; Use your own tools to build spiders, fetch the most defended pages and run your web scraping operations without leaving your coding agent.&lt;/li&gt;
&lt;li&gt;Send a daily crawl-health summary to &lt;strong&gt;Slack or another messaging tool&lt;/strong&gt;, including only jobs that need attention.&lt;/li&gt;
&lt;li&gt;Fetch difficult-to-access sources and save a cited research brief to a &lt;strong&gt;knowledge base&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Search, fetch and extract web data before passing an approved sample to an &lt;strong&gt;analytics or storage destination&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to connect
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=wbaVOWvCUgc" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=wbaVOWvCUgc&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Find our &lt;a href="https://docs.zyte.com/zyte-web-data/mcp.html" rel="noopener noreferrer"&gt;MCP documentation&lt;/a&gt; in the Zyte API docs.&lt;/p&gt;

&lt;p&gt;Zyte MCP is hosted, so there is no server package to maintain.&lt;/p&gt;

&lt;p&gt;Add this remote Streamable HTTP endpoint to a compatible MCP client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://mcp.zyte.com/v1/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical configuration is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"zyte"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://mcp.zyte.com/v1/mcp"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Claude Code, the expected command is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http zyte https://mcp.zyte.com/v1/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first connection, complete the Zyte sign-in flow. OAuth gives the client a short-lived, scoped token instead of placing a permanent Zyte API key in the agent’s configuration.&lt;/p&gt;

&lt;p&gt;Then ask for an outcome rather than a tool name:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Fetch this page and return the main content as Markdown.”&lt;/p&gt;

&lt;p&gt;“Extract the current product price and availability from this URL.”&lt;/p&gt;

&lt;p&gt;“List my recent failed Scrapy Cloud jobs and explain the most common error.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What’s next
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.zyte.com/zyte-web-data/mcp.html" rel="noopener noreferrer"&gt;Check out the docs for full details&lt;/a&gt; of capabilities, setup instructions, billing and usage.&lt;/p&gt;

&lt;p&gt;More tools and capabilities for our MCP server are in the pipeline.&lt;/p&gt;

&lt;p&gt;And we have ambitions for &lt;a href="https://www.zyte.com/blog/agentic-web-scraping/" rel="noopener noreferrer"&gt;agentic web data&lt;/a&gt; beyond even MCP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.zyte.com/blog/data-is-the-new-water/" rel="noopener noreferrer"&gt;Fluid data gathering&lt;/a&gt; is already here. Connect your client to &lt;a href="https://mcp.zyte.com/v1/mcp" rel="noopener noreferrer"&gt;mcp.zyte.com/v1/mcp&lt;/a&gt; and ask your agent to fetch a page it could not read before.&lt;/p&gt;

</description>
      <category>zyte</category>
      <category>webscraping</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Stopping a crawl when the data stops looking real</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:46:13 +0000</pubDate>
      <link>https://dev.to/extractdata/stopping-a-crawl-when-the-data-stops-looking-real-17c7</link>
      <guid>https://dev.to/extractdata/stopping-a-crawl-when-the-data-stops-looking-real-17c7</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/jhnwr/scrapy-jev" rel="noopener noreferrer"&gt;&lt;code&gt;scrapy-jev&lt;/code&gt;&lt;/a&gt;, a &lt;a href="https://scrapy.org" rel="noopener noreferrer"&gt;Scrapy&lt;/a&gt; pipeline that asks a fast, cheap AI model whether each scraped field still looks like a real product name, price, SKU, or category, and stops the crawl when too many items fail the check. It works, and it caught the kind of breakage that normally goes unnoticed until someone opens the exported CSV. It also has a narrow job: it catches values that look wrong, not selectors that point at the wrong thing but happen to still return something plausible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I was trying to solve
&lt;/h2&gt;

&lt;p&gt;Scrapers don't announce when they break. A site renames a CSS class, or restructures a product card, and the extractor starts returning the wrong text, or nothing at all, while every other signal in the crawl looks healthy. No exception fires. Nothing appears in the log. You find out when someone downstream opens the export and the price column is full of category names.&lt;/p&gt;

&lt;p&gt;What I wanted was something that could ask, mid-crawl, "does this still look like a real value for this field?" and stop before that damage spread across thousands of pages, rather than finding out afterward from a customer complaint or a broken pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built it with
&lt;/h2&gt;

&lt;p&gt;I wrote the client, pipeline, and add-on with DeepSeek V4 Pro doing most of the coding, working from a spec of the endpoint and the pipeline behavior I wanted.&lt;/p&gt;

&lt;p&gt;The core idea only works if judging a field is cheap and fast enough to run inline, on every crawl, without turning into its own cost center. A general-purpose LLM generating a paragraph of reasoning per field would have been too slow and too expensive to run at any real scale.&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" rel="noopener noreferrer"&gt;Jev&lt;/a&gt;, a "System One" model from TypeSafe AI, reached through &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter's&lt;/a&gt; alpha endpoint. Instead of generating text, Jev returns typed, calibrated decisions. You send it a &lt;code&gt;state&lt;/code&gt; and one or more typed questions, and it returns structured answers with probabilities attached, in roughly 70 to 500 ms.&lt;/p&gt;

&lt;p&gt;I wrote more about what Jev actually is, and where a model like it fits into web scraping, in &lt;a href="https://www.zyte.com/blog/jev-the-model-that-cannot-write-a-word-and-where-it-fits-in-web-scraping-does-it/" rel="noopener noreferrer"&gt;an earlier post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For each field, I sent a two-level &lt;code&gt;score&lt;/code&gt; question, "does this look like a plausible, correctly-scraped value for this field, or not," which meant the returned score was directly interpretable as P(plausible). One request carried one question per field, evaluated against the whole item at once, so a five-field item cost one call, not five.&lt;/p&gt;

&lt;p&gt;On top of that I built three pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JevClient&lt;/code&gt;&lt;/strong&gt;, a thin wrapper that reads item fields through &lt;code&gt;itemadapter&lt;/code&gt;, so it works against Scrapy &lt;code&gt;Item&lt;/code&gt;, plain dicts, &lt;code&gt;attrs&lt;/code&gt;, and dataclasses, and returns a score per field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;QualityGatePipeline&lt;/code&gt;&lt;/strong&gt;, an item pipeline that scores a sample of items, requires every field on an item to clear a threshold to count as a pass (not a majority, since one bad field should fail the item), and stops the spider if the sample's pass rate falls below a configured threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Addon&lt;/code&gt;&lt;/strong&gt;, so installing the package doesn't require hand-editing &lt;code&gt;ITEM_PIPELINES&lt;/code&gt;. It self-disables if no fields are configured, so adding the dependency does nothing until you turn it on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two design choices were deliberate rather than obvious. First, judging a fixed-size sample rather than every item keeps the cost of the check independent of crawl size. A 1,000-page crawl and a 100,000-page crawl spend the same amount on verification. Second, a missing field counts as a failure rather than being skipped, because a selector silently returning &lt;code&gt;None&lt;/code&gt; is the most common way scrapers actually break, and skipping it would have defeated the entire point.&lt;/p&gt;

&lt;p&gt;I checked the actual dollar cost against real calls logged during testing rather than guessing. A three-field scoring request, one field was missing and never sent, ran to 570 input tokens and cost $0.000024, which works out to $0.042 per million input tokens, output is free. Scaled to a full four-field item across a 1-million-item crawl, that's roughly 700 million input tokens, somewhere around $30. Scoring a fixed sample of 20 items per crawl instead, regardless of whether the crawl has 1,000 items or 1,000,000, costs a fraction of a cent.&lt;/p&gt;

&lt;p&gt;At Jev's actual pricing, the dollar difference between sampling and scoring everything isn't the main argument, $30 per million items is trivial either way. The real argument for sampling is that scoring every item means every item's pipeline waits on a round trip to an external API, and the crawl now depends on roughly a million of those calls all succeeding rather than twenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trying it yourself
&lt;/h2&gt;

&lt;p&gt;Installing it doesn't touch &lt;code&gt;ITEM_PIPELINES&lt;/code&gt; by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv add scrapy-jev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning it on is three lines in &lt;code&gt;settings.py&lt;/code&gt;: register the add-on, and tell it which fields to judge.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ADDONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrapy_jev.Addon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;350&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;JEV_FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JEV_API_KEY&lt;/code&gt;, or the &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt; environment variable, has to be set too. If neither is present, or &lt;code&gt;JEV_FIELDS&lt;/code&gt; is empty, the add-on raises &lt;code&gt;NotConfigured&lt;/code&gt; and gets out of the way rather than doing nothing silently.&lt;/p&gt;

&lt;p&gt;The rest is optional and has defaults tuned for a small verification sample rather than exhaustive checking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_FIELDS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fields to judge on each item.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_SAMPLE_SIZE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Items to judge before deciding.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_PASS_RATE_THRESHOLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimum share (0–1) of items that must pass.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_FIELD_THRESHOLD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-field plausibility below which a field fails.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~typesafe/jev-latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Model id for the decisions endpoint.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://openrouter.ai/api/alpha/decisions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Endpoint.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_PIPELINE_PRIORITY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;400&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ITEM_PIPELINES&lt;/code&gt; priority used by the add-on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JEV_API_KEY&lt;/code&gt; / &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;API key (setting or environment variable).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JEV_STOP_REASON&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"Extraction quality fell below threshold (Jev plausibility check)"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Closing reason logged when the gate stops the spider.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The bug that mattered more than the feature
&lt;/h2&gt;

&lt;p&gt;The most useful thing I learned building this had nothing to do with AI. My first version raised &lt;code&gt;CloseSpider&lt;/code&gt; from inside the pipeline to stop the crawl, and the crawl didn't stop. Scrapy's item-processing path wraps &lt;code&gt;process_item&lt;/code&gt; in a bare &lt;code&gt;except Exception&lt;/code&gt;, logs "Error processing item", drops the item, and keeps going. &lt;code&gt;CloseSpider&lt;/code&gt; is an &lt;code&gt;Exception&lt;/code&gt; subclass, so it gets swallowed like any other error. The gate fired every time, and the crawl just kept going anyway, for another thousand pages, retries and all.&lt;/p&gt;

&lt;p&gt;The fix was to stop asking politely and instead tell the engine directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scrapy.utils.defer&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;deferred_from_coro&lt;/span&gt;
&lt;span class="nf"&gt;deferred_from_coro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;crawler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close_spider_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's not a Jev problem or an AI problem. It's the kind of thing you only find by actually watching a crawl you expected to stop keep running, and it's a reminder that the flashy part of a project is rarely where the real debugging happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whether it actually worked
&lt;/h2&gt;

&lt;p&gt;I tested it against a demo target, a fictional 1,000-product coffee shop with markup I'd deliberately built to drift, crawled through scrapy-poet page objects.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Breaking the &lt;code&gt;sku&lt;/code&gt; selector so it returned the product title instead: Jev scored &lt;code&gt;sku&lt;/code&gt; around 0.08 to 0.35 while the other fields stayed above 0.9, and the gate stopped the crawl.&lt;/li&gt;
&lt;li&gt;Breaking the &lt;code&gt;name&lt;/code&gt; selector so it returned nothing: every item in the sample failed, and the crawl stopped with a clear reason logged.&lt;/li&gt;
&lt;li&gt;Renaming the price CSS class: the gate didn't catch it, and correctly so. The demo's price extraction was a regex against a currency-prefixed string, not a class-based selector, so the rename didn't actually break anything. The check didn't cry wolf.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what one of those failures actually looked like in the logs, an item where &lt;code&gt;name&lt;/code&gt; came back &lt;code&gt;None&lt;/code&gt; while the other three fields scored a clean 1.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-21 16:08:28 [scrapy_jev.client] INFO: Jev response: {'model': 'typesafe/jev-1.13-20260917', 'answers': {'price': {'type': 'score', 'score': 1, 'legend': {'0': 'Does not look like a real value for this field', '1': 'Looks like a plausible, correctly-scraped value for this field'}, 'probabilities': {'0': 0, '1': 1}, 'confidence': 0.99}, 'sku': {'type': 'score', 'score': 1, 'legend': {'0': 'Does not look like a real value for this field', '1': 'Looks like a plausible, correctly-scraped value for this field'}, 'probabilities': {'0': 0, '1': 1}, 'confidence': 0.99}, 'category': {'type': 'score', 'score': 1, 'legend': {'0': 'Does not look like a real value for this field', '1': 'Looks like a plausible, correctly-scraped value for this field'}, 'probabilities': {'0': 0, '1': 1}, 'confidence': 0.99}}, 'usage': {'input_tokens': 570, 'output_tokens': 43, 'cost': 2.394e-05}, 'id': 'gen-dec-1790003308-TkGiCpI2ZIe3KtT8fFSn', 'provider': 'TypeSafe'}
2026-09-21 16:08:28 [scrapy_jev.pipeline] WARNING: scrapy_jev: item failed quality check: name=None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;name&lt;/code&gt; isn't in the &lt;code&gt;answers&lt;/code&gt; at all, because a missing value never gets sent to Jev in the first place. &lt;code&gt;JevClient&lt;/code&gt; only asks about fields that have something to judge, and the pipeline treats the absence itself as the failure. &lt;code&gt;price&lt;/code&gt;, &lt;code&gt;sku&lt;/code&gt;, and &lt;code&gt;category&lt;/code&gt; came back at confidence 0.99, which is exactly what should happen when three fields are fine and only one selector is broken.&lt;/p&gt;

&lt;p&gt;That last result mattered more than the first two. A quality gate that fires on cosmetic changes gets disabled by the second team it annoys. This one only fired when the extracted value was actually wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it doesn't help
&lt;/h2&gt;

&lt;p&gt;This is not a general defense against scraper drift, and I don't want to oversell it as one.&lt;/p&gt;

&lt;p&gt;It only catches breakage that produces an implausible-looking value. A selector that starts pointing at the wrong product on the page, but returns something that's still shaped like a real name and a real price, will sail through untouched. Jev has no way to know the name belongs to the wrong item. It's a plausibility check, not a correctness check.&lt;/p&gt;

&lt;p&gt;It's sample-based, which is the right tradeoff for cost but means a crawl can run through a chunk of bad items before or after the sampled window and never trigger the gate, depending on where the breakage falls relative to the sample.&lt;/p&gt;

&lt;p&gt;It also adds a hard dependency on an external API mid-crawl, OpenRouter's alpha endpoint in this case, not even TypeSafe's own. That's one more thing that can be slow, rate-limited, or unavailable at exactly the moment you need the crawl to keep running smoothly. I didn't build in a fallback behavior for that; right now a Jev outage just means the gate isn't checking anything, silently, which is close to the same failure mode the whole project set out to fix.&lt;/p&gt;

&lt;p&gt;The thresholds are guesses too: sample size, pass rate, per-field cutoff, all tuned against one demo site. I have no evidence yet that a 0.7 pass rate and a 0.5 per-field threshold are the right defaults for a real production catalog with more natural variance in its data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it
&lt;/h2&gt;

&lt;p&gt;As a package: yes, with caveats. &lt;code&gt;scrapy-jev&lt;/code&gt; is published, on &lt;a href="https://pypi.org/project/scrapy-jev/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;, with trusted publishing so there's no stored token to leak, and it does the one thing it claims to do. It caught two different kinds of real selector breakage in testing and correctly ignored a change that didn't matter. That's a genuine, narrow win, and the packaging overhead, src layout, CI, changelog, was small next to the core pipeline work.&lt;/p&gt;

&lt;p&gt;As a general answer to "how do I know when my scraper is broken": no, not by itself. It's a plausibility net under specific fields, not a correctness check, and it depends on an external model call succeeding at the exact moment you need it to. It's worth having if you already have a low-cost way to bolt it onto a pipeline, but it isn't something to build a monitoring strategy around by itself. The main thing I got out of it wasn't even the AI check. It was being forced to actually watch a crawl fail to stop, and finding a bug in my assumptions about Scrapy's exception handling that I'd have shipped otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The interesting result wasn't that an AI model can score whether a scraped value looks real. It can, cheaply and fast enough to run inline. What matters more is that the check is only as good as its blind spots, and the most valuable thing the project produced was finding a case where the failure mode I was defending against, a crawl silently continuing after something breaks, was hiding in my own stop-the-spider code, not in the site I was scraping.&lt;/p&gt;

</description>
      <category>jev</category>
      <category>ai</category>
      <category>webscraping</category>
      <category>scrapy</category>
    </item>
    <item>
      <title>Jev, the model that cannot write a word, and where it fits in web scraping (does it?)</title>
      <dc:creator>Ayan Pahwa</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:41:26 +0000</pubDate>
      <link>https://dev.to/extractdata/jev-the-model-that-cannot-write-a-word-and-where-it-fits-in-web-scraping-does-it-45jb</link>
      <guid>https://dev.to/extractdata/jev-the-model-that-cannot-write-a-word-and-where-it-fits-in-web-scraping-does-it-45jb</guid>
      <description>&lt;p&gt;My first reaction to Jev was that I did not get it, and I suspect I was not the only one. You give it something, you ask a question, and it comes back with yes or no and a confidence number. This is where AI started for me. Is this a photo of a dog? Yes, 92% confident. That demo is older than most people's careers, so when the launch thread was filled with people calling it a new category of model, I assumed I was missing the joke. Rather than keep arguing with a comment section, I spent an afternoon putting it into the scraping workflow I actually use.&lt;br&gt;
The part that makes Jev odd is that it cannot write. It does not write badly or write short; it has no ability to produce a string at all. You hand it data and a list of typed questions, and it hands back probabilities and choices which could be decision directions your agents can take, so my first thought was to use it as a complimentary block with LLMs for agentic application use-case to increase accuracy of output or to reduce cost for my agents. More on this later.&lt;br&gt;
Initially it also sounded useless for web scraping, since extraction means producing text. That turned out to be the wrong way around, because there is a job in every pipeline that is not extraction at all: &lt;em&gt;deciding whether the record you just built is any good.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What the hype is about
&lt;/h2&gt;

&lt;p&gt;TypeSafe AI put Jev on Hacker News on September 15, 2026, and the &lt;a href="https://news.ycombinator.com/item?id=49717558" rel="noopener noreferrer"&gt;launch thread&lt;/a&gt; reached more than 1,900 points and 509 comments. Three days later a project called OpenJev drew 714 points of its own.&lt;br&gt;
The appeal is easy enough to state. &lt;strong&gt;Output tokens are free&lt;/strong&gt;, the answer always comes back in the shape you asked for, and a per-record call is quick enough not to be the bottleneck.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;Jev belongs to a category TypeSafe calls System One models. An ordinary language model is autoregressive: it predicts a token, appends it to the context, predicts the next one, and repeats until it decides to stop, which is why output costs money.&lt;br&gt;
Jev is not trained to generate text.&lt;br&gt;
You give it a state, which is your data, and a map of typed questions, and it evaluates every question against that state in parallel. TypeSafe describes what comes back as typed decisions and probabilities rather than generated text. The answer to your fifth question is not waiting on your first.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpn5257c6unlq8d3tgkch.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpn5257c6unlq8d3tgkch.png" alt="A generative model emits tokens one at a time in a chain, while Jev evaluates several typed questions in parallel" width="800" height="440"&gt;&lt;/a&gt;&lt;br&gt;
Everything you can ask is one of three shapes. A &lt;strong&gt;Noul&lt;/strong&gt; is a yes or no question returning a probability between 0 and 1, and it has no confidence field, which catches people out: the probability is the answer, not a measure of how sure the model is. A &lt;strong&gt;Choice&lt;/strong&gt; picks one option from a set you define, up to 255 of them, and returns the winner, the distribution, and a confidence. A &lt;strong&gt;Score&lt;/strong&gt; places the input on a rubric you write, and its number is probability-weighted across your levels, so it can land between two rungs.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it works, in the simplest case
&lt;/h2&gt;

&lt;p&gt;A request is one flat JSON object, and the answers come back under keys you chose yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Help! My payouts have been failing for three days."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"is_urgent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does this convey urgency?"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"team"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which team should handle this?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Payments, invoicing, refunds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"technical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bugs, outages, integrations"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get &lt;code&gt;is_urgent&lt;/code&gt; back as a float and team as one of your two strings plus confidence. No parsing, no retry loop, no prompt engineering to coax valid JSON out of a model that would rather write a paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it differs from a language model, and what it costs you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Output tokens are free. Input runs at $0.042 per million tokens and everything the model produces costs nothing&lt;/strong&gt;, which inverts the usual incentive: with a generative model you ask the fewest questions you can get away with, and here you ask everything you might want to know. The answer is always a valid instance of the type you requested. Responses land inside two seconds. TypeSafe says the probabilities are calibrated; I did not test that, and one result below makes me want to.&lt;br&gt;
The launch coverage mostly stops there. The next bit matters more. You will always get a well-formed answer, it can be completely wrong, and arriving in a tidy shape makes it easier to trust than it has earned.&lt;br&gt;
Against that, it cannot generate a string, so it can never fill in a field for you. TypeSafe's own &lt;a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" rel="noopener noreferrer"&gt;model jaggedness page&lt;/a&gt; is blunt about the consequence: "For data extraction, it is better to extract possible options using regex or a generative model and let &lt;code&gt;jev-1.13&lt;/code&gt; pick the correct extraction." Your code proposes, the model decides.&lt;br&gt;
A Choice always returns one of the options you gave it, and the confidence does not reliably warn you when none of them fit: in a larger run it labeled a category listing page poetry at a confidence of 1.00.&lt;br&gt;
The documentation tells you to add an other or none of the above options for exactly that reason, and I did not, which you will see the cost of below. Context is capped at 64k tokens per request, with 32k for the state plus your longest question. It is text only, English first, and the documentation is honest that CJK scripts are handled but not equally well.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where it could fit in web scraping
&lt;/h2&gt;

&lt;p&gt;My filter, after getting this wrong twice: &lt;strong&gt;if a regular expression, a status code, or a CSS class can answer the question, do not ask a model.&lt;/strong&gt; Those signals are structural, and code beats a non-deterministic model on structure every time, for nothing.&lt;br&gt;
What is left is the questions where the answer only exists in the language, and where the alternative is a hand-maintained list of phrases that is never finished. Classifying a product into a category, or deciding whether a description actually describes the thing it is attached to, are not regex problems, and I have a number for that claim further down.&lt;br&gt;
A gate runs after the work, on a record you have already built, and decides whether to trust it. A switch runs before, and decides what the pipeline does next, so it is asking ahead of the expensive thing instead of auditing after it. Switches are the more interesting group if cost is what you care about, and they are not unique to scraping: routing a support ticket to a person or a canned reply, sending an uploaded document to the right parser, and deciding whether a log line is worth waking anyone over are all the same shape.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzp2hckpigg57dvyff4l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzp2hckpigg57dvyff4l.png" alt="A gate runs after the work and decides whether to trust a record. A switch runs before it and picks a cheap branch, an expensive branch, or skipping the page" width="799" height="484"&gt;&lt;/a&gt;&lt;br&gt;
The scraping switch I keep coming back to is choosing an extraction type, because &lt;a href="https://www.zyte.com/zyte-api/" rel="noopener noreferrer"&gt;Zyte API&lt;/a&gt; will not let you combine multiple automatic extraction fields in one request, so something upstream perhaps could decide between product, article, job posting, and the rest before you spend the call. I have not tested that one yet, and my own filter argues against it: most sites announce their page type structurally, in the URL, in JSON-LD &lt;code&gt;@type&lt;/code&gt;, or in an &lt;code&gt;og:type&lt;/code&gt; tag, and code should read those first. A Choice earns a look only for the pages where none of that is present.&lt;/p&gt;
&lt;h2&gt;
  
  
  One real example
&lt;/h2&gt;

&lt;p&gt;The shape I ended up with is the gate, and it is about as plain as it looks:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn8ojp58yre4ffvybknd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn8ojp58yre4ffvybknd.png" alt="Sequence diagram: fetch the page with requests, parse with BeautifulSoup, run cheap checks, then one batched call of six questions to Jev before writing or holding the record" width="800" height="821"&gt;&lt;/a&gt;&lt;br&gt;
I scraped a single book from &lt;a href="https://books.toscrape.com" rel="noopener noreferrer"&gt;books.toscrape.com&lt;/a&gt;, using nothing but &lt;a href="https://requests.readthedocs.io/" rel="noopener noreferrer"&gt;requests&lt;/a&gt; and &lt;a href="https://www.crummy.com/software/BeautifulSoup/" rel="noopener noreferrer"&gt;BeautifulSoup&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;bs4&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BeautifulSoup&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scrape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="n"&gt;soup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BeautifulSoup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;html.parser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cells&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;table.table-striped td&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="n"&gt;heading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;h1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#product_description ~ p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;# the site does not publish one
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^\d.]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;£&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ul.breadcrumb li a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;soup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ul.breadcrumb li a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;availability&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those &lt;code&gt;if heading else None&lt;/code&gt; guards are not decoration. Point these selectors at a page that does not exist and they all come back empty, and without the guards the parse raises before you reach the interesting part.&lt;br&gt;
Then one call to Jev API carrying six questions about that record at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;QUESTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_a_book&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noul&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Is this record a single real book, rather than an error page or a listing page?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;price_present&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noul&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Is the `price` field filled in with a real price?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author_present&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noul&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Is the `author` field filled in with a real author name?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency_right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noul&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Is the `currency` field the right currency for the `price` shown on this listing?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;category_right&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;noul&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Does the `category` field match what the title and description are actually about?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;genre&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choice&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which genre does this book belong to?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criteria&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;poetry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;travel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mystery&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;science fiction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;self help&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;art&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;music&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;humor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask_jev&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.typesafe.ai/v1/systemone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TYPESAFE_API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jev-latest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;QUESTIONS&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole integration: a dictionary in, six typed answers out. Basically a Jev powered data quality checker.&lt;/p&gt;

&lt;h2&gt;
  
  
  A good record, a bad one, and one that is quietly wrong
&lt;/h2&gt;

&lt;p&gt;Jev responded :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ "name": "A Light in the Attic", "author": null,
  "description": "It's hard to imagine a world without A Light i...",
  "price": "51.77", "currency": "GBP", "category": "Poetry",
  "availability": "In stock (22 available)" }
  is_a_book        0.85
  price_present    0.71
  author_present   0.01
  currency_right   0.61
  category_right   0.96
  genre            poetry (confidence 1.00)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a URL that does not exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{ "name": "404 Not Found", "author": null, "description": null,
  "price": null, "currency": null, "category": null, "availability": null }
  is_a_book        0.04
  category_right   0.08
  genre            humor (confidence 0.41)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jev rejects it, and &lt;code&gt;author_present&lt;/code&gt; reports 0.01 on both, because this site publishes no author anywhere.&lt;br&gt;
That second record should never have reached the model, though. The response was a 404, &lt;code&gt;raise_for_status()&lt;/code&gt; would have ended it a line earlier, and a null check catches it anyway. My own filter says so. I show it because it is the failure people actually ship, not because it needs a model. This next one does.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug no check can see
&lt;/h2&gt;

&lt;p&gt;Take the correctly scraped record and swap in the description of a different book.&lt;br&gt;
Think of this as the zip bug, or the pagination bug, the off-by-one in a list comprehension, and I have shipped it more than once. Every field is populated, every type is right, the price is positive, and the status code is 200.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  name        : 'A Light in the Attic'
  description : 'WICKED above her hipbone, GIRL across her heart...'
  null check  : PASSES
  type check  : price float() = 51.77, positive
  category_right   0.04 &amp;lt;-- DATA QUALITY :D
  genre            mystery (confidence 0.69)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;category_right&lt;/code&gt; falls from 0.96 to 0.04&lt;/strong&gt;, and the genre follows the planted description rather than the title, which is a tell that the model read the field instead of pattern-matching the name. Across 59 correct records and the same 59 with descriptions shifted by one, that question caught 46 crossings at a 0.5 threshold with zero false alarms, and 52 at 0.7 with one. Most of those crossings land within the same genre, which is the harder case.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmr78swlz2l05d234yll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhmr78swlz2l05d234yll.png" alt="Four checks in cost order: null, type, and range checks are free, and Jev is the last and narrowest layer" width="799" height="238"&gt;&lt;/a&gt;&lt;br&gt;
Now look again at the good record, because two of those answers are weaker than they should be. &lt;code&gt;price_present&lt;/code&gt; came back 0.71 on a record where the price is there, and that is a question a null check answers perfectly and for free. &lt;code&gt;currency_right&lt;/code&gt; came back 0.61, and that one is my fault: my parser strips the pound sign before building the record, so the state I sent Jev contained no evidence of any currency at all. It was right to shrug. The jaggedness page tells you to point a question at the relevant state, and I had deleted it. The strong answers, 0.96 and 1.00, are the judgment calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this saves
&lt;/h2&gt;

&lt;p&gt;I sent the identical record and the identical six questions to a cheap generative model, &lt;code&gt;gpt-5.6-luna&lt;/code&gt;, routed through OpenRouter so its latency carries a hop that Jev's does not, three runs each:&lt;br&gt;
|  | latency | tokens | cost |&lt;br&gt;
|---|---|---|---|&lt;br&gt;
| Jev | &lt;strong&gt;0.92s&lt;/strong&gt; | &lt;strong&gt;776 in, output free&lt;/strong&gt; | &lt;strong&gt;$0.000033&lt;/strong&gt; |&lt;br&gt;
| generative model | 3.8s to 8.9s | 457 in, 117 to 142 out | $0.000232 to $0.000262 |&lt;br&gt;
Roughly 7X the cost, on one record. The latency spread is the more interesting half: &lt;strong&gt;Jev sat between 0.92 and 0.97 seconds across every run, while the generative model ranged from under 4 seconds to nearly 9.&lt;/strong&gt; You can plan around the first number.&lt;br&gt;
One deduction, in fairness: the shape advantage is smaller than it looks, because a generative API can be pushed into a schema with structured outputs. Cost and consistency are the real differences.&lt;br&gt;
At a million records a day with six questions each, that gap is roughly $33 against $232, though TypeSafe's published ceiling of 1,200 requests a minute puts a million a day at about 58% of the limit before you ask for more. &lt;strong&gt;The marginal question is nearly free: a seventh check, or a twentieth, costs a few more input tokens for the wording and no extra time, because they are evaluated together.&lt;/strong&gt;&lt;br&gt;
That last point deserves a number against the alternative it replaces. On a separate run over 59 books, using the site's own category as the answer key and showing the model only the title and description, one Choice got 55 right against 43 for the best keyword list I could write. All four of Jev's misses were arguable rather than wrong: business against self help, twice, and travel against art. That run cost $0.00188 in total.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your mileage will vary
&lt;/h2&gt;

&lt;p&gt;Everything here ran against &lt;code&gt;jev-1.13.0&lt;/code&gt; on one teaching site, in English, on one afternoon, with three runs of the comparison. Treat it as a first look, not a benchmark.&lt;br&gt;
Before relying on any of it, know this. &lt;strong&gt;Jev is not deterministic.&lt;/strong&gt; Across repeated identical calls the confident answers held steady while the borderline ones drifted by several points, so do not put a threshold anywhere near where the answers wobble. I was ready to present that as my own finding until I read further into TypeSafe's cookbooks and found they had measured the same thing: six of eight Nouls came back with a standard deviation of exactly zero across five repeats while two carried noise, and their conclusion is the useful one. "The noise is a property of the question, not of how you batch."&lt;br&gt;
A well-formed request can also encode the wrong question. I once passed a Choice's options as a list nested under a key called options, expecting an error. None came, because criteria accepts a map whose values may be arrays, so what I had sent was a valid one-option Choice. Jev returned options as the winner at a confidence of 1.0. Nothing was broken and the answer was useless: the type is guaranteed, the meaning is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other ways to use it
&lt;/h2&gt;

&lt;p&gt;None of these are tested. Triaging spider monitor alerts so a human only sees the ambiguous ones, which pairs naturally with &lt;a href="https://www.zyte.com/blog/spider-monitoring-made-easy/" rel="noopener noreferrer"&gt;Spidermon&lt;/a&gt; or a &lt;a href="https://www.zyte.com/blog/meet-scrapy-spidey-sense-a-preflight-check-for-scrapy-spiders/" rel="noopener noreferrer"&gt;preflight check like scrapy-spidey-sense&lt;/a&gt;. Adjudicating deduplication candidates your own code has already shortlisted. Flagging listings whose description contradicts their own attributes.&lt;br&gt;
One caution applies to all of them. The state you send is untrusted text off the internet, and TypeSafe is direct about it: "Content written to adversarially steer the model, whether that is an injected instruction, a deliberately misleading framing, or text that argues for its own classification, can move the answer." That last case should worry a scraper, because a page has every incentive to argue for its own classification. Their cookbook is blunter still: nothing here is a security boundary. The same thinking applies as to &lt;a href="https://www.zyte.com/blog/the-page-your-agent-scrapes-is-now-an-attack-surface-is-it-ready-for-the-hostile-web/" rel="noopener noreferrer"&gt;any model reading a hostile page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Jev may never write you a selector. For now it is a cheap and very fast second opinion on data you already have, and the free output tokens mean you can afford that opinion on every record rather than on a sample.&lt;br&gt;
I would ship it for the narrow job, with thresholds tuned on my own data and every cheap check running in front of it. I would not replace a null check with a model call.&lt;br&gt;
As for the dog photo, it is the same shape of question I was writing classifiers for a decade ago. What changed is that I did not train anything. No labeled set, no feature engineering, and when I wanted an eleventh genre I added a key to a dictionary instead of collecting examples. That, and the price, is what changes which questions are worth asking at all.&lt;br&gt;
&lt;em&gt;Originally published on &lt;a href="https://www.zyte.com/blog/jev-the-model-that-cannot-write-a-word-and-where-it-fits-in-web-scraping-does-it/" rel="noopener noreferrer"&gt;Zyte&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webscraping</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>uv Python cheatsheet: what changed in 0.12 and what still trips you up</title>
      <dc:creator>Ayan Pahwa</dc:creator>
      <pubDate>Fri, 18 Sep 2026 18:05:05 +0000</pubDate>
      <link>https://dev.to/extractdata/uv-python-cheatsheet-what-changed-in-012-and-what-still-trips-you-up-5b38</link>
      <guid>https://dev.to/extractdata/uv-python-cheatsheet-what-changed-in-012-and-what-still-trips-you-up-5b38</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Use the uv cheatsheet we created for Zyte's community : &lt;a href="https://github.com/zytelabs/uv-cheatsheet" rel="noopener noreferrer"&gt;https://github.com/zytelabs/uv-cheatsheet&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;uv init&lt;/code&gt; does not do what it did in April, the very few issues I had with it has been fixed in the recent releases but it's one good package manager that I never felt a need to update and hence couldn't really enjoy the latest and greatest, until this blog.&lt;br&gt;
I keep a working reference in my repository, the kind you paste into a colleague's message when they ask how to start a project, and when I checked it against a current build the two entries I trusted most did not survive. One had been wrong since late July. The other had never been right on either version I tested, and I had been passing it around anyway.&lt;/p&gt;

&lt;p&gt;This is normal for a tool moving at uv's pace rather than a scandal. The big ideas hold: &lt;strong&gt;uv is still the fast Rust-based package and project manager that replaced an awkward pile of older tools, and it is still the right default.&lt;/strong&gt; What drifts is the layer underneath, the part you actually type.&lt;/p&gt;

&lt;p&gt;Everything below was reproduced against uv 0.12.15, released on Tuesday, September 15, 2026, with uv 0.11.7 installed alongside, so every before and after is real terminal output rather than recollection.&lt;/p&gt;
&lt;h2&gt;
  
  
  The command that changed under everyone
&lt;/h2&gt;

&lt;p&gt;Since uv 0.12.0, released on Tuesday, July 28, 2026, projects created with &lt;code&gt;uv init&lt;/code&gt; &lt;a href="https://github.com/astral-sh/uv/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;define a build system and are packaged by default&lt;/a&gt;, so you get a &lt;code&gt;src&lt;/code&gt; layout, a &lt;code&gt;[project.scripts]&lt;/code&gt; entry, and a &lt;code&gt;[build-system]&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv init demo
Initialized project &lt;span class="sb"&gt;`&lt;/span&gt;demo&lt;span class="sb"&gt;`&lt;/span&gt; at &lt;span class="sb"&gt;`&lt;/span&gt;/private/tmp/demo&lt;span class="sb"&gt;`&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;find demo &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/.git/*'&lt;/span&gt; | &lt;span class="nb"&gt;sort
&lt;/span&gt;demo/.gitignore
demo/.python-version
demo/pyproject.toml
demo/README.md
demo/src/demo/__init__.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project.scripts]&lt;/span&gt;
&lt;span class="py"&gt;demo&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"demo:main"&lt;/span&gt;
&lt;span class="nn"&gt;[build-system]&lt;/span&gt;
&lt;span class="py"&gt;requires&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;["uv_build&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mf"&gt;0.13&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="s"&gt;"]&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;build-backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"uv_build"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The older behavior gave you a bare &lt;code&gt;main.py&lt;/code&gt; at the top level with no build system, which meant your own code was not installed into its own virtual environment and was importable only by accident of whichever directory you happened to be standing in. That layout still exists behind &lt;code&gt;uv init --no-package&lt;/code&gt;, and existing projects are untouched, so nothing breaks.&lt;br&gt;
The entry in my notes said the reverse. It said &lt;code&gt;uv init --package&lt;/code&gt; was the flag you needed for a build system and a &lt;code&gt;src&lt;/code&gt; layout, and that without it your imports worked by luck. True in April 2026, backwards now, and exactly the sort of small thing a reader copies into a project and then carries for a year.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why switch?
&lt;/h2&gt;

&lt;p&gt;I asked &lt;a href="https://www.zyte.com/author/john-rooney/" rel="noopener noreferrer"&gt;John&lt;/a&gt;, our developer engagement manager, what &lt;em&gt;uv&lt;/em&gt; replaced for him, because I wanted the version of this that comes from using the thing rather than reading about it. His answer was pip and venv, which he called fine, just slow and dated next to modern tooling. He had tried Poetry, liked the idea of it, and never got it to stick. What he actually wanted was cargo for Python.&lt;br&gt;
Two things closed that gap. One was speed. The other was the piece pip never had, which is a way to run a tool you have not installed. Node developers have had npx for years and Python had nothing, and &lt;code&gt;uvx&lt;/code&gt; is that.&lt;/p&gt;

&lt;p&gt;What he does now is unremarkable in the best way. Everything goes through uv, his agents run &lt;code&gt;uv sync&lt;/code&gt;, and &lt;code&gt;pyproject.toml&lt;/code&gt; turned dependency management and Dockerfiles into something standard rather than something every project reinvents. Scrapy and Scrapy Cloud work exactly as they did before.&lt;br&gt;
His only complaint was having to delete the &lt;code&gt;main.py&lt;/code&gt; file that &lt;code&gt;uv init&lt;/code&gt; leaves behind.&lt;br&gt;
That complaint is not an issue anymore. It is the same 0.12.0 change from the section above. The packaged default produces &lt;code&gt;src/&amp;lt;name&amp;gt;/__init__.py&lt;/code&gt; and no &lt;code&gt;main.py&lt;/code&gt; at all: WIN!!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv init mp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; find mp &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/.git/*'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On uv 0.11.7 that leaves you a &lt;code&gt;main.py&lt;/code&gt; to delete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mp/.gitignore
mp/.python-version
mp/main.py
mp/pyproject.toml
mp/README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 0.12.15 it does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mp/.gitignore
mp/.python-version
mp/pyproject.toml
mp/README.md
mp/src/mp/__init__.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;His one real gripe with uv is already fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where uv already lives in the Scrapy world?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://scrapy.org/" rel="noopener noreferrer"&gt;Scrapy&lt;/a&gt; runs its continuous integration on uv today. The workflow pins &lt;code&gt;astral-sh/setup-uv&lt;/code&gt;, drives the test matrix through &lt;code&gt;uvx --with tox-uv tox&lt;/code&gt;, and sets &lt;code&gt;UV_PYTHON_PREFERENCE: only-system&lt;/code&gt; with a comment explaining why, which is to make uv use the interpreter &lt;code&gt;actions/setup-python&lt;/code&gt; already installed instead of downloading one of its own. Steal that last setting. In continuous integration you have usually already paid for a specific interpreter, and letting uv helpfully fetch a second one gives you a build that passes against a Python your users are not running.&lt;br&gt;
Although Scrapy's installation guide doesn't mention uv. It covers pip, it covers conda, it covers virtual environments, and it stops. Documentation usually trails practice so this is nobody's failure, but it is real and fixable, and since Zyte maintains Scrapy it is a gap we can close rather than complain about. If you have ever wondered what a genuinely useful first contribution to a large open-source project looks like, updating an installation page to match what the maintainers already do is a strong candidate.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two modes that do not mix
&lt;/h2&gt;

&lt;p&gt;uv has two personalities. In project mode, &lt;code&gt;pyproject.toml&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; are the source of truth, &lt;code&gt;.venv&lt;/code&gt; is disposable output, and you drive everything with &lt;code&gt;uv add&lt;/code&gt;, &lt;code&gt;uv sync&lt;/code&gt;, &lt;code&gt;uv lock&lt;/code&gt;, and &lt;code&gt;uv run&lt;/code&gt;. In pip mode, &lt;code&gt;uv venv&lt;/code&gt; and &lt;code&gt;uv pip install&lt;/code&gt; reproduce the workflow you already know, and the source of truth is whatever you remember typing. Both are called uv, and nothing in the interface tells you which one you are in.&lt;/p&gt;

&lt;p&gt;Everything uv does from the first command onward moves between three files, and almost every confusion is about which one a command writes to.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6x8znb3whkwmw84s4lb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff6x8znb3whkwmw84s4lb.png" alt="A diagram showing uv add writing pyproject.toml to uv.lock, and uv sync writing uv.lock into .venv" width="800" height="704"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;What each uv command actually writes to.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The folklore, which I had written down myself, says that &lt;code&gt;uv pip install&lt;/code&gt; inside a project gets silently undone by the next &lt;code&gt;uv run&lt;/code&gt; or &lt;code&gt;uv sync&lt;/code&gt;. Tested on both 0.11.7 and 0.12.15, that is half right. &lt;code&gt;uv run&lt;/code&gt; prunes nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv pip &lt;span class="nb"&gt;install &lt;/span&gt;six          &lt;span class="c"&gt;# six is not a project dependency&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;uv run python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"pass"&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;uv run &lt;span class="nt"&gt;--no-sync&lt;/span&gt; python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import six"&lt;/span&gt;
&lt;span class="c"&gt;# six is still there&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;uv sync&lt;/code&gt; is what removes it, because &lt;code&gt;uv sync&lt;/code&gt; is exact by default and makes the environment match the lockfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv &lt;span class="nb"&gt;sync
&lt;/span&gt;Resolved 1 package &lt;span class="k"&gt;in &lt;/span&gt;2ms
Uninstalled 1 package &lt;span class="k"&gt;in &lt;/span&gt;0.47ms
 - &lt;span class="nv"&gt;six&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;1.17.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also an escape hatch almost nobody mentions. &lt;code&gt;uv sync --inexact&lt;/code&gt; installs your declared dependencies and leaves anything extra alone, so the package survives the round trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv pip &lt;span class="nb"&gt;install &lt;/span&gt;six &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--inexact&lt;/span&gt;
&lt;span class="c"&gt;# six is still there&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Memorize the precise version rather than the vague one: &lt;code&gt;uv sync&lt;/code&gt; is exact by default, &lt;code&gt;--inexact&lt;/code&gt; opts out, and &lt;code&gt;uv run&lt;/code&gt; does not prune. The practical advice is unchanged, since inside a project the durable ways to add a package are &lt;code&gt;uv add&lt;/code&gt; or an edit to &lt;code&gt;pyproject.toml&lt;/code&gt; followed by a lock, but knowing which command did the deleting is the difference between fixing a problem and performing a ritual.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hash check that did nothing
&lt;/h2&gt;

&lt;p&gt;If you pin dependencies by hash, you have probably written a &lt;code&gt;requirements.txt&lt;/code&gt; beginning with the &lt;code&gt;--require-hashes&lt;/code&gt; directive. Before uv 0.12.0, uv read that directive, told you it was unsupported, and installed your packages anyway without checking a single hash. Here is uv 0.11.7 against a file containing &lt;code&gt;--require-hashes&lt;/code&gt; and one pinned requirement carrying no hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warning: Ignoring unsupported option in `requirements.txt`: `--require-hashes` (hint: pass `--require-hashes` on the command line instead)
Resolved 1 package in 82ms
Installed 1 package in 1ms
 + six==1.17.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warning is accurate and the outcome is still wrong. You asked for a guarantee, and what you got was a note explaining the guarantee had been declined, followed by the install proceeding regardless. Scrolling past in a long build log, that line is invisible.&lt;br&gt;
uv 0.12.0 made the directive real. The same file on 0.12.15:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;error: In &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nt"&gt;--require-hashes&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt; mode, all requirements must have a &lt;span class="nb"&gt;hash&lt;/span&gt;, but none were provided &lt;span class="k"&gt;for&lt;/span&gt;: &lt;span class="nv"&gt;six&lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;1.17.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing installs. The build fails, correctly, because a requirement with no hash cannot be hash checked.&lt;br&gt;
The consequence deserves stating plainly. If you put &lt;code&gt;--require-hashes&lt;/code&gt; in a requirements file, installed it with uv before July 28, 2026, and did not also pass the flag on the command line, you did not have hash-checked installs, whatever your build log implied. Any pipeline claiming hash pinning is worth revisiting to confirm which uv it ran on, and if the answer is pre-0.12, whether the directive was passed on the command line rather than only living in the file.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pin uv itself
&lt;/h2&gt;

&lt;p&gt;Most of us pin our dependencies. Far fewer pin the tool doing the pinning, and uv is pre-1.0 software sitting in the most load-bearing step of the build.&lt;br&gt;
On Tuesday, September 15, 2026, uv shipped 0.12.14 and 0.12.15 on the same day, because 0.12.14 carried a regression that rejected valid installation commands, including &lt;code&gt;uv pip install --system&lt;/code&gt; inside the official &lt;code&gt;python&lt;/code&gt; Docker images. If your build used that command without pinning uv, it broke. The fix arrived within hours, which is a good outcome you would still rather have watched from a distance.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Friy7o9cwtm0sae95d7p1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Friy7o9cwtm0sae95d7p1.png" alt="A diagram showing uv version pinned to a known-good uv 0.12.15, which then produces a committed uv.lock" width="800" height="1043"&gt;&lt;/a&gt;&lt;br&gt;
A quieter version of the same lesson turned up while writing this. My machine's package manager was serving uv 0.12.13 while upstream was on 0.12.15. Neither number is wrong, but "latest" and "latest from your package manager" are different claims, and only one of them reproduces for a colleague on another operating system. Pin the version in your images and your continuous integration, then upgrade deliberately, the way you would treat any dependency you cannot easily roll back.&lt;/p&gt;
&lt;h2&gt;
  
  
  Five traps worth five minutes
&lt;/h2&gt;

&lt;p&gt;None of these are bugs. They are places where a reasonable expectation meets a different design decision, and all five were reproduced on 0.12.15.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu78s6lpl27t80lrvlz9q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu78s6lpl27t80lrvlz9q.png" alt="inline dependencies and script run" width="800" height="1044"&gt;&lt;/a&gt;&lt;br&gt;
The first is that &lt;code&gt;uv run&lt;/code&gt; runs a script while &lt;code&gt;uvx&lt;/code&gt; runs a tool. &lt;code&gt;uv run script.py&lt;/code&gt; executes the file and reads its &lt;a href="https://peps.python.org/pep-0723/" rel="noopener noreferrer"&gt;PEP 723&lt;/a&gt; inline dependency header; &lt;code&gt;uvx&lt;/code&gt; is the tool runner and does not, which matters whenever a single-file program is driven by some other command. That trap nearly shipped in my earlier article on &lt;a href="https://www.zyte.com/blog/web-data-in-a-reactive-notebook-an-introduction-to-marimo/" rel="noopener noreferrer"&gt;running web data workflows in a reactive notebook&lt;/a&gt;, where the documented command worked locally and would have failed for every reader who cloned the repository fresh. A clean-room test in an empty directory caught it before publication. To uv's credit, the error now signposts the way out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uvx s.py
error: It looks like you tried to run a Python script at &lt;span class="sb"&gt;`&lt;/span&gt;s.py&lt;span class="sb"&gt;`&lt;/span&gt;, which is not supported by &lt;span class="sb"&gt;`&lt;/span&gt;uvx&lt;span class="sb"&gt;`&lt;/span&gt;
hint: Use &lt;span class="sb"&gt;`&lt;/span&gt;uv run s.py&lt;span class="sb"&gt;`&lt;/span&gt; instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, &lt;code&gt;--locked&lt;/code&gt; asserts and &lt;code&gt;--frozen&lt;/code&gt; ignores. &lt;code&gt;uv sync --locked&lt;/code&gt; fails when &lt;code&gt;uv.lock&lt;/code&gt; no longer matches &lt;code&gt;pyproject.toml&lt;/code&gt;, which is what you want guarding continuous integration. &lt;code&gt;uv sync --frozen&lt;/code&gt; skips the check and installs against the stale lockfile without comment. Two flags that look like synonyms and behave like opposites.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvniev530r2q1zl9w4iyz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvniev530r2q1zl9w4iyz.png" alt="uv sync --locked" width="800" height="882"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--locked&lt;/span&gt;
error: The lockfile at &lt;span class="sb"&gt;`&lt;/span&gt;uv.lock&lt;span class="sb"&gt;`&lt;/span&gt; needs to be updated, but &lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nt"&gt;--locked&lt;/span&gt;&lt;span class="sb"&gt;`&lt;/span&gt; was provided.
hint: To update the lockfile, run &lt;span class="sb"&gt;`&lt;/span&gt;uv lock&lt;span class="sb"&gt;`&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, &lt;code&gt;uv python pin&lt;/code&gt; does not rebuild your environment. It rewrites &lt;code&gt;.python-version&lt;/code&gt; and stops, and the existing &lt;code&gt;.venv&lt;/code&gt; keeps whatever interpreter it had until &lt;code&gt;uv sync&lt;/code&gt; deletes and recreates it. Note also that &lt;code&gt;requires-python&lt;/code&gt; outranks the pin, so pinning 3.12 under &lt;code&gt;requires-python = "&amp;gt;=3.13"&lt;/code&gt; fails loudly rather than disagreeing in silence, which is the right call.&lt;br&gt;
Fourth, uv virtual environments ship without pip. This is deliberate and almost always fine, right until a legacy tool shells out to &lt;code&gt;python -m pip&lt;/code&gt; and reports no module named pip. &lt;code&gt;uv venv --seed&lt;/code&gt; puts it back.&lt;br&gt;
Fifth, &lt;code&gt;uv add&lt;/code&gt; writes a lower bound rather than a pin. &lt;code&gt;uv add six&lt;/code&gt; puts &lt;code&gt;six&amp;gt;=1.17.0&lt;/code&gt; in &lt;code&gt;pyproject.toml&lt;/code&gt;, and the resolved version lives only in &lt;code&gt;uv.lock&lt;/code&gt;. Commit the lockfile, for applications and libraries alike, because it is the only artifact recording what you actually tested against.&lt;/p&gt;
&lt;h2&gt;
  
  
  Locking a crawler
&lt;/h2&gt;

&lt;p&gt;Scrapy is a good place to watch all of this land at once, because a crawler has the properties that make dependency management interesting: a lockfile that has to survive a rebuild, an image rebuilt every time a selector changes, and a continuous integration run where a stale lock should fail loudly rather than pass quietly.&lt;br&gt;
Starting one takes two commands, and the second is the one that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;uv init crawler &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;crawler
&lt;span class="nv"&gt;$ &lt;/span&gt;uv add scrapy
&lt;span class="nv"&gt;$ &lt;/span&gt;uv run scrapy version
Scrapy 2.19.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That wrote &lt;code&gt;scrapy&amp;gt;=2.19.0&lt;/code&gt; into &lt;code&gt;pyproject.toml&lt;/code&gt; and 47 packages into &lt;code&gt;uv.lock&lt;/code&gt;. The 47 is the number worth noticing, because Scrapy pulls in Twisted, lxml, cryptography, and a long tail beneath them, so the distance between "I installed Scrapy" and "I can rebuild this exact environment in six months" is 46 packages you never chose. &lt;code&gt;uv sync --locked&lt;/code&gt; is what closes that distance, and in continuous integration it is the difference between a build that fails on a stale lockfile and one that quietly resolves something new.&lt;br&gt;
For the container, ordering does the work. Astral publishes uv as an image, and the pattern in &lt;a href="https://docs.astral.sh/uv/guides/integration/docker/" rel="noopener noreferrer"&gt;their Docker guide&lt;/a&gt; copies the binary in at a pinned version rather than installing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.13-slim&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=ghcr.io/astral-sh/uv:0.12.15 /uv /uvx /bin/&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pyproject.toml uv.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--locked&lt;/span&gt; &lt;span class="nt"&gt;--no-install-project&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;uv &lt;span class="nb"&gt;sync&lt;/span&gt; &lt;span class="nt"&gt;--locked&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["uv", "run", "scrapy", "crawl", "products"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details there earn their place. Dependencies are synced before your source is copied, so editing a spider leaves the layer holding Twisted, lxml, and cryptography untouched instead of rebuilding it. And the uv version is pinned in the &lt;code&gt;COPY&lt;/code&gt; line, which is the concrete form of the advice from earlier: on September 15, that pin was the difference between a broken build and an uneventful one. If your crawls drive a browser, the same ordering matters more, because the dependency layer gets considerably heavier once a browser is in it, which John covers in &lt;a href="https://www.zyte.com/blog/running-playwright-at-scale-connecting-to-the-zyte-cdp-browser/" rel="noopener noreferrer"&gt;running Playwright at scale&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would still change
&lt;/h2&gt;

&lt;p&gt;An honest cheatsheet should say where a tool annoys its own users, and uv's issue tracker is unusually clear about this, since the requests are heavily upvoted and have been open a long time. As of Thursday, September 17, 2026, the most-supported open requests are &lt;a href="https://github.com/astral-sh/uv/issues/5903" rel="noopener noreferrer"&gt;using &lt;code&gt;uv run&lt;/code&gt; as a task runner&lt;/a&gt; at 702 up-votes and 254 comments, an &lt;a href="https://github.com/astral-sh/uv/issues/1419" rel="noopener noreferrer"&gt;&lt;code&gt;upgrade --all&lt;/code&gt; option&lt;/a&gt; at 536, a &lt;a href="https://github.com/astral-sh/uv/issues/6794" rel="noopener noreferrer"&gt;dedicated &lt;code&gt;uv upgrade&lt;/code&gt;&lt;/a&gt; for bumping &lt;code&gt;pyproject.toml&lt;/code&gt; at 532, and a &lt;a href="https://github.com/astral-sh/uv/issues/1910" rel="noopener noreferrer"&gt;&lt;code&gt;uv shell&lt;/code&gt; activation command&lt;/a&gt; at 416.&lt;br&gt;
Those four cluster around one theme, which is that uv replaced the tools people used for everyday chores without replacing the chores. Upgrading a dependency and writing the new bound back into &lt;code&gt;pyproject.toml&lt;/code&gt; remains a two-step dance, and running a project's common commands still needs &lt;code&gt;make&lt;/code&gt;, &lt;code&gt;just&lt;/code&gt;, or a pile of shell aliases.&lt;/p&gt;

&lt;p&gt;It is also worth knowing what to stop repeating. The criticism I still see most often, that Dependabot cannot read &lt;code&gt;uv.lock&lt;/code&gt;, is out of date. uv is now a first-class ecosystem in &lt;a href="https://docs.github.com/en/code-security/dependabot/ecosystems-supported-by-dependabot/supported-ecosystems-and-repositories" rel="noopener noreferrer"&gt;GitHub's supported-ecosystems table&lt;/a&gt;, with its own &lt;code&gt;uv&lt;/code&gt; value in the configuration rather than being routed through &lt;code&gt;pip&lt;/code&gt;. It is not flawless, and dependabot-core carries several open issues about how it updates &lt;code&gt;uv.lock&lt;/code&gt;, but "unsupported" is no longer the right word.&lt;br&gt;
The larger open question is governance. On Thursday, March 19, 2026, Astral founder Charlie Marsh announced that the company had &lt;a href="https://astral.sh/blog/openai" rel="noopener noreferrer"&gt;"entered into an agreement to join OpenAI as part of the Codex team"&lt;/a&gt;, writing that "OpenAI will continue supporting our open source tools after the deal closes." I have no inside knowledge and no prediction. At the time of writing uv remains permissively licensed, actively developed, and pre-1.0 with no announced 1.0 date, and that is a fact worth holding alongside the adoption figures, which are substantial: on Thursday, September 17, 2026, &lt;a href="https://pypistats.org/packages/uv" rel="noopener noreferrer"&gt;pypistats&lt;/a&gt; reported 141,324,434 downloads of uv in the preceding 30 days, and the &lt;a href="https://github.com/astral-sh/uv" rel="noopener noreferrer"&gt;project repository&lt;/a&gt; showed 89,920 stars.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheatsheet, grouped by intent
&lt;/h2&gt;

&lt;p&gt;Alphabetical command lists are useless when you cannot remember the command, so this is grouped by what you are trying to do. It also lives in a repository at &lt;a href="https://github.com/zytelabs/uv-cheatsheet" rel="noopener noreferrer"&gt;zytelabs/uv-cheatsheet&lt;/a&gt;, along with a one-page printable version, so you can correct it when it goes stale, which it will.&lt;br&gt;
&lt;strong&gt;Start something.&lt;/strong&gt; &lt;code&gt;uv init name&lt;/code&gt; for a packaged project with a &lt;code&gt;src&lt;/code&gt; layout, &lt;code&gt;uv init name --no-package&lt;/code&gt; for the old flat script layout, and &lt;code&gt;uv init --lib&lt;/code&gt; when you are writing a library.&lt;br&gt;
&lt;strong&gt;Change dependencies inside a project.&lt;/strong&gt; &lt;code&gt;uv add pkg&lt;/code&gt;, &lt;code&gt;uv add --dev pkg&lt;/code&gt; for tooling that never ships, and &lt;code&gt;uv remove pkg&lt;/code&gt;. Never &lt;code&gt;uv pip install&lt;/code&gt; here. Use &lt;code&gt;uv lock --upgrade-package pkg&lt;/code&gt; to bump one thing and &lt;code&gt;uv lock --upgrade&lt;/code&gt; to bump everything.&lt;br&gt;
&lt;strong&gt;Reproduce an environment.&lt;/strong&gt; &lt;code&gt;uv sync&lt;/code&gt; for exact, &lt;code&gt;uv sync --inexact&lt;/code&gt; to leave extras alone, &lt;code&gt;uv sync --locked&lt;/code&gt; in continuous integration so a stale lockfile fails the build, and &lt;code&gt;uv sync --frozen&lt;/code&gt; only when you deliberately want the old lock.&lt;br&gt;
&lt;strong&gt;Run things.&lt;/strong&gt; &lt;code&gt;uv run cmd&lt;/code&gt; inside a project, &lt;code&gt;uv run script.py&lt;/code&gt; for a single file with a PEP 723 header, and &lt;code&gt;uvx tool&lt;/code&gt; for a tool you have not installed. Never &lt;code&gt;source .venv/bin/activate&lt;/code&gt;, because &lt;code&gt;uv run&lt;/code&gt; syncs first and wins over an activated environment anyway, warning that a mismatched &lt;code&gt;VIRTUAL_ENV&lt;/code&gt; "will be ignored" unless you pass &lt;code&gt;--active&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;Manage interpreters.&lt;/strong&gt; &lt;code&gt;uv python list&lt;/code&gt;, &lt;code&gt;uv python install 3.13&lt;/code&gt;, and &lt;code&gt;uv python pin 3.12&lt;/code&gt; followed by &lt;code&gt;uv sync&lt;/code&gt; to make it real. Set &lt;code&gt;UV_PYTHON_PREFERENCE=only-system&lt;/code&gt; in continuous integration, or &lt;code&gt;only-managed&lt;/code&gt; when you want uv's own builds.&lt;br&gt;
&lt;strong&gt;Escape hatches.&lt;/strong&gt; &lt;code&gt;uv venv --seed&lt;/code&gt; when something needs pip in the environment, &lt;code&gt;uv tree --invert --package pkg&lt;/code&gt; when you need to know who dragged a dependency in, and &lt;code&gt;uv export&lt;/code&gt; when a downstream tool insists on a &lt;code&gt;requirements.txt&lt;/code&gt;.&lt;br&gt;
&lt;strong&gt;Containers.&lt;/strong&gt; Pin the uv version in the image, use &lt;code&gt;UV_PROJECT_ENVIRONMENT&lt;/code&gt; to control where the environment lands, and install dependencies before copying your source so the dependency layer caches. For scraping work, where images get rebuilt constantly as selectors change, that ordering is the difference between rebuilding a spider and rebuilding Twisted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves you
&lt;/h2&gt;

&lt;p&gt;uv is infrastructure now. It sits under a very large number of builds, including the continuous integration of the framework this company maintains, and it is still pre-1.0 and moving fast enough that a six-month-old reference misleads rather than merely lags.&lt;br&gt;
So treat your uv knowledge like a dependency. Give it a version, check it occasionally, and be willing to find that something you were confident about moved underneath you. I found two in my own notes, and I was not looking hard.&lt;br&gt;
Pin the version, put &lt;code&gt;--locked&lt;/code&gt; in continuous integration, and see what your build has been getting away with. The cheatsheet above is in &lt;a href="https://github.com/zytelabs/uv-cheatsheet" rel="noopener noreferrer"&gt;zytelabs/uv-cheatsheet&lt;/a&gt; if you would rather print it than scroll it, and pull requests are the fastest way to make this article wrong.&lt;br&gt;
&lt;em&gt;Originally published on &lt;a href="https://www.zyte.com/blog/uv-python-cheatsheet-what-changed-in-0-12-and-what-still-trips-you-up/" rel="noopener noreferrer"&gt;Zyte&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>uv</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Give an AI Agent a Browser: From Instructions to Actions</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Fri, 11 Sep 2026 14:10:00 +0000</pubDate>
      <link>https://dev.to/extractdata/give-an-ai-agent-a-browser-from-instructions-to-actions-1n7a</link>
      <guid>https://dev.to/extractdata/give-an-ai-agent-a-browser-from-instructions-to-actions-1n7a</guid>
      <description>&lt;p&gt;Most browser automation examples start with selectors. Find the button, identify the input, write the script, and hope the page does not change before the next run.&lt;/p&gt;

&lt;p&gt;That approach works, but it becomes awkward when a site requires real browser interaction. Some information only appears after a click. Some pages run browser checks. And if the agent is running on a remote server, even launching and maintaining a local browser can become a project of its own.&lt;/p&gt;

&lt;p&gt;In this walkthrough, I connected an agent to a Zyte CDP browser through Playwright CLI and gave it instructions in plain language. The interesting part was not simply connecting to a remote browser. It was seeing what the agent could do once it had access to a real, interactive browser session.&lt;/p&gt;

&lt;p&gt;Video timestamps are included throughout this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://youtu.be/c6vyfQrlNaM" rel="noopener noreferrer"&gt;follow along&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: a short instruction file and Playwright CLI [00:18]
&lt;/h2&gt;

&lt;p&gt;I started with a small Markdown file that explains how the agent should use the Zyte CDP browser with Playwright CLI. It tells the agent to make sure Playwright CLI is installed and meets the required version, then gives it the basic connection instructions.&lt;/p&gt;

&lt;p&gt;This creates a simple bridge between the agent and the browser. Once the browser is connected, the agent can use the operations available through Playwright CLI: opening pages, finding fields, clicking buttons, entering text, and reading the resulting page.&lt;/p&gt;

&lt;p&gt;The difference is that I do not have to write a separate Playwright script for every interaction. I can describe the task and let the agent work out how to perform it on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deliberately interactive demo site [00:39]
&lt;/h2&gt;

&lt;p&gt;The demo website is intentionally simple. It contains categories, a search box, product controls, and several buttons. It also includes a browser check, which appears briefly when the page loads.&lt;/p&gt;

&lt;p&gt;The site has a useful interaction flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search for products.&lt;/li&gt;
&lt;li&gt;Select products.&lt;/li&gt;
&lt;li&gt;Click a compare button.&lt;/li&gt;
&lt;li&gt;Read the comparison table that appears.&lt;/li&gt;
&lt;li&gt;Open an individual product page.&lt;/li&gt;
&lt;li&gt;Click a button to check availability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some of this information might be available through Ajax requests or an underlying API. In a real project, it can be worth investigating those options. But there are also cases where the most reliable path is to use the website as a user would and click through the interface.&lt;/p&gt;

&lt;p&gt;That is where a browser-connected agent becomes useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replacing selector work with instructions [01:13]
&lt;/h2&gt;

&lt;p&gt;The traditional Playwright workflow requires you to inspect the page and choose selectors. You need to work out which element is the search field, which button triggers comparison, and which part of the page contains the result.&lt;/p&gt;

&lt;p&gt;With the agent, the instruction can be much closer to the actual task:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Go to the website, search for "brake", and return the products you find.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent can inspect the page, locate the relevant field, enter the search term, and read the results. It is still using selectors under the hood, but you do not have to discover and maintain those selectors yourself for each request.&lt;/p&gt;

&lt;p&gt;This is especially useful when the task changes from run to run. The instruction can describe the outcome rather than encoding every click in a fixed script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting the agent securely [01:28]
&lt;/h2&gt;

&lt;p&gt;For the demonstration, I used OpenCode with DeepSeek Flash through OpenRouter. This task does not require a large amount of reasoning. The agent mainly needs to follow the browser instructions and carry out a sequence of actions, so a relatively inexpensive model is enough.&lt;/p&gt;

&lt;p&gt;The connection also needs authentication. The API key is read from an environment variable and used to create a short-lived configuration file in a temporary location. That file is passed to Playwright CLI when it connects to the browser.&lt;/p&gt;

&lt;p&gt;The important practice here is to keep the key out of the instruction file and out of the agent's visible working material. The connection configuration should be created securely and cleaned up according to the needs of the environment.&lt;/p&gt;

&lt;p&gt;After that, the agent can use the browser through Playwright CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Searching without writing selectors [03:02]
&lt;/h2&gt;

&lt;p&gt;The first task was simple: open the site and search for "brake".&lt;/p&gt;

&lt;p&gt;The agent found the search bar, entered the term, and returned the matching products. During the process, Playwright CLI reported the elements it found and the actions it took. The model was verbose, but the important result was that no custom selector script was needed.&lt;/p&gt;

&lt;p&gt;The same approach works when the page is more complicated than the demo. You can describe the field or action in terms a user would understand, and the agent can inspect the current page before deciding what to interact with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using the site's compare feature [04:07]
&lt;/h2&gt;

&lt;p&gt;Next, I asked the agent to take the top three products, use the website's compare feature, and return the comparison table.&lt;/p&gt;

&lt;p&gt;This matters because the table does not appear until the products have been selected and the compare action has been triggered. It is not simply a block of data sitting in the initial HTML.&lt;/p&gt;

&lt;p&gt;The agent had to find the product controls, select the relevant items, click the compare button, and read the table that appeared below the results. Again, the instruction described the goal rather than the selectors:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For the top three products, use the website's compare feature and return the table it shows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The example is deliberately basic, and the same information might have been available elsewhere on this particular site. The point is to demonstrate the interaction pattern. Once the agent has a browser, it can perform actions that depend on the page's state and then collect the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking availability on each product page [05:13]
&lt;/h2&gt;

&lt;p&gt;I then asked the agent to visit each product page and retrieve the availability information.&lt;/p&gt;

&lt;p&gt;The product pages include a "check availability" button. The agent can follow the product information it already collected, open each page, find that button, click it, and return the availability result.&lt;/p&gt;

&lt;p&gt;This is the kind of task that can become a collection of small scripts when written manually. The agent can handle the sequence from a single higher-level instruction, provided the task is clear and the browser session remains available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why use a remote browser? [05:48]
&lt;/h2&gt;

&lt;p&gt;If Playwright can launch a browser locally, why connect to a remote one?&lt;/p&gt;

&lt;p&gt;The two main reasons are access and maintenance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access to sites that expect a real browser
&lt;/h3&gt;

&lt;p&gt;Some sites do not respond well to a basic automation setup. They may perform browser checks or apply other forms of traffic protection. In the demo, the site was configured so that a standard Playwright launch would not get access, while the remote browser could connect successfully.&lt;/p&gt;

&lt;p&gt;There are open-source stealth browser options, and some of them work well. But they need to be maintained. Browser behavior changes, sites change their checks, and the tools need to keep up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Less local infrastructure to maintain
&lt;/h3&gt;

&lt;p&gt;Running a browser locally can also create deployment work. An agent may be running on a server without a display, which means you may need a virtual display such as Xvfb. Then there is the browser installation, version management, operating system configuration, and the rest of the environment around it.&lt;/p&gt;

&lt;p&gt;None of these problems is impossible to solve. They are just extra moving parts, and every extra moving part is another thing that can fail in an agentic workflow.&lt;/p&gt;

&lt;p&gt;A remote browser moves much of that browser infrastructure outside the agent's environment. The agent only needs to connect, perform the work, collect the result, and disconnect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session limits and closing the connection [02:28, 07:34]
&lt;/h2&gt;

&lt;p&gt;The browser session in the demonstration lasted five minutes. That limit is important for two reasons: the agent needs enough time to finish its work, and the connection should be closed as soon as the work is complete.&lt;/p&gt;

&lt;p&gt;When I spent too long talking through the demonstration, the session expired. The agent reconnected and continued with the task, but this is a useful reminder to design the workflow around short, purposeful sessions.&lt;/p&gt;

&lt;p&gt;In a production-style flow, the agent might receive one instruction, connect to the Zyte CDP browser, complete the interaction, return the data, and close the browser. Closing the connection ends the session and avoids paying for unnecessary additional browser time.&lt;/p&gt;

&lt;p&gt;This cleanup step should be part of the instructions or application logic, not something left to chance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The larger idea
&lt;/h2&gt;

&lt;p&gt;The real benefit here is not that an agent can click a few buttons on a demo site. It is that browser automation can be expressed in terms of the work you want done:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search for a product.&lt;/li&gt;
&lt;li&gt;Compare the first three results.&lt;/li&gt;
&lt;li&gt;Visit each product page.&lt;/li&gt;
&lt;li&gt;Check availability.&lt;/li&gt;
&lt;li&gt;Return the results.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent still needs a browser automation tool, authentication, sensible session limits, and clear instructions. But you do not have to begin by turning every task into a collection of selectors and hard-coded steps.&lt;/p&gt;

&lt;p&gt;That makes a remote CDP browser a useful option for agentic web interaction, especially when a site requires clicks, state changes, browser checks, or other behavior that a simple scraping request cannot reproduce.&lt;/p&gt;

&lt;p&gt;The instructional Markdown file and the browser CDP resources used in the demonstration are linked below the video. If you are experimenting with browser-connected agents, this is a straightforward pattern to try: give the agent a small set of browser instructions, let it act on the page, and make sure it closes the connection when the job is done.&lt;/p&gt;

&lt;p&gt;Zyte CDP browser documentation:&lt;br&gt;
&lt;a href="https://docs.zyte.com/zyte-api/usage/cdp.html" rel="noopener noreferrer"&gt;https://docs.zyte.com/zyte-api/usage/cdp.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Zyte headless browser:&lt;br&gt;
&lt;a href="https://www.zyte.com/zyte-api/headless-browser/" rel="noopener noreferrer"&gt;https://www.zyte.com/zyte-api/headless-browser/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instruction Markdown file:&lt;br&gt;
&lt;a href="https://raw.githubusercontent.com/zytelabs/zyte-cdp-examples/refs/heads/main/agent-onboard.md" rel="noopener noreferrer"&gt;https://raw.githubusercontent.com/zytelabs/zyte-cdp-examples/refs/heads/main/agent-onboard.md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webscraping</category>
      <category>playwright</category>
      <category>zyte</category>
    </item>
  </channel>
</rss>
