<?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: John Rooney</title>
    <description>The latest articles on DEV Community by John Rooney (@john_rooney).</description>
    <link>https://dev.to/john_rooney</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%2Fuser%2Fprofile_image%2F3129763%2F0afdc059-5402-40b8-9c98-07d6831b0839.jpg</url>
      <title>DEV Community: John Rooney</title>
      <link>https://dev.to/john_rooney</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/john_rooney"/>
    <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>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>
    <item>
      <title>40% of pages are empty over plain HTTP</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:25:00 +0000</pubDate>
      <link>https://dev.to/extractdata/40-of-pages-are-empty-over-plain-http-lip</link>
      <guid>https://dev.to/extractdata/40-of-pages-are-empty-over-plain-http-lip</guid>
      <description>&lt;p&gt;40.6% of popular landing pages need JavaScript to show you anything useful. That is from &lt;a href="https://www.zyte.com/sowa/2026/barriers/javascript/" rel="noopener noreferrer"&gt;State of Web Access&lt;/a&gt;, an audit Zyte (where I work) ran on 11,100 landing pages. The test was blunt. Fetch the page over plain HTTP, fetch it again in a headless browser, and if rendering grew the meaningful HTML by more than half, count it as JavaScript-dependent.&lt;/p&gt;

&lt;p&gt;On the pages that failed the test, rendering added 140,923 bytes on average. Three quarters of the final HTML did not exist until a browser ran the scripts. Restaurants and airlines top the list at 66%, travel at 65%.&lt;/p&gt;

&lt;p&gt;The report calls this architecture rather than defence, and I agree. Single page apps won. The HTML that comes over the wire is a shell and the content arrives in a second round trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  What rendering costs you
&lt;/h2&gt;

&lt;p&gt;The instinct on seeing an empty response is to reach for Playwright. Here is what that does to the bill, using the audit's own tier data. Zyte's tiers map to the infrastructure needed to fetch a page reliably, and the report scored every site both ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Access method&lt;/th&gt;
&lt;th&gt;Simple&lt;/th&gt;
&lt;th&gt;Easy&lt;/th&gt;
&lt;th&gt;Moderate&lt;/th&gt;
&lt;th&gt;Complex&lt;/th&gt;
&lt;th&gt;Advanced&lt;/th&gt;
&lt;th&gt;Mean tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain HTTP&lt;/td&gt;
&lt;td&gt;85.2%&lt;/td&gt;
&lt;td&gt;9.4%&lt;/td&gt;
&lt;td&gt;2.5%&lt;/td&gt;
&lt;td&gt;2.0%&lt;/td&gt;
&lt;td&gt;0.9%&lt;/td&gt;
&lt;td&gt;1.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headless browser&lt;/td&gt;
&lt;td&gt;58.4%&lt;/td&gt;
&lt;td&gt;31.3%&lt;/td&gt;
&lt;td&gt;8.0%&lt;/td&gt;
&lt;td&gt;1.8%&lt;/td&gt;
&lt;td&gt;0.5%&lt;/td&gt;
&lt;td&gt;1.55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap is not sites defending themselves. A browser carries a heavier fingerprint and a heavier compute cost even on a site with no barriers at all. Switching to rendering moves a quarter of all sites up a tier for no reason other than the method.&lt;/p&gt;

&lt;p&gt;So before rendering, I look in three places. The data is very often already in the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, measure
&lt;/h2&gt;

&lt;p&gt;This is the audit's test, rewritten to compare visible text instead of HTML bytes, which is stricter. Run it once per website before you decide anything.&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;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;selectolax.parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTMLParser&lt;/span&gt;

&lt;span class="n"&gt;UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36&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;visible_text_len&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="nb"&gt;str&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;HTMLParser&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tree&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;script, style, noscript, template&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&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;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;separator&lt;/span&gt;&lt;span class="o"&gt;=&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="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;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&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;js_dependence&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="nb"&gt;str&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&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="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="n"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;follow_redirects&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;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;text&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;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="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="n"&gt;user_agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UA&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;networkidle&lt;/span&gt;&lt;span class="sh"&gt;"&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="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="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;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;visible_text_len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;visible_text_len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rendered&lt;/span&gt;&lt;span class="p"&gt;)&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;plain_chars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rendered_chars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;js_dependent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&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;__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;__main__&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="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;js_dependence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;js_dependent&lt;/code&gt; is false, stop here. You have a plain HTTP site and a browser will only make it slower and more expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second, harvest the state the framework left behind
&lt;/h2&gt;

&lt;p&gt;SPAs hydrate from data the server already embedded. Next.js puts it in a script tag with the id &lt;code&gt;__NEXT_DATA__&lt;/code&gt;. Nuxt uses a global. Apollo, Redux and a dozen others assign to &lt;code&gt;window.__SOMETHING__&lt;/code&gt;. E-commerce and news sites also carry JSON-LD for search engines, which is often cleaner than the visible page.&lt;/p&gt;

&lt;p&gt;A Scrapy spider that pulls all of it from the plain response, with no browser involved:&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;json&lt;/span&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;scrapy&lt;/span&gt;

&lt;span class="n"&gt;STATE_SELECTORS&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;next&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;script#__NEXT_DATA__::text&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;jsonld&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;script[type=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/ld+json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]::text&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;STATE_GLOBALS&lt;/span&gt; &lt;span class="o"&gt;=&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;window\.__INITIAL_STATE__\s*=\s*&lt;/span&gt;&lt;span class="sh"&gt;"&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;window\.__PRELOADED_STATE__\s*=\s*&lt;/span&gt;&lt;span class="sh"&gt;"&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;window\.__APOLLO_STATE__\s*=\s*&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmbeddedStateSpider&lt;/span&gt;&lt;span class="p"&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;Spider&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;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;embedded_state&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;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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sel&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;STATE_SELECTORS&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;in&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;css&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;getall&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;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;source&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="n"&gt;response&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="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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&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;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unparseable %s block on %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source&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;url&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;script&lt;/span&gt; &lt;span class="ow"&gt;in&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;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;script::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;getall&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;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;STATE_GLOBALS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&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;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;script&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;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;obj&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="nf"&gt;_leading_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&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="nf"&gt;end&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;obj&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&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;global&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;url&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="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="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="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@staticmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_leading_json&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# Parses the JSON object at the start of s and ignores whatever follows
&lt;/span&gt;        &lt;span class="c1"&gt;# (a semicolon, more script). Avoids trying to find the closing brace by hand.
&lt;/span&gt;        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;raw_decode&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;raw_decode&lt;/code&gt; trick is the useful part. You never have to find the end of the object yourself. Nuxt is the awkward one, since its global is usually a function call rather than a literal, and you either evaluate it or fall through to the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third, replay the request the page makes
&lt;/h2&gt;

&lt;p&gt;Open DevTools, Network tab, filter to Fetch/XHR, reload. The JSON endpoint the page calls is right there, and it usually returns cleaner data than the HTML ever will. Right click, copy as cURL, and check what it needs. If it works with a static header set, you have a plain HTTP scraper for a JavaScript site. If it needs a token that a script computes on the client, that is your signal to render, and now you know why.&lt;/p&gt;

&lt;h2&gt;
  
  
  When rendering is the right call
&lt;/h2&gt;

&lt;p&gt;Three cases. The API is signed or needs a challenge token from a script. The content genuinely does not exist until a user interacts. Or the TLS probe and the 403 classifier from the earlier posts in this series put the site at Moderate or above, at which point you need a browser-shaped session anyway and rendering comes with it.&lt;/p&gt;

&lt;p&gt;Everything else is a plain HTTP job wearing a React costume.&lt;/p&gt;

&lt;p&gt;The full JavaScript breakdown by industry and site size is on the &lt;a href="https://www.zyte.com/sowa/2026/barriers/javascript/" rel="noopener noreferrer"&gt;report page&lt;/a&gt;, and the tier tables are under &lt;a href="https://www.zyte.com/sowa/2026/access/scraping-cost/" rel="noopener noreferrer"&gt;scraping cost&lt;/a&gt;. The tier model is Zyte's own pricing model applied to Zyte's own data, so trust the ordering between industries more than the absolute percentages.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I work at Zyte. The audit is ours. The code and the opinions are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>python</category>
      <category>scrapy</category>
    </item>
    <item>
      <title>One TLS handshake predicts the whole anti-bot stack</title>
      <dc:creator>John Rooney</dc:creator>
      <pubDate>Mon, 07 Sep 2026 15:19:39 +0000</pubDate>
      <link>https://dev.to/extractdata/one-tls-handshake-predicts-the-whole-anti-bot-stack-1jh</link>
      <guid>https://dev.to/extractdata/one-tls-handshake-predicts-the-whole-anti-bot-stack-1jh</guid>
      <description>&lt;p&gt;When I size up a new website I used to start with headers. Copy them out of DevTools, match the order, add the cookies, see what happens. Now I start with one TLS handshake, because it tells me more about the site than an afternoon of header work.&lt;/p&gt;

&lt;p&gt;The reason is a set of numbers from &lt;a href="https://www.zyte.com/sowa/2026/barriers/tls/" rel="noopener noreferrer"&gt;State of Web Access&lt;/a&gt;, the audit Zyte (my employer) ran on 11,100 popular landing pages. 13.8% of them screen the TLS handshake. Look at what else those sites run compared to everyone else:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Barrier&lt;/th&gt;
&lt;th&gt;TLS-fingerprinting sites&lt;/th&gt;
&lt;th&gt;All other sites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rate limiting&lt;/td&gt;
&lt;td&gt;78.1%&lt;/td&gt;
&lt;td&gt;13.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Antibot&lt;/td&gt;
&lt;td&gt;45.0%&lt;/td&gt;
&lt;td&gt;14.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CAPTCHA&lt;/td&gt;
&lt;td&gt;37.3%&lt;/td&gt;
&lt;td&gt;20.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JavaScript required&lt;/td&gt;
&lt;td&gt;27.0%&lt;/td&gt;
&lt;td&gt;42.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A TLS check is the strongest single predictor in the whole dataset. If the handshake is being inspected, you are almost certainly going to hit rate limiting too, and you have a coin-flip chance of a dedicated bot manager on top. If it is not being inspected, the site probably stops at a CDN firewall. One request and you know which world you are in.&lt;/p&gt;

&lt;p&gt;The JavaScript row runs the other way, and I find it the most interesting. TLS-checking sites need a browser less often. They are server-rendered, performance-minded operations that enforce access at the connection, not in the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody notices this layer
&lt;/h2&gt;

&lt;p&gt;TLS fingerprinting fails before HTTP starts. Your client sends a ClientHello announcing which TLS versions, cipher suites and extensions it supports, in a particular order. Python on OpenSSL, Go's crypto/tls and Chrome's BoringSSL all produce different ones. A site can see "Chrome user agent, OpenSSL handshake" and know the client is lying before it reads a single header.&lt;/p&gt;

&lt;p&gt;JA3 hashed those fields into a fingerprint in 2017. Chrome started shuffling its extension order in 2023, which broke JA3, and JA4 replaced it by sorting the fields first. The detail does not matter much for scraping. What matters is that the check is silent. Some sites return a bare 403 with an empty body. Some just close the connection, and your logs say "connection reset by peer", which looks like a network blip. Nothing in the failure says TLS.&lt;/p&gt;

&lt;p&gt;Vendor attribution was only possible for 45% of the TLS-checking sites in the audit. The other 55% show the behaviour with no named signature. Where it could be attributed, a single bot management vendor was 88.8% of it. So this is mostly one vendor's check, bundled into a product a lot of sites bought for other reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe
&lt;/h2&gt;

&lt;p&gt;This is the method the report used, cut down. Two requests, identical headers, from the same IP. The only thing that differs is the handshake. aiohttp presents a standard Python TLS signature. curl_cffi presents Chrome's. If the first fails and the second passes, the site is looking at TLS.&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;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;curl_cffi.requests&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&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="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36&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;Accept&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;text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8&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;Accept-Language&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;en-US,en;q=0.9&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;python_tls&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="nb"&gt;str&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ClientSession&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;s&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="n"&gt;s&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="n"&gt;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ClientTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&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;aiohttp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;          &lt;span class="c1"&gt;# reset or refused: count it as a block
&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;chrome_tls&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="nb"&gt;str&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;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&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="nc"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;impersonate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chrome&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="n"&gt;HEADERS&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;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&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;s&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;20&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&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;r&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;probe&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="nb"&gt;str&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&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;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;python_tls&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="nf"&gt;chrome_tls&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;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;py&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="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;ch&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;ch&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&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;python_tls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chrome_tls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tls_filtered&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;filtered&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;__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;__main__&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="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;probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install with &lt;code&gt;pip install aiohttp curl_cffi&lt;/code&gt;. The impersonate part follows whatever Chrome version curl_cffi ships, so it drifts less than a hand-maintained profile would.&lt;/p&gt;

&lt;p&gt;Because both requests leave the same machine, IP reputation is held constant. This is a controlled experiment with one variable, which is more than most scraping diagnostics manage.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I read the result
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;tls_filtered&lt;/code&gt; is true, I plan for the stack the table above predicts. Browser-shaped TLS from request one, session handling, per-domain concurrency of one or two, content validation on every response. I do not spend a day on header tweaks because headers were never the problem.&lt;/p&gt;

&lt;p&gt;If it is false and I am still blocked, I have ruled out one layer for the cost of one request. The block is IP class, headers, cookies or behaviour, and I look there instead.&lt;/p&gt;

&lt;p&gt;If it is false and both requests pass, I have a Simple-tier site and I should stop reaching for Playwright. The audit's tier data puts 85% of landing pages at plain HTTP. Rendering when you do not need to is the most common way I see scraping budgets disappear.&lt;/p&gt;

&lt;p&gt;One caveat the report makes and I will repeat. Everything here is landing pages, scanned once, from datacentre IPs. Search and product pages are usually defended harder than the front door. Treat the percentages as a floor.&lt;/p&gt;

&lt;p&gt;The industry split is on the &lt;a href="https://www.zyte.com/sowa/2026/barriers/tls/" rel="noopener noreferrer"&gt;TLS page&lt;/a&gt;. Jewellery and luxury leads at 34%. Reference sites sit at 3%, because blocking crawlers would kill the thing they exist for.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I work at Zyte. The dataset is ours. The probe is a cut-down version of what the audit ran, and the opinions are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>zyte</category>
      <category>python</category>
      <category>scrapy</category>
    </item>
  </channel>
</rss>
