<?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: Andrii Votiakov</title>
    <description>The latest articles on DEV Community by Andrii Votiakov (@votiakov).</description>
    <link>https://dev.to/votiakov</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%2F4036616%2F84057901-9646-4353-ae20-026381e5bc2e.png</url>
      <title>DEV Community: Andrii Votiakov</title>
      <link>https://dev.to/votiakov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/votiakov"/>
    <language>en</language>
    <item>
      <title>When you actually need a headless browser (and when you're just wasting RAM)</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Fri, 31 Jul 2026 09:05:56 +0000</pubDate>
      <link>https://dev.to/votiakov/when-you-actually-need-a-headless-browser-and-when-youre-just-wasting-ram-209e</link>
      <guid>https://dev.to/votiakov/when-you-actually-need-a-headless-browser-and-when-youre-just-wasting-ram-209e</guid>
      <description>&lt;p&gt;Most of the store-locator scrapers I build never touch a browser. I open the Network tab, find the JSON endpoint the map calls, hit it with &lt;code&gt;fetch&lt;/code&gt;, done. A browser is slow, hungry, and flaky, so I treat it as the last resort.&lt;/p&gt;

&lt;p&gt;National Book Tokens is where the last resort actually kicked in. It's a UK book gift-card brand, and their "find a bookshop" page shows a Google-Maps-style board of pins for every shop that takes the card. I wanted the full geocoded list. So I did the usual thing: opened the page, opened DevTools, watched the Network tab reload.&lt;/p&gt;

&lt;p&gt;Nothing. No &lt;code&gt;/api/shops&lt;/code&gt;, no &lt;code&gt;locations.json&lt;/code&gt;, no XHR carrying an array of bookshops. The map just... had pins. That absence is the whole story, and it's the thing worth learning, because it's the signal that tells you a browser is unavoidable instead of just habit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell a browser is actually required
&lt;/h2&gt;

&lt;p&gt;Here's the test I run before I let myself spin up Chrome. It takes about thirty seconds.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the page normally in the browser. See the data on screen? Good.&lt;/li&gt;
&lt;li&gt;View the &lt;em&gt;raw&lt;/em&gt; HTML. Not the Elements panel, the raw document. &lt;code&gt;Ctrl+U&lt;/code&gt; (view-source), or in DevTools open the Network tab, click the document request, look at the Response sub-tab. This is the exact bytes the server sent, before any JavaScript ran.&lt;/li&gt;
&lt;li&gt;Ctrl+F in that raw response for something you can see on screen. A shop name, a postcode, a latitude.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the data is in the raw HTML, you (almost) never need a browser. Server-rendered pages put everything in that first response, and a plain &lt;code&gt;fetch&lt;/code&gt; plus an HTML parser gets you the lot. If the data is &lt;em&gt;not&lt;/em&gt; in the raw response but &lt;em&gt;is&lt;/em&gt; in the live Elements panel, that gap is your answer: the page arrived nearly empty and JavaScript filled it in after the fact. You cannot &lt;code&gt;fetch&lt;/code&gt; your way to something that only exists after JS runs.&lt;/p&gt;

&lt;p&gt;On the National Book Tokens page I searched the Elements panel for &lt;code&gt;markers&lt;/code&gt; and found this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"markers"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:none"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  [[&lt;span class="ni"&gt;&amp;amp;quot;&lt;/span&gt;Foyles&lt;span class="ni"&gt;&amp;amp;quot;&lt;/span&gt;,&lt;span class="ni"&gt;&amp;amp;quot;&lt;/span&gt;51.5155&lt;span class="ni"&gt;&amp;amp;quot;&lt;/span&gt;,&lt;span class="ni"&gt;&amp;amp;quot;&lt;/span&gt;-0.1300&lt;span class="ni"&gt;&amp;amp;quot;&lt;/span&gt;,100,&lt;span class="ni"&gt;&amp;amp;quot;&amp;amp;lt;&lt;/span&gt;div&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;...&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;/div&lt;span class="ni"&gt;&amp;amp;gt;&amp;amp;quot;&lt;/span&gt;,...
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hidden div stuffed with the entire dataset, as an HTML-entity-escaped JSON array. The site's own map code reads this div back, unescapes it, and drops the pins. Then I looked at that same &lt;code&gt;#markers&lt;/code&gt; div in view-source, and it was &lt;strong&gt;empty&lt;/strong&gt;. Just &lt;code&gt;&amp;lt;div id="markers"&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;. Client-side JS builds the contents on load.&lt;/p&gt;

&lt;p&gt;That's the convict. Data present in the DOM, absent from the raw HTML, sitting in a div that some script populates. No JSON endpoint to call directly. A browser it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser has exactly one job
&lt;/h2&gt;

&lt;p&gt;This is the part people get wrong. They reach for Puppeteer and then start driving it like a robot user, clicking around, scrolling, taking screenshots, scraping rendered text off the page. That's slow and brittle.&lt;/p&gt;

&lt;p&gt;The browser's only real job here is to run the page's JavaScript and let the DOM settle. That's it. The moment &lt;code&gt;#markers&lt;/code&gt; is populated, everything after that is plain string work: read the innerHTML, unescape it, &lt;code&gt;JSON.parse&lt;/code&gt;, map the array. String work runs fine anywhere and it's fast. So I keep the browser part as small as I possibly can and get out.&lt;/p&gt;

&lt;p&gt;I also don't &lt;em&gt;launch&lt;/em&gt; Chrome, I &lt;em&gt;connect&lt;/em&gt; to one. Running the actual scraper inside a Firebase Function, launching full Chromium is a pain (cold starts, binary size, memory). So I run a headless Chrome somewhere else, a browserless-style service, and attach over a WebSocket with &lt;code&gt;puppeteer-core&lt;/code&gt;. &lt;code&gt;puppeteer-core&lt;/code&gt; is the same library without the bundled Chromium download, which is exactly what you want when the browser lives elsewhere.&lt;/p&gt;

&lt;p&gt;Here's the whole thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer-core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PAGE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://www.nationalbooktokens.com/redeem/find-a-bookshop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// A remote headless Chrome (browserless-style). Locally you can just use&lt;/span&gt;
&lt;span class="c1"&gt;// full `puppeteer` and puppeteer.launch({ headless: "new" }) instead.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BROWSER_WS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BROWSER_WS_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ws://your-chrome-host/browser/chrome?token=...&lt;/span&gt;

&lt;span class="c1"&gt;// Undo the HTML-entity escaping the site applied before it stored the JSON.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unEscapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;quot;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;#39;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// do &amp;amp;amp; LAST or you double-decode&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scrapeBookshops&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;browserWSEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BROWSER_WS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ignoreHTTPSErrors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;PAGE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;domcontentloaded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// 1. the div must exist...&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForSelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#markers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. ...AND be substantially filled. An empty #markers appears early,&lt;/span&gt;
    &lt;span class="c1"&gt;//    so waiting on presence alone parses a half-built div.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;markers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Pull the raw escaped blob out of the page. That's all the browser is for.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawInner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;markers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rawInner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// always let go of the remote browser&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;Notice &lt;code&gt;waitForFunction&lt;/code&gt;. &lt;code&gt;waitForSelector("#markers")&lt;/code&gt; on its own is a trap: the empty div often exists in the DOM before the script fills it, so the selector resolves instantly and you scrape nothing. Waiting for &lt;code&gt;innerHTML.length &amp;gt; 100&lt;/code&gt; means "wait until the JSON is actually in there." Pick a threshold above whatever the empty/half state looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the plain string work
&lt;/h2&gt;

&lt;p&gt;Everything past this point runs in Node, no browser needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseMarkers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawInner&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// SSR frameworks sprinkle empty comments into the DOM; they break JSON.parse.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawInner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;!--&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*--&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unEscapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// array of positional arrays&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;listings&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Positional, not keyed: [ name, lat, lng, zIndex, popupHtml, ..., uniqueId ]&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latitude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;longitude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uniqueId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// address lives inside the popup HTML at [4]: first &amp;lt;p&amp;gt;, &amp;lt;br&amp;gt; -&amp;gt; ", "&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;p&amp;gt;/&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;br&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\/?&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/,&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&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="nx"&gt;uniqueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;longitude&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// drop 0/0 junk&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One page load returns the entire dataset. There's no pagination, no radius sweep, no tiling. Every pin is already in that single array, which is the upside of the DOM-blob pattern: once you've paid for the browser, you get everything in one shot.&lt;/p&gt;

&lt;p&gt;A few things that bit me and will bit you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The double-escape.&lt;/strong&gt; &lt;code&gt;&amp;amp;amp;amp;&lt;/code&gt; shows up when the source escaped an already-escaped string. Decode &lt;code&gt;&amp;amp;amp;&lt;/code&gt; last in your unescape chain, or &lt;code&gt;Foyles &amp;amp;amp; Co&lt;/code&gt; turns into a broken parse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;String coordinates.&lt;/strong&gt; Lat and lng come as &lt;code&gt;"51.5155"&lt;/code&gt;, quoted. &lt;code&gt;parseFloat&lt;/code&gt; them or your map math silently produces &lt;code&gt;NaN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Positional arrays.&lt;/strong&gt; There are no keys. &lt;code&gt;row[0]&lt;/code&gt; is the name today because that's the order the site emits. If they reorder, your mapping is wrong and nothing throws. Log a sample row and eyeball it before you trust the indices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half-filled div.&lt;/strong&gt; Covered above, but it's the number-one reason "it worked in DevTools, empty in the script."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wrap the connect-navigate-extract cycle in a small retry loop (three attempts is plenty) and always &lt;code&gt;disconnect&lt;/code&gt; in a &lt;code&gt;finally&lt;/code&gt;. Remote browsers drop connections, and a leaked session is a session you're paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this generalizes
&lt;/h2&gt;

&lt;p&gt;The National Book Tokens pattern (Google Maps reading pin data from a hidden element) is everywhere on old-school store-locator pages. But the real transferable move isn't Puppeteer. It's the raw-HTML-versus-live-DOM diff. That one check tells you which of three worlds you're in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Data in the raw HTML: &lt;code&gt;fetch&lt;/code&gt; plus a parser, no browser.&lt;/li&gt;
&lt;li&gt;Data from a JSON XHR you can see in the Network tab: call that endpoint directly, still no browser.&lt;/li&gt;
&lt;li&gt;Data absent from both, appearing only after JS runs: now, and only now, a headless browser, used purely to run the script and read the settled DOM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for Chrome last, and when you do, make it do the smallest possible thing. It runs the JavaScript, it hands you a string, and then you fire it and go back to code that doesn't eat 400MB of RAM.&lt;/p&gt;

&lt;p&gt;Good-citizen note: these are public store-finder endpoints. Cache the result, don't re-run it every request, and don't hammer someone's map page for data that changes maybe monthly.&lt;/p&gt;

&lt;p&gt;I build scrapers like this for a living. My published actors are at &lt;a href="https://apify.com/native_emblem" rel="noopener noreferrer"&gt;apify.com/native_emblem&lt;/a&gt; if you'd rather rent the output than maintain the Chrome.&lt;/p&gt;

</description>
      <category>scraping</category>
      <category>puppeteer</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your scraper isn't broken. The site changed, and it didn't tell you.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Wed, 29 Jul 2026 08:50:28 +0000</pubDate>
      <link>https://dev.to/votiakov/your-scraper-isnt-broken-the-site-changed-and-it-didnt-tell-you-ba</link>
      <guid>https://dev.to/votiakov/your-scraper-isnt-broken-the-site-changed-and-it-didnt-tell-you-ba</guid>
      <description>&lt;p&gt;The scraper had been green for eleven weeks. Every run exited 0, every run wrote rows to the warehouse, the dashboard showed a healthy line. Then someone in the pricing team asked why 40% of products suddenly had no price. They'd been shipping decisions on that data for most of a week.&lt;/p&gt;

&lt;p&gt;Nothing had failed. That's the whole problem. The site had quietly moved the price into a different element during an A/B test, our selector returned null, and the pipeline did exactly what we told it to: it wrote null and moved on. A scrape can succeed structurally and fail semantically, and almost nobody monitors for the second kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exit 0 is not the same as correct
&lt;/h2&gt;

&lt;p&gt;I've written about scrapers that return zero results and still exit clean. Silent drift is the same disease at a different stage. The request went through. The HTML parsed. The loop ran. Every layer reported success because every layer only knows about its own job, and none of them know what a &lt;em&gt;good record&lt;/em&gt; is supposed to look like.&lt;/p&gt;

&lt;p&gt;Markup drift is the number one way scrapers rot, and it rarely announces itself with a crash. A crash would be a gift. A crash pages someone. What actually happens is subtler: a retailer ships a redesign, or rolls out a variant to 10% of traffic, or renames a CSS class in a build step, and one field goes dark while the other nine keep flowing. Your fill rate drops from 99% to 61% and the only thing that changed color is a number in a spreadsheet three teams away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the output, not the input
&lt;/h2&gt;

&lt;p&gt;The instinct is to harden the parser. More selectors, more try/catch, more defensive HTML handling. That helps, but it's aiming at the wrong target. You cannot assert your way to correctness on input you don't control. What you &lt;em&gt;can&lt;/em&gt; control is a contract on the output: after a run, a record is only allowed to exist if it meets a schema you defined.&lt;/p&gt;

&lt;p&gt;The cheapest version is a canary check on a sample. Pull N records from the run, assert the fields that must never be null actually aren't, and fail loudly if too few pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;assertHealthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minFillRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;price&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fillRate&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;fillRate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filled&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fillRate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;minFillRate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Fill-rate check failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;=&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;fillRate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;return&lt;/span&gt; &lt;span class="nx"&gt;fillRate&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;Now a run that produces 40% null prices doesn't exit 0. It throws, and throwing is a thing your alerting already understands. You've converted a silent semantic failure into a loud structural one, which is the only kind of failure your on-call rotation can actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  A static threshold is a starting point, not the answer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;minFillRate: 0.9&lt;/code&gt; is a guess, and guesses age badly. Some fields are legitimately sparse. Not every product has a discount, not every listing has a review count, and a hard 90% floor on an optional field will page you at 3am for nothing. The failure you actually care about is not "this field is low," it's "this field is lower than it was yesterday."&lt;/p&gt;

&lt;p&gt;So track fill rate per field over time and compare each run to a rolling baseline. The alert fires on the &lt;em&gt;delta&lt;/em&gt;, not the absolute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;driftAlarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;drop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// history: last ~14 runs of fill rate for this field&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; fill-rate &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;% vs baseline &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;
      &lt;span class="nx"&gt;baseline&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt;%, likely markup drift`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;A field that has hovered at 12% for two weeks and is still at 12% is fine. A field that lived at 99% and dropped to 61% overnight is a selector that just died, and you'll know within one run instead of one quarter. Persist these numbers somewhere boring, a small table or even a JSON blob per run, and the baseline builds itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't let one dead selector take the record down
&lt;/h2&gt;

&lt;p&gt;The last piece is structural. If a single field extraction is the only path to that field, then the day it breaks, the field is simply gone. The fix is to run several extraction strategies per field and take the first that produces a valid value. A machine-readable data block on the page, a couple of markup patterns, a fallback derived from a neighbor. When one strategy dies to a redesign, another usually still stands, and the record survives with its price intact.&lt;/p&gt;

&lt;p&gt;This is the shape I lean on in production. Multiple independent ways to reach each field, a schema contract on the way out, and fill-rate tracking so I find out from my own monitoring before a downstream team finds out from a broken report. The strategies do the resilience. The output validation does the alerting. You need both, because resilient extraction that you never measure will still drift eventually, and it won't tell you either.&lt;/p&gt;

&lt;p&gt;The mental model that fixes this: your scraper's job is not to finish. It's to produce records that pass a contract, and anything short of that is a failure even when the process exits 0.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://cartpie.com" rel="noopener noreferrer"&gt;Cartpie&lt;/a&gt;, an e-commerce product-data platform where this multi-strategy extraction and output validation is the whole point, and I publish scrapers on &lt;a href="https://apify.com/native_emblem" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; that are built the same way.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>dataengineering</category>
      <category>monitoring</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your BigQuery bill is a query-shape problem. We cut one 72% in three weeks.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:35:43 +0000</pubDate>
      <link>https://dev.to/votiakov/your-bigquery-bill-is-a-query-shape-problem-we-cut-one-72-in-three-weeks-3mnh</link>
      <guid>https://dev.to/votiakov/your-bigquery-bill-is-a-query-shape-problem-we-cut-one-72-in-three-weeks-3mnh</guid>
      <description>&lt;p&gt;BigQuery doesn't charge you for having data. It charges you for touching it. On-demand pricing bills per byte scanned, which means your bill isn't a data-size problem or a traffic problem. It's a query-shape problem, and query shape is fixable in an afternoon per table.&lt;/p&gt;

&lt;p&gt;A recent client was a textbook case. Dashboards running &lt;code&gt;SELECT *&lt;/code&gt; over an unpartitioned events table, so every refresh scanned the full history back to 2019. Plus a pile of scheduled queries feeding tables nobody read anymore. Three weeks of work cut the bill 72%, about £2K a month, and the queries got faster. That last part surprises people. It shouldn't. Bytes scanned is both the cost metric and the latency driver. Same lever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week 1: find out where the money goes
&lt;/h2&gt;

&lt;p&gt;Don't guess. BigQuery logs every job with its billed bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;user_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_bytes_billed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;POW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tib_billed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&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;runs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_bytes_billed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;POW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tib_total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;`region-eu`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFORMATION_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JOBS&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;creation_time&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMP_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;job_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'QUERY'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;user_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tib_total&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Almost every time, a handful of queries dominate. Here it was five queries producing most of the spend, and four of them were dashboard refreshes running on a schedule. Recurring queries are where the money is, because a bad shape times 48 runs a day compounds while you sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week 2: fix the shape
&lt;/h2&gt;

&lt;p&gt;Partition by the column people actually filter on (usually a date), cluster by the next most common filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events_v2&lt;/span&gt;
&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;CLUSTER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;
&lt;span class="k"&gt;OPTIONS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;require_partition_filter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the DDL:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;require_partition_filter = TRUE&lt;/code&gt; is the line that keeps the fix fixed. Without it, someone writes one exploratory full scan in month two and you're back where you started. With it, BigQuery rejects the query instead of billing you for it. Make waste an error, not a habit.&lt;/p&gt;

&lt;p&gt;Clustering only pays if queries filter or join on the clustered columns. One team's ad-hoc analysis here filtered on a column we hadn't clustered, and their queries saw no improvement at all. Cluster for the workload you measured in week 1, not for the schema you imagine.&lt;/p&gt;

&lt;p&gt;Then rewrite the top offenders. &lt;code&gt;SELECT *&lt;/code&gt; on a columnar store bills you for every column, including the 40 you never render. Listing columns explicitly cut one dashboard query's scan by an order of magnitude before partitioning even entered the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Week 3: kill the zombie jobs
&lt;/h2&gt;

&lt;p&gt;This is the unglamorous part and it was worth a big slice of the 72%. Scheduled queries outlive their consumers. The dashboard gets deprecated, the Slack report gets muted, and the pipeline underneath keeps materializing tables daily, forever, at full scan cost.&lt;/p&gt;

&lt;p&gt;Cross-reference every scheduled query against actual reads of its output table (INFORMATION_SCHEMA again, look for jobs referencing it). No reads in 60 days? Pause it. We paused first rather than deleting, waited two weeks for screams, heard none, deleted. Nobody has asked about any of them since.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to skip
&lt;/h2&gt;

&lt;p&gt;Slot reservations. Flat-rate pricing is a real tool, but it's the tool you reach for after fixing query shape, not instead of it. Buying capacity to run wasteful queries is paying rent on the waste. Fix the shape first, then check whether your steady-state scan volume still justifies reservations. After this cleanup, it didn't come close.&lt;/p&gt;

&lt;p&gt;The whole engagement was three weeks because none of this is hard. It just requires someone to actually look at INFORMATION_SCHEMA instead of the billing page, which tells you what you spent but never why.&lt;/p&gt;

&lt;p&gt;I do this kind of work at &lt;a href="https://reducecost.cloud" rel="noopener noreferrer"&gt;reducecost.cloud&lt;/a&gt; on pay-for-savings terms: nothing upfront, I take a share of the reduction I deliver, and if I save you nothing you pay nothing.&lt;/p&gt;

</description>
      <category>bigquery</category>
      <category>gcp</category>
      <category>dataengineering</category>
      <category>sql</category>
    </item>
    <item>
      <title>Build a SHEIN price tracker in 20 minutes with n8n and an Apify actor</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Fri, 24 Jul 2026 16:56:13 +0000</pubDate>
      <link>https://dev.to/votiakov/build-a-shein-price-tracker-in-20-minutes-with-n8n-and-an-apify-actor-1ejd</link>
      <guid>https://dev.to/votiakov/build-a-shein-price-tracker-in-20-minutes-with-n8n-and-an-apify-actor-1ejd</guid>
      <description>&lt;p&gt;I wanted to know the moment a specific SHEIN dress dropped below a price I'd set, without checking the page myself like it was 2011. The whole thing took twenty minutes to wire up in n8n against one of my Apify actors, it runs on a schedule for pennies, and it pings me only when a price actually falls. No servers, no cron on a box I have to remember exists, no browser automation to babysit. Here's the exact build.&lt;/p&gt;

&lt;p&gt;The shape is five nodes: a schedule trigger fires, an HTTP node runs the SHEIN actor and gets the results back in one call, a code node parses the products, another compares each price against what I stored last run, and a notify node fires only on a drop. Everything talks to the public Apify API, so there's nothing exotic to install.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Schedule trigger
&lt;/h2&gt;

&lt;p&gt;Add a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; node. Once a day is plenty for retail pricing, more often than hourly is just noise and wasted runs. Set it to something like every day at 08:00. That's the whole node. n8n handles the cron for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Run the actor and get results in one shot
&lt;/h2&gt;

&lt;p&gt;Add an &lt;strong&gt;HTTP Request&lt;/strong&gt; node. The trick that keeps this simple is Apify's run-sync-get-dataset-items endpoint: it starts the actor, waits for it to finish, and returns the scraped dataset in the same response. No polling, no run-ID juggling, no second call to fetch results.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Method: &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;https://api.apify.com/v2/acts/native_emblem~shein-product-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body content type: &lt;code&gt;JSON&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Body: the actor's input, the keywords or product URLs you want priced
&lt;/li&gt;
&lt;/ul&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;"keywords"&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="s2"&gt;"summer midi dress"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&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;Put your token in n8n credentials rather than pasting it into the URL in the clear, but the shape is exactly that: one POST, token as a query param, actor input as the JSON body. The response is a plain array of product objects. If you're curious what comes back before wiring the rest, the same call works from your terminal:&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;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/native_emblem~shein-product-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN"&lt;/span&gt; &lt;span class="se"&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;'{"keywords":["summer midi dress"],"maxItems":25}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One warning: run-sync holds the connection open until the actor finishes, so keep &lt;code&gt;maxItems&lt;/code&gt; modest for a tracker. You're pricing a watchlist, not mirroring the catalog. Small inputs come back in a few seconds and never bump the sync timeout.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Parse the products
&lt;/h2&gt;

&lt;p&gt;Add a &lt;strong&gt;Code&lt;/strong&gt; node. The dataset comes back as items, and you just pull the fields you care about into a flat shape the next steps can diff. Product ID as the key, price as the value, name for the notification text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// n8n Code node, runs once over all incoming items&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// current price as a number&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Field names depend on the actor's output schema, so glance at one real item and map accordingly. The point is you leave this node with one clean record per product: id, name, price, url.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Diff against last run
&lt;/h2&gt;

&lt;p&gt;You need somewhere to remember yesterday's price. A Google Sheet is the no-fuss choice and the one I'd start with: one row per product ID, columns for name, last price, and URL. n8n's Google Sheets node reads and writes it directly. A tiny Postgres table or even n8n's own static data works too, but a sheet you can eyeball is the friendliest for a first build.&lt;/p&gt;

&lt;p&gt;Read the sheet, join on product ID, and compare. Add another &lt;strong&gt;Code&lt;/strong&gt; node, or an &lt;strong&gt;IF&lt;/strong&gt; node if you prefer clicking to typing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// keep only products whose price dropped vs the stored value&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastPrice&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastPrice&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;json&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="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;drop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastPrice&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then write the fresh prices back to the sheet so the next run compares against today. That write-back is the step people forget, and without it every run re-alerts on the same old drop forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Notify on a drop only
&lt;/h2&gt;

&lt;p&gt;Whatever survives the filter is a genuine price drop, so hand it to a notification node. Slack, Telegram, Discord, email, n8n has a node for all of them. Message body straight from the fields you carried through:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"summer midi dress" dropped 4.20 to 11.79. {url}&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If nothing dropped, the filter passes zero items, the notify node does nothing, and you hear nothing. Silence is the correct default for a price tracker. You want it to interrupt you exactly when it matters and stay quiet the other 364 mornings.&lt;/p&gt;

&lt;h2&gt;
  
  
  That's the whole thing
&lt;/h2&gt;

&lt;p&gt;Five nodes, one scheduled trigger, one API call that both runs the scraper and returns the data, a sheet for memory, and a filter that only speaks up on a drop. Swap &lt;code&gt;keywords&lt;/code&gt; for a list of exact product URLs and you're watching a specific watchlist instead of a search. Point it at any of the fields the actor returns, stock status, rating, whatever, and the same skeleton becomes a back-in-stock alert or a review watcher. The pattern outlives the example.&lt;/p&gt;

&lt;p&gt;The SHEIN actor I wired here is one I publish on &lt;a href="https://apify.com/native_emblem/shein-product-scraper" rel="noopener noreferrer"&gt;Apify&lt;/a&gt;: proxyless and clean-JSON, so parse steps like the one above stay this short. It takes a keyword and returns matching products, so you don't need product URLs to start, and it's priced per product actually returned ($5 per 1,000 search results). It's the same extraction engine behind &lt;a href="https://cartpie.com" rel="noopener noreferrer"&gt;Cartpie&lt;/a&gt;, the e-commerce product-data platform I'm building. Wire it into n8n once and you'll find a dozen more things worth watching.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>automation</category>
      <category>nocode</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Half the web already hands you clean product data. You just have to ask.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:30:15 +0000</pubDate>
      <link>https://dev.to/votiakov/half-the-web-already-hands-you-clean-product-data-you-just-have-to-ask-alh</link>
      <guid>https://dev.to/votiakov/half-the-web-already-hands-you-clean-product-data-you-just-have-to-ask-alh</guid>
      <description>&lt;p&gt;I once spent an afternoon writing selectors to pull a price off a product page. Wrote the CSS path, handled the currency symbol, dealt with the sale-price-versus-list-price mess, tested it across a dozen listings. Then I opened view-source, scrolled a bit, and found the same price sitting in a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block, typed, labeled, and currency-tagged. The retailer had published it there on purpose. I'd done a morning's work to reconstruct data that was already handed to me clean.&lt;/p&gt;

&lt;p&gt;That block wasn't there for scrapers. It was there for Google. And that's exactly why it's the best starting point you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retailers publish machine-readable product data for search engines
&lt;/h2&gt;

&lt;p&gt;A huge share of e-commerce pages carry a structured description of the product embedded right in the HTML, separate from the visual markup. It exists so that Google, Bing, Pinterest, and every other crawler can show rich results: the price, the star rating, the in-stock badge you see under a search listing. Retailers are strongly incentivized to keep it accurate and current, because getting it wrong means losing the rich result or getting penalized for mismatched data.&lt;/p&gt;

&lt;p&gt;There are three common shapes. JSON-LD is the one you want most, a self-contained JSON object following the schema.org vocabulary. OpenGraph tags are &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; elements in the head, coarser but nearly universal, great for name, image, and sometimes price. Microdata is the old inline style, attributes like &lt;code&gt;itemprop&lt;/code&gt; sprinkled through the visible HTML. All three describe the same underlying thing. JSON-LD just does it in one clean block instead of scattered across the DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a JSON-LD Product block
&lt;/h2&gt;

&lt;p&gt;The pattern is boring in the best way. Find the script tags, parse each as JSON, and look for the object whose &lt;code&gt;@type&lt;/code&gt; is &lt;code&gt;Product&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cheerio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script[type="application/ld+json"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;contents&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="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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;data&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="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// malformed block, skip it&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// JSON-LD is often an array or wrapped in @graph&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@graph&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nodes&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="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[].&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt; &lt;span class="o"&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="o"&gt;||&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;priceCurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. schema.org/InStock&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;No currency-symbol parsing. No sale-versus-list guesswork. The &lt;code&gt;availability&lt;/code&gt; field is a plain URL you can map to a boolean. You get name, sku, image, price, and stock state from one parse, and it survives visual redesigns because the retailer maintains it independently of the layout their designers keep changing.&lt;/p&gt;

&lt;p&gt;Beyond being easier, it's more polite. You're reading a static block that's already in the HTML the retailer chose to serve, not hammering internal endpoints or driving a headless browser through a render loop. Less load on them, less cost for you, more stability for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rank your sources by reliability
&lt;/h2&gt;

&lt;p&gt;Once you internalize that pages carry data at several levels of quality, extraction becomes a preference order rather than a single brittle path. Conceptually, structured data sits above hand-written selectors. A JSON-LD &lt;code&gt;Product&lt;/code&gt; block is typed and maintained for Google; a CSS selector against rendered markup is your guess about someone else's HTML, and it breaks the day their build tool renames a class.&lt;/p&gt;

&lt;p&gt;So the order I reason in: prefer the typed, purpose-published structured block first. Fall back to OpenGraph and microdata for the fields it covers. Reach for hand selectors last, for the fields nothing else exposes. The higher up the stack you can stay, the longer your scraper lives between repairs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: structured data lies sometimes
&lt;/h2&gt;

&lt;p&gt;It's cleaner, not infallible. Structured blocks can be stale, cached from a build that ran before the last price change. They can be partial, a &lt;code&gt;Product&lt;/code&gt; with a name and image but no &lt;code&gt;offers&lt;/code&gt;. They can be templated wrong, every variant reporting the parent's price. Some sites inject the JSON-LD client-side, so it isn't in the initial HTML at all. Trust it as the best signal, not the only one.&lt;/p&gt;

&lt;p&gt;Which means you validate what you pull and fall back when it fails the check. If the JSON-LD has no price, drop to OpenGraph. If that's empty too, try the selector. Run the fill-rate and required-field checks over the final merged record so a silently-empty &lt;code&gt;offers&lt;/code&gt; array gets caught before it ships. The structured block gives you a fast, stable default; the fallbacks and the validation are what make it trustworthy.&lt;/p&gt;

&lt;p&gt;That layered approach, several independent ways to reach each field with a clear preference order and validation across them, is the core of how I build extraction in production. I won't get into how the strategies are weighted or combined against any specific site, because that tuning is the actual work and it shifts constantly. But the principle is public and it's the right instinct: before you write a single selector, view-source and check whether the retailer already handed you the answer in a block they maintain for Google. Half the time, they did.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://cartpie.com" rel="noopener noreferrer"&gt;Cartpie&lt;/a&gt;, an e-commerce product-data platform built on exactly this multi-source approach, and I publish proxyless scrapers on &lt;a href="https://apify.com/native_emblem" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; that start from structured data first.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>seo</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My pipeline parsed every page twice, and I blamed the parser</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Thu, 23 Jul 2026 13:20:23 +0000</pubDate>
      <link>https://dev.to/votiakov/my-pipeline-parsed-every-page-twice-and-i-blamed-the-parser-12fb</link>
      <guid>https://dev.to/votiakov/my-pipeline-parsed-every-page-twice-and-i-blamed-the-parser-12fb</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This story fits in one diff: &lt;code&gt;memorySize: 2048&lt;/code&gt; became &lt;code&gt;memorySize: 512&lt;/code&gt;. Getting there took a week of optimizing the wrong thing, 404 lines of it, and one evening of actually reading the code between the functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system
&lt;/h2&gt;

&lt;p&gt;A product-data pipeline I ran for a client. In: a URL from any of tens of thousands of retailer domains. Out: a clean record with title, price, currency, images, availability. Eight figures of product pages refreshed every month, all of it on AWS Lambda and SQS.&lt;/p&gt;

&lt;p&gt;Downloads went through a proxy waterfall. Plain HTTP request first, because it's free. If that fails, escalate: datacenter proxy, then residential, then a real browser. Each tier costs more than the one before it, so the economics of the whole pipeline depend on the cheap tiers winning as often as possible.&lt;/p&gt;

&lt;p&gt;Extraction ran four strategies against every downloaded page: JSON-LD, microdata, OpenGraph, and a per-domain selector config. Parse the HTML once with cheerio, let the four extractors share the tree, merge results field by field. Boring, layered. It works. I've written before about why I like this shape: when a retailer redesigns, one strategy dies and the other three carry the record.&lt;/p&gt;

&lt;p&gt;This is a story about the word "once" in that paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Fashion-retailer product pages are enormous. A server-rendered page with a hundred color-and-size variants ships HTML by the megabyte, and a megabyte of HTML costs a multiple of itself in heap the moment it becomes a DOM tree. The download function had 2 GB of memory and still ran hot on heavy pages: slow invocations, memory climbing toward the ceiling, and enough background worry that the code had &lt;code&gt;logMemoryUsage()&lt;/code&gt; calls sprinkled through it from earlier rounds of suspicion.&lt;/p&gt;

&lt;p&gt;The graphs said memory. The parser is what uses memory. So the parser was guilty. Case closed, investigation over, off to optimize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong week
&lt;/h2&gt;

&lt;p&gt;The commit is still in the repo, and the message alone is a confession: &lt;code&gt;feat: EXPERIMENTAL cheerio memory reduction&lt;/code&gt;. Three files, 404 lines added, 53 removed.&lt;/p&gt;

&lt;p&gt;What those lines contained:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A pre-parse HTML stripper that removed scripts, styles, and comments before cheerio ever saw the document, complete with a proud log line, quoted verbatim from the diff: &lt;code&gt;HTML optimized: ${html.length} -&amp;gt; ${optimizedHtml.length} characters&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A brand-new &lt;code&gt;utils/memory.ts&lt;/code&gt; with a whole toolkit: &lt;code&gt;estimateHtmlMemoryUsage()&lt;/code&gt;, &lt;code&gt;isHtmlTooLarge()&lt;/code&gt;, &lt;code&gt;optimizeCheerio()&lt;/code&gt;, and the crown jewel, &lt;code&gt;forceGarbageCollection()&lt;/code&gt;. Running Node with &lt;code&gt;--expose-gc&lt;/code&gt; in production is the software equivalent of hitting the TV.&lt;/li&gt;
&lt;li&gt;A rewrite of the extractor runner from &lt;code&gt;Promise.all&lt;/code&gt; to a sequential for-loop, on the theory that four extractors sharing one tree "held references too long". They share a reference to the same tree, so running them one at a time saves almost nothing. That detail should have tipped me off that I didn't understand where the memory was going. It didn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the trap: it helped. Stripping megabytes of scripts before parsing genuinely shrinks the tree, and "it helped" feels like progress when it's actually anesthesia. Symptom optimization pays out just often enough to keep you at the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read
&lt;/h2&gt;

&lt;p&gt;Five days later I sat down and read the waterfall code end to end. Not the extractors. The plumbing between them.&lt;/p&gt;

&lt;p&gt;A proxy waterfall needs to know whether a tier succeeded, and in scraping a 200 response means nothing. Bot walls return 200, empty client-side shells return 200, parked domains return 200 with a smile. So each tier's success check called a function named &lt;code&gt;checkMinimumData&lt;/code&gt;: parse the response, run all four extraction strategies, and answer one question. Did we get at least a title and a price? Honest check, correct implementation.&lt;/p&gt;

&lt;p&gt;Then, one function later, the success path took the response it had just validated... and parsed it. Ran all four strategies. Again. Same bytes, same cheerio load, same selector config, this time to produce the record we'd actually save.&lt;/p&gt;

&lt;p&gt;Every page that succeeded anywhere in the waterfall was fully extracted exactly twice. Under the right timing, both DOM trees existed in memory at once. Which is how a 2 GB function chokes on a 3 MB page. The parser I'd spent a week optimizing was doing its job flawlessly. Twice per page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nobody saw it
&lt;/h2&gt;

&lt;p&gt;Both call sites were individually correct, which is exactly why the bug survived every review.&lt;/p&gt;

&lt;p&gt;Validation genuinely needs extraction: checking that a page yields real product data is the only honest way to grade a scrape. Processing genuinely needs extraction: that's the product. Each function read sane on its own, each was tested on its own, and the duplication lived in the seam between them. Seams belong to nobody, so nobody was looking there.&lt;/p&gt;

&lt;p&gt;It also explains why the optimization week "helped". Stripping scripts before parsing made both parses cheaper, so the numbers moved for a while.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Cache what validation computed, hand it to the processor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tier success check: run extraction ONCE, remember the result&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minimumDataResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkMinimumData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;parserConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;extractor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageDocument&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;minimumDataResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Success&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;validationResultCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;minimumDataResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;minimumDataResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Success&lt;/span&gt;&lt;span class="dl"&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// success path: extraction already happened, do not do it again&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;minimumDataResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;cachedValidationResult&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty meaningful lines. And yes, &lt;code&gt;as any&lt;/code&gt; - the cache rode on an object whose type didn't know about it, and I wanted the win in production the same day. The cast is still in the file. I checked this morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipts
&lt;/h2&gt;

&lt;p&gt;The commit timestamps from that day are the most honest part of this story:&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%2Flbdku5tu8qkawgc1cplr.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%2Flbdku5tu8qkawgc1cplr.png" alt="git log of the wrong week: the EXPERIMENTAL optimization, the noon memory cut, the 18:33 fix" width="800" height="166"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 12:00, high on a week of optimization, I shipped this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt;    &lt;span class="na"&gt;memorySize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2048&lt;/span&gt;
&lt;span class="na"&gt;+    memorySize&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;512&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 18:33, the extraction cache landed. I'll let you reconstruct how the hours in between went, with a function that was still doing every parse twice on a quarter of its former memory.&lt;/p&gt;

&lt;p&gt;The order was backwards, and it worked anyway, in the way that matters: quartering the allocation turned a hidden inefficiency into a visible emergency, and the emergency forced the read that the graphs never did.&lt;/p&gt;

&lt;p&gt;Lambda bills memory times duration. Cutting allocation 4x cuts the price of every invocation by 75% before you even count duration, and duration dropped too, because half of all parsing work in the pipeline's hottest function simply stopped existing. No product change. No extraction-quality change. The savings came from deleting work, not from doing the work faster.&lt;/p&gt;

&lt;p&gt;The 404-line optimization commit is still in the history. I left it as a monument. &lt;code&gt;forceGarbageCollection&lt;/code&gt; is still exported from &lt;code&gt;utils/memory.ts&lt;/code&gt;, still imported by the extractor builder. Never called from anywhere. I checked that this morning too. The most honest line of code in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Two habits came out of that week, and they've paid for themselves several times since.&lt;/p&gt;

&lt;p&gt;First, any pipeline with a validate-then-process shape gets one specific review question now: what does validation compute that processing recomputes? The answer is almost never "nothing". Retry wrappers validate. Health checks validate. Queue consumers validate, then hand the raw input to a processor that starts from zero. The duplicated work hides between the two functions, in code no single reviewer owns.&lt;/p&gt;

&lt;p&gt;Second, when the symptom is resource usage, the first question is "what work is happening", not "how do I make this work cheaper". A profiler answers the first. Optimizations answer the second. I had them in the wrong order for a full week while the answer sat in a function I'd read ten times without reading it.&lt;/p&gt;

&lt;p&gt;Competitive programming taught me, years ago, to read the problem twice before typing. Production has the same rule. The difference is that in production, the problem statement is your own code.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>webscraping</category>
      <category>performance</category>
    </item>
    <item>
      <title>Scraping millions of pages a day: what actually breaks</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Thu, 23 Jul 2026 07:50:26 +0000</pubDate>
      <link>https://dev.to/votiakov/scraping-millions-of-pages-a-day-what-actually-breaks-1c2b</link>
      <guid>https://dev.to/votiakov/scraping-millions-of-pages-a-day-what-actually-breaks-1c2b</guid>
      <description>&lt;p&gt;I ran a scraping platform that processed millions of pages a day at roughly 95% extraction success, around three seconds per page. The fetch-and-parse code, the part everyone thinks of as "the scraper", was a tiny slice of the whole thing. The years went into everything around it.&lt;/p&gt;

&lt;p&gt;Here's what actually broke, more or less in the order it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The queue with no manners
&lt;/h2&gt;

&lt;p&gt;Crawling is bursty in a way that surprises you the first time. One category page fans out into a few hundred product URLs. A sitemap refresh dumps half a million at once. Meanwhile your parsers chew through pages at a steady rate that doesn't care about your ambitions.&lt;/p&gt;

&lt;p&gt;Our first queue was effectively unbounded. It absorbed every burst happily, Redis memory climbed for two days, and then the whole thing fell over at once instead of slowing down gracefully. Lesson: if your queue can't say no, it's not a queue, it's a landfill. Bound it, and make producers block or shed when it's full. A crawler that pauses discovery for an hour is a non-event. A crawler that OOMs the broker is a weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries that stampede
&lt;/h2&gt;

&lt;p&gt;A target site starts throwing 500s for two minutes. Fine, that happens. Every failed page gets rescheduled with the same fixed backoff. Which means 40,000 pages land back on that site in the same ten-second window, it tips over again, and now their WAF has opinions about you. You caused the second outage yourself.&lt;/p&gt;

&lt;p&gt;Two fixes, both mandatory: full jitter on backoff, and a per-domain circuit breaker so a struggling site stops receiving traffic entirely for a while.&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;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;300.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# full jitter: spread retries across the whole window
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one-liner is the difference between "we retry politely" and "we DDoS people by accident".&lt;/p&gt;

&lt;h2&gt;
  
  
  Dedupe before fetch, not after
&lt;/h2&gt;

&lt;p&gt;The same product reaches you through six URLs. Tracking params, color variant params, three different category paths, a mobile subdomain. If you dedupe after fetching, you paid for six fetches to keep one page. At millions of pages a day that's real money and real block-risk for zero data.&lt;/p&gt;

&lt;p&gt;Canonicalize at enqueue time. Strip the junk params, pick one canonical form, check the seen-set before the URL ever enters the queue. And watch that seen-set: it grows forever unless you give it a TTL or accept a small false-positive rate with something bloom-filter-shaped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parser drift, the quiet one
&lt;/h2&gt;

&lt;p&gt;Sites almost never break your parser loudly. They drip. A price moves from a DOM node into an embedded script blob. An image attribute gets renamed. Your extraction still "succeeds", the run is green, and one field quietly goes null on 30% of pages. Nobody notices until a downstream consumer asks why half the prices vanished last Tuesday.&lt;/p&gt;

&lt;p&gt;Field-level fill rates saved us. For every field, track what fraction of pages yielded a value, keep a rolling baseline per site, alert on the delta. A green run only proves the code didn't crash.&lt;/p&gt;

&lt;p&gt;The other thing that got us to 95%: never rely on a single extraction strategy. Embedded JSON, microdata, plain DOM selectors, run more than one and merge by confidence. When a site redesign kills one of them, the others carry it while you fix things, instead of the number going to zero overnight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CPU step hiding in an I/O system
&lt;/h2&gt;

&lt;p&gt;Scraping feels like an I/O problem. Fetch, wait, fetch, wait. So you build it all&lt;br&gt;
async and assume the network is the only thing you're waiting on. Then you profile a&lt;br&gt;
slow day and find the bottleneck is HTML parsing.&lt;/p&gt;

&lt;p&gt;Loading a big product page into an HTML parser and running extractors over it is&lt;br&gt;
CPU-bound and synchronous. On a single-threaded runtime, every parse blocks the event&lt;br&gt;
loop, which means it blocks the very downloads you thought were the slow part. Your&lt;br&gt;
concurrency number looks healthy and your throughput doesn't match it, because workers&lt;br&gt;
are queued behind each other's parsing, not behind the network.&lt;/p&gt;

&lt;p&gt;The fix is to move the CPU-heavy parse off the main thread into a worker pool, and fall&lt;br&gt;
back to inline parsing when the pool is disabled or a job errors. Downloads keep flowing&lt;br&gt;
while parsing happens elsewhere. Obvious in hindsight, invisible until you measure it.&lt;br&gt;
It ended up one of the bigger single throughput wins I got at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  You will need to debug the past
&lt;/h2&gt;

&lt;p&gt;The nastiest questions arrive late. "Why did brand X prices go missing on the 14th?" It's the 19th. The pages have changed since. Your logs say everything was fine.&lt;/p&gt;

&lt;p&gt;We got out of that hole with a typed per-page event stream plus archived raw HTML. Every page emitted structured events (fetched, parsed, extracted, with typed payloads) and the raw response got stored. So "what happened on the 14th" became a replay: pull the archived pages, re-run the current parser against them, diff the output. Debugging in the past tense. Without replay you're reconstructing incidents from vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable summary
&lt;/h2&gt;

&lt;p&gt;None of this is glamorous. There's no clever algorithm in this post, and that's the point. At small scale, scraping is a parsing problem. At millions of pages a day it's a distributed systems problem wearing a parsing costume, and the failure modes are the boring ones: backpressure, thundering herds, silent data decay, no audit trail.&lt;/p&gt;

&lt;p&gt;All of this is what I'm productizing at Cartpie, e-commerce product data as an API so you don't have to own any of the machinery above. Free tier is live.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>distributedsystems</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>Anti-bot without melting your budget: the proxy waterfall.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:05:44 +0000</pubDate>
      <link>https://dev.to/votiakov/anti-bot-without-melting-your-budget-the-proxy-waterfall-4a04</link>
      <guid>https://dev.to/votiakov/anti-bot-without-melting-your-budget-the-proxy-waterfall-4a04</guid>
      <description>&lt;p&gt;Residential proxies are the caviar of scraping infra. They work when nothing else does, and they cost accordingly. The mistake I see over and over is teams routing every single request through residential IPs because a handful of pages needed it. You end up paying steak prices to fetch a robots.txt.&lt;/p&gt;

&lt;p&gt;On one client I cut BrightData spend by 90% and ScrapingBee by 67% without touching success rates, and the whole change was structural. Stop treating all traffic as equally sensitive. Tier it, and only escalate when you have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The waterfall
&lt;/h2&gt;

&lt;p&gt;The idea is a fall-through ladder. Cheap options first, expensive last, and you only move down a rung when the current one demonstrably fails.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier 0, no proxy.&lt;/strong&gt; Plenty of endpoints don't care. Public JSON APIs, sitemaps, image CDNs, anything that isn't fingerprinting you. If it works direct, that request costs you nothing. On my own stack naked direct requests clear roughly 40% of targets by themselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 0.5, still no proxy, but fix your TLS fingerprint.&lt;/strong&gt; This is the rung everyone skips. A plain HTTP client has a dead-giveaway TLS/JA3 handshake that Cloudflare and similar defenses match before your request even reaches the app. Swap in a client that mimics a real Chrome fingerprint (cycletls and friends) and a slice of "blocked" sites start returning 200s. Still free, still no proxy. This one change bought me a double-digit percentage-point jump on Cloudflare-fronted pages that direct requests couldn't touch. Do it before you spend a cent on IPs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 1, datacenter / mobile proxies.&lt;/strong&gt; Cheap, fast, plentiful. Good enough for the large majority of HTML and API traffic that just wants a non-suspicious IP with reasonable rate limiting. Rotating mobile IPs sit around here too and punch above datacenter for not much more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 2, residential / rendered.&lt;/strong&gt; The expensive tier, reserved for flows that actually fingerprint hard: checkout, some search endpoints, sites that clearly block datacenter ranges, anything needing real JS execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier 3, managed anti-bot / unlocker service.&lt;/strong&gt; The nuclear option. Slowest and priciest per request, handles CAPTCHAs, clears ~99%. Only when everything above bounces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point of the ladder is the distribution. If the top is free and clears 40%, a fingerprint fix pushes that higher for nothing, and the ~99% unlocker only ever sees the genuinely hard tail, your average cost per page collapses even though your worst-case tool is still expensive.&lt;/p&gt;

&lt;p&gt;The two things that make this work and not just be a fancy retry loop: knowing when a tier failed, and remembering what worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure is not just a status code
&lt;/h2&gt;

&lt;p&gt;A 403 is easy. The dangerous case is a 200 that's actually a soft block. You get back a page, it has the right content-type, your parser even runs, and it's a captcha interstitial or a stripped-down decoy. If you only check the HTTP status, you'll happily record garbage and never escalate.&lt;/p&gt;

&lt;p&gt;So validation is content-level, not transport-level:&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;def&lt;/span&gt; &lt;span class="nf"&gt;is_good&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="n"&gt;expect&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&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;False&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;resp&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;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;expect&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;min_bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&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;False&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&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;body&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;BLOCK_MARKERS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;  &lt;span class="c1"&gt;# captcha/challenge strings
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="c1"&gt;# the actual proof: did the data we came for show up?
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;must_contain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&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;expect&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;tier&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TIERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# cheapest first
&lt;/span&gt;        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tier&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_good&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="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;url_pattern&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;tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# cache the winner
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AllTiersFailed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;must_contain&lt;/code&gt; is the honest check. A price marker, a known JSON key, a product-id pattern. If the thing you came for isn't in the body, you didn't get the page, whatever the status line claimed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Remember what worked
&lt;/h2&gt;

&lt;p&gt;The other half of the savings is &lt;code&gt;remember&lt;/code&gt;. Once you learn that a given domain or URL shape needs Tier 2, cache that decision with a TTL and start future requests to that pattern at the right rung instead of walking the whole ladder every time. Walking the ladder on every request means you pay the datacenter attempt and the residential attempt for pages you already know need residential. Cache it and you pay for the tier that works, most of the time, and re-probe occasionally in case the site relaxed.&lt;/p&gt;

&lt;p&gt;Flip side: expire those decisions. Sites change defenses. A domain that needed residential in March might be fine on datacenter by June, and a stale cache keeps you overpaying. A day or two TTL was the sweet spot for me.&lt;/p&gt;

&lt;p&gt;Where the money actually went, once I measured it: the vast majority of requests were fine on Tier 0 or Tier 1, and a small slice of genuinely sensitive flows justified the residential spend. Before the waterfall, that small slice was setting the price for everything. That's the whole trick. Not a cheaper proxy vendor, a cheaper distribution of traffic across the vendors you already have.&lt;/p&gt;

&lt;p&gt;One caveat worth stating: this adds moving parts. A tier ladder, a validation layer, a decision cache with expiry. If you're scraping a few thousand pages a month off one friendly site, skip all of it and use one proxy. The waterfall earns its complexity at volume, when a 90% cut is real money.&lt;/p&gt;

&lt;p&gt;I do cloud and infra cost work under &lt;a href="https://reducecost.cloud" rel="noopener noreferrer"&gt;reducecost.cloud&lt;/a&gt;, where this kind of "same result, structured cheaper" thinking shows up constantly, and the scraping side of it feeds &lt;a href="https://cartpie.com" rel="noopener noreferrer"&gt;cartpie.com&lt;/a&gt;, an e-commerce product-data scraping platform with a free tier live now.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>Your scraper probably doesn't need a browser. It needs the right request.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Tue, 21 Jul 2026 09:21:42 +0000</pubDate>
      <link>https://dev.to/votiakov/your-scraper-probably-doesnt-need-a-browser-it-needs-the-right-request-1889</link>
      <guid>https://dev.to/votiakov/your-scraper-probably-doesnt-need-a-browser-it-needs-the-right-request-1889</guid>
      <description>&lt;p&gt;The default move when a page is JavaScript-heavy is to reach for headless Chrome. Spin up Puppeteer, wait for the DOM to settle, scrape the rendered result. It works. It also costs you a couple hundred megabytes of RAM per worker, a few seconds per page, and a proxy bill that scales with how much you hate money.&lt;/p&gt;

&lt;p&gt;Most of the time you don't need it. The page you're looking at is JavaScript-heavy precisely because the data arrives separately, as JSON, over an API the site's own frontend calls. If you can call that API directly, you skip the browser, the render wait, and most of the cost. I've run a SHEIN scraper on Apify this way with no proxies at all, and it holds up at volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data is already JSON, somewhere
&lt;/h2&gt;

&lt;p&gt;Think about how a modern shop is built. The HTML shell loads, then the frontend fires XHR/fetch requests to some backend-for-frontend, gets back clean JSON, and paints the page. That JSON is the thing you want. It's already parsed, already structured, and it doesn't have a single CSS selector waiting to break on you.&lt;/p&gt;

&lt;p&gt;Rendering the page to scrape the DOM is doing all that work twice: once by the browser, once by you. Going straight to the JSON cuts out the browser entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually find it
&lt;/h2&gt;

&lt;p&gt;Open the site in a normal browser and open devtools. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Go to the Network tab and filter to Fetch/XHR.&lt;/strong&gt; This drops images, fonts, and scripts, and leaves the data calls. Clear it, then reload or click the thing that loads the products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch what comes back.&lt;/strong&gt; Click a request, look at the Preview tab. You're hunting for a response that looks like the page's data: a list of products, a price, an ID. When you find one, you've found your target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the request that produced it.&lt;/strong&gt; What's the URL shape, what query params or body does it take, what headers actually matter. Most headers are noise. A few are load-bearing. You find out which by removing them one at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the mobile site.&lt;/strong&gt; This is the tip people skip. The mobile web app or the app's own backend is often simpler, less defended, and returns flatter JSON than the desktop equivalent. If the desktop API is a pain, &lt;code&gt;m.&lt;/code&gt; something or the mobile user-agent path frequently isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce it outside the browser.&lt;/strong&gt; Right-click the request, copy as cURL, paste into a terminal. If it still returns data, you have a scraper. If it 403s, something in the headers or params was doing authentication work, and now you know where to look.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the shape of what you end up with once the browser is gone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MOBILE_UA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;accept&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// the handful of headers that actually gate the response,&lt;/span&gt;
    &lt;span class="c1"&gt;// discovered by removing them until it breaks&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goods_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;goods_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retail_price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;retail_price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&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;That's the whole thing. No render loop, no &lt;code&gt;waitForSelector&lt;/code&gt;, no headless binary to keep patched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts I won't hand you
&lt;/h2&gt;

&lt;p&gt;I'm being deliberately vague about the exact endpoints, params, and signing for any specific site, including SHEIN. Figuring those out is the actual work, and it's a moving target that shifts when the site ships a new app version. What I'll give you is the method. What I won't is the finished recipe, partly because it's my edge and partly because it'd be stale by the time you read it.&lt;/p&gt;

&lt;p&gt;Some genuinely need a browser, and I'll say so. Endpoints signed with a token generated by obfuscated client-side JS, or flows gated behind a real interaction, are sometimes not worth cracking. Rendering once to harvest a token, then firing cheap direct requests after, is a fair compromise. But reach for that after you've checked the Network tab, not before.&lt;/p&gt;

&lt;p&gt;Rule of thumb I'd actually bet on: if a site has a mobile app, it has an API you can probably reach, and it's probably nicer than anything you'd scrape off the desktop DOM. Start there.&lt;/p&gt;

&lt;p&gt;If you want a worked example that runs proxyless in production, my SHEIN product scraper on Apify is built exactly this way: &lt;a href="https://apify.com/native_emblem/shein-product-scraper" rel="noopener noreferrer"&gt;apify.com/native_emblem/shein-product-scraper&lt;/a&gt;. And the broader platform version of this lives at &lt;a href="https://cartpie.com" rel="noopener noreferrer"&gt;cartpie.com&lt;/a&gt;, an e-commerce product-data scraping platform with a free tier live now.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>api</category>
    </item>
    <item>
      <title>We cut a Lambda bill 96.5% by fixing a retry storm.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Mon, 20 Jul 2026 16:51:44 +0000</pubDate>
      <link>https://dev.to/votiakov/we-cut-a-lambda-bill-965-by-fixing-a-retry-storm-4ed7</link>
      <guid>https://dev.to/votiakov/we-cut-a-lambda-bill-965-by-fixing-a-retry-storm-4ed7</guid>
      <description>&lt;p&gt;The invocation graph is the first thing I open on any serverless engagement, and this one looked wrong from across the room. 29.3K Lambda invocations per hour. For a workload that justified a few hundred.&lt;/p&gt;

&lt;p&gt;Nobody had noticed, because nothing was down. That's the ugly property of a retry storm. The system stays green while it burns money. Every retry eventually succeeds, lands in a dead-letter queue, or quietly expires. Dashboards show traffic, and the only symptom is a bill that grows faster than the product does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a retry storm actually is
&lt;/h2&gt;

&lt;p&gt;AWS retries on your behalf in more places than most teams can list. Async Lambda invocations retry twice by default. An SQS event source leaves failed messages on the queue, and after the visibility timeout they become visible again and are redelivered. EventBridge has its own retry policy. Step Functions too. Each layer is individually reasonable. Stacked together, they multiply.&lt;/p&gt;

&lt;p&gt;The failure mode that bit this client was surprisingly simple. The handler wrapped everything in &lt;code&gt;try/catch&lt;/code&gt;, logged the error, and re-threw. Everything. Including validation errors on malformed messages. A malformed message can never succeed, but the code told SQS, "please try again."&lt;/p&gt;

&lt;p&gt;Without a redrive policy, SQS keeps redelivering a failing message until the retention period expires. The default retention period is four days. One poison message became four days of Lambda invocations. There were thousands of poison messages, and the function that consumed their output failed too, creating its own retry storm downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Four changes, in order of impact.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Classify errors before throwing
&lt;/h3&gt;

&lt;p&gt;Retryable and terminal errors are different animals. A network timeout deserves another attempt. A schema violation belongs in a parking lot, not in an infinite retry loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batchItemFailures&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Records&lt;/span&gt;&lt;span class="p"&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="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;parkMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;batchItemFailures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;itemIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&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="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="nx"&gt;batchItemFailures&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;This uses SQS partial batch responses, so only the failed messages are retried. Without this feature, one failed record can cause the entire batch to be retried, including messages that were already processed successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Configure a dead-letter queue with a sensible &lt;code&gt;maxReceiveCount&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Three attempts, then move the message to a DLQ and alert on queue depth.&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;"RedrivePolicy"&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;"deadLetterTargetArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:sqs:eu-west-1:123456789:orders-dlq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxReceiveCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&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;The DLQ is not a trash can. It is a work queue for humans. Someone should periodically inspect it, fix the underlying issue, and decide whether those messages should be replayed.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Add a circuit breaker around flaky downstream services
&lt;/h3&gt;

&lt;p&gt;When a dependency is down, ten thousand Lambda invocations hammering it will not make it recover any faster. Fail fast, back off, and let SQS absorb the burst.&lt;/p&gt;

&lt;p&gt;One caveat: circuit breakers are awkward in Lambda because execution environments are ephemeral. The breaker state has to live somewhere external. We stored ours in a DynamoDB item with a TTL. It worked well enough, even if it was not the prettiest implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Tune timeouts to reality
&lt;/h3&gt;

&lt;p&gt;The function timeout was set close to the Lambda maximum "to be safe." Safe for whom?&lt;/p&gt;

&lt;p&gt;Long timeouts increase the maximum cost of every failed invocation and delay retries. We set the timeout to our p99 execution time plus some headroom. That reduced the cost of failures while still giving healthy requests enough time to complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Lambda invocations dropped from 29.3K per hour to 669. That's a 97.7% reduction. The Lambda line item fell by 96.5%.&lt;/p&gt;

&lt;p&gt;Same product. Same traffic. Same features.&lt;/p&gt;

&lt;p&gt;The only difference was that we stopped paying to retry work that could never succeed.&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%2Fv86xrvhmtalkoc5zl25z.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%2Fv86xrvhmtalkoc5zl25z.png" alt="Lambda invocations per hour, before and after the fix: a flat line around 29,300/hour collapsing to roughly 669/hour" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget idempotency
&lt;/h2&gt;

&lt;p&gt;Retries only work safely if your processing is idempotent.&lt;/p&gt;

&lt;p&gt;If processing the same order twice sends two emails, ships two packages, or charges the customer twice, retries become a business problem instead of an infrastructure problem.&lt;/p&gt;

&lt;p&gt;Whenever you rely on retries, make sure your handlers can safely process the same message multiple times.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to catch this in your own account
&lt;/h2&gt;

&lt;p&gt;Spend ten minutes checking these today.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Track the ratio of &lt;code&gt;Errors / Invocations&lt;/code&gt; in CloudWatch. Alert when the error rate is consistently above your normal baseline.&lt;/li&gt;
&lt;li&gt;For every SQS-triggered Lambda, verify the queue has a redrive policy. Without a DLQ, poison messages keep cycling until the retention period expires.&lt;/li&gt;
&lt;li&gt;Compare Lambda invocations with real business events. If you process 10,000 orders per day but see 300,000 invocations, retries are probably inflating the numbers.&lt;/li&gt;
&lt;li&gt;Review &lt;code&gt;MaximumRetryAttempts&lt;/code&gt; for asynchronous Lambda invocations. The default is two retries, but for some idempotency-sensitive workloads you may want zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retries are a good default for a world where failures are often transient. They are a terrible default for deterministic failures. Your error handling is what tells AWS which kind it is.&lt;/p&gt;

&lt;p&gt;If your AWS bill feels wrong and you'd rather not gamble on another generic cloud consultancy, I do this at &lt;a href="https://reducecost.cloud" rel="noopener noreferrer"&gt;reducecost.cloud&lt;/a&gt; on a pay-for-savings basis. Zero upfront. You only pay a share of what I actually save you.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Stop writing a parser per site. Run five and let confidence decide.</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Mon, 20 Jul 2026 09:40:12 +0000</pubDate>
      <link>https://dev.to/votiakov/stop-writing-a-parser-per-site-run-five-and-let-confidence-decide-55ib</link>
      <guid>https://dev.to/votiakov/stop-writing-a-parser-per-site-run-five-and-let-confidence-decide-55ib</guid>
      <description>&lt;p&gt;For a long time I ran product extraction off a database of custom selector configs. Hundreds of retailers, each with its own set of CSS selectors mapped field by field: price here, title there, image over there. It worked. It was also a treadmill. Every retailer that redesigned its frontend silently broke its config, and the maintenance tax grew with every retailer I added. Hundreds of configs is hundreds of things that can rot without telling you, usually right before someone downstream asks why a brand's prices went null.&lt;/p&gt;

&lt;p&gt;At some point I stopped feeding it. The scraping platform I ran at my last engagement fed a 20M+ product catalogue at millions of pages a day, and the thing that made that survivable wasn't better per-site config. It was leaning on almost no per-site config at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Instead of one careful parser per site, run several cheap generic extractors on every page, in parallel:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD.&lt;/strong&gt; A huge share of e-commerce pages ship a &lt;code&gt;schema.org/Product&lt;/code&gt; block because Google rewards it. It has name, price, currency, availability, images. It's the closest thing to a public API hiding in the HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenGraph tags.&lt;/strong&gt; &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;product:price:amount&lt;/code&gt;. Lower quality than JSON-LD, but present on sites too lazy for structured data, because everyone wants pretty link previews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microdata / RDFa.&lt;/strong&gt; Older sites, still surprisingly common outside the US.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded state.&lt;/strong&gt; &lt;code&gt;__NEXT_DATA__&lt;/code&gt;, &lt;code&gt;window.__INITIAL_STATE__&lt;/code&gt; and friends. A JSON blob the site's own frontend hydrates from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heuristics.&lt;/strong&gt; A price-shaped string near a currency symbol inside the main content region. The largest above-the-fold image. The &lt;code&gt;h1&lt;/code&gt;. Dumb, and dumb works more often than you'd think.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these is reliable alone. OG price tags go stale, JSON-LD sometimes describes the wrong variant, heuristics grab the crossed-out "was" price. The trick is you don't pick an extractor. You pick per field, and you let agreement between sources carry the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confidence merge
&lt;/h2&gt;

&lt;p&gt;Each extractor emits candidates with a prior based on how trustworthy that source usually is for that field. Then candidates that agree with each other get boosted, and the top score wins per field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&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="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PRIORS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agreeing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;agreeing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;candidates&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;normalize&lt;/code&gt; matters more than the scoring. "1 299,00 zł" and "1299.00 PLN" have to compare equal or your agreement boost never fires. I spent more time on value normalization than on everything else in this system combined.&lt;/p&gt;

&lt;p&gt;Add a validation pass after the merge: does the price parse, is the currency a real ISO code, is the title longer than three characters. A merged record that fails validation is a signal, not a result. Log it, sample those pages weekly, and you'll find out which extractor is drifting before your customers do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Because the long tail is where per-site config dies. The top few hundred retailers can justify hand-tuned selectors, and I maintained exactly that for years. Site number 4,000 does not, and with an ensemble it doesn't need any. A new domain comes in, four of five extractors fire, three agree on price, and you have a usable record with zero engineering hours spent. Across that platform we held roughly 95% extraction success at about 3 seconds a page, and most of the domains involved had nobody who had ever looked at their HTML.&lt;/p&gt;

&lt;p&gt;The honest limitation: variant-heavy product pages are still rough. When one URL carries twelve colorways at different prices, the ensemble happily returns a confident answer to the wrong question, because every source on the page describes the default variant. I handle that with a separate variant expansion step, and it's the least elegant code I own.&lt;/p&gt;

&lt;p&gt;But for "give me name, price, image, availability from an arbitrary shop URL," the ensemble beat every hand-written parser I've replaced with it. Write extractors for formats, not for websites. Formats change maybe once a decade. Websites change on Tuesdays.&lt;/p&gt;

&lt;p&gt;This approach is the core of what I'm building at Cartpie, an e-commerce product-data scraping platform, free tier live at &lt;a href="https://cartpie.com" rel="noopener noreferrer"&gt;cartpie.com&lt;/a&gt; if you want to throw some long-tail URLs at it.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Most company job boards are just a public JSON API you can GET</title>
      <dc:creator>Andrii Votiakov</dc:creator>
      <pubDate>Sun, 19 Jul 2026 16:24:37 +0000</pubDate>
      <link>https://dev.to/votiakov/most-company-job-boards-are-just-a-public-json-api-you-can-get-55g3</link>
      <guid>https://dev.to/votiakov/most-company-job-boards-are-just-a-public-json-api-you-can-get-55g3</guid>
      <description>&lt;p&gt;I spent an afternoon trying to scrape a careers page with a headless browser before I noticed the page itself was calling a JSON endpoint. The company runs Greenhouse. Greenhouse serves the whole board — every open role, full descriptions — over one unauthenticated GET. No Playwright, no proxy, no waiting for React to hydrate. I deleted the browser code and never looked back.&lt;/p&gt;

&lt;p&gt;That's the thing nobody tells you when you're building a job aggregator or a "who's hiring" tracker. Stripe, Spotify, Ramp — thousands of companies post through four ATS platforms, and each one exposes a per-company feed that's already public. You just have to know the URL shape.&lt;/p&gt;

&lt;p&gt;Here are the four I use, with real slugs so you can curl along.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoints
&lt;/h2&gt;

&lt;p&gt;Every platform keys off a &lt;strong&gt;slug&lt;/strong&gt; — the token buried in the careers URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Greenhouse
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://boards-api.greenhouse.io/v1/boards/{slug}/jobs?content=true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;?content=true&lt;/code&gt; gives you the full HTML description inline. Hit &lt;code&gt;stripe&lt;/code&gt; and you get 500+ jobs in a single response — no pagination at all. The slug is whatever sits in &lt;code&gt;boards.greenhouse.io/&lt;/code&gt;&lt;strong&gt;&lt;code&gt;stripe&lt;/code&gt;&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;gotScraping&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://boards-api.greenhouse.io/v1/boards/stripe/jobs?content=true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;responseType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// res.body.jobs -&amp;gt; [{ id, title, location: {name}, content, absolute_url, departments, offices, first_published, updated_at }]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap cost me twenty minutes: Greenhouse &lt;strong&gt;double-encodes&lt;/strong&gt; the &lt;code&gt;content&lt;/code&gt; field. You get &lt;code&gt;&amp;amp;lt;p&amp;amp;gt;&lt;/code&gt; where you expected &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;. Run it through an HTML-entity decode once before you touch it, or you'll render literal tags in your UI and blame your own template.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lever
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://api.lever.co/v0/postings/{slug}?mode=json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try &lt;code&gt;spotify&lt;/code&gt; or &lt;code&gt;ro&lt;/code&gt;. You get a flat array back, and the useful fields (team, location, commitment) hide one level down under &lt;code&gt;categories&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// [{ id, text, categories: { team, location, commitment }, description, descriptionPlain, hostedUrl, applyUrl, createdAt }]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 404 here is not an error. It just means the company isn't on Lever, which is exactly what you want to hear when you're guessing which platform a slug belongs to.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ashby
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://api.ashbyhq.com/posting-api/job-board/{slug}?includeCompensation=true
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try &lt;code&gt;ramp&lt;/code&gt;. Ashby is the one I wish everyone used. Structured &lt;code&gt;location&lt;/code&gt;, &lt;code&gt;secondaryLocations&lt;/code&gt;, a real &lt;code&gt;isRemote&lt;/code&gt; boolean, &lt;code&gt;employmentType&lt;/code&gt;, and both &lt;code&gt;descriptionHtml&lt;/code&gt; and &lt;code&gt;descriptionPlain&lt;/code&gt; so you don't have to strip tags yourself. When the company fills in comp bands, you get those too.&lt;/p&gt;

&lt;h3&gt;
  
  
  SmartRecruiters
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;https://api.smartrecruiters.com/v1/companies/{slug}/postings?limit=100&amp;amp;offset=0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try &lt;code&gt;Visa&lt;/code&gt;. This is the odd one out — it &lt;strong&gt;paginates&lt;/strong&gt;. You get a &lt;code&gt;totalFound&lt;/code&gt; and you loop &lt;code&gt;limit&lt;/code&gt;/&lt;code&gt;offset&lt;/code&gt; until you've pulled everything. And here's the catch that bit me: the list endpoint returns metadata only. No description body. If you want the actual job text you have to call a separate per-posting detail endpoint, one request each. So the "free and fast" story falls apart a bit for SmartRecruiters — you're paying N+1 requests for full text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning four shapes into one
&lt;/h2&gt;

&lt;p&gt;The endpoints are the easy part. The value is a single record shape so the rest of your code never has to know or care which ATS a company runs. A thin per-provider normalizer does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeGreenhouse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;greenhouse&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;department&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;departments&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/remote/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;absolute_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;descriptionHtml&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeEntities&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;postedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first_published&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeLever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;categories&lt;/span&gt; &lt;span class="o"&gt;??&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="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lever&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;department&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;department&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;remote&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/remote/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workplaceType&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostedUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;descriptionHtml&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;postedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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="c1"&gt;// ...ashby, smartRecruiters similarly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;remote&lt;/code&gt; is a regex guess on Greenhouse and Lever because neither gives you a clean flag. Ashby actually hands you &lt;code&gt;isRemote&lt;/code&gt;, so use it there instead of guessing. That inconsistency is the whole reason the normalizer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guessing the platform when you only have a name
&lt;/h2&gt;

&lt;p&gt;Say a user types "Ramp" and you have no idea which ATS they're on. You could look it up. Or you could throw the slug at all four at once and keep whatever answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findJobs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nf"&gt;fetchGreenhouse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;fetchLever&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;fetchAshby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;fetchSmartRecruiters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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;The 404s from the three wrong platforms come back in a couple hundred milliseconds and cost nothing. In a real build you'd resolve the &lt;em&gt;actual&lt;/em&gt; slug by parsing the careers-page embed — the ATS board is almost always iframed or linked from there — but bare-slug guessing covers more companies than you'd expect: company names and slugs match more often than not.&lt;/p&gt;

&lt;h2&gt;
  
  
  1,199 roles in one pass
&lt;/h2&gt;

&lt;p&gt;To sanity-check that this actually holds up, I ran it across 8 real companies spanning all four platforms and pulled &lt;strong&gt;1,199 open roles&lt;/strong&gt; in a single sweep. Proxyless, roughly 200ms per company on the single-request platforms. The stable part is what sells it: these are the same feeds the companies' own job widgets call, so a marketing redesign doesn't touch them. Scrape the rendered page and you're one CSS class rename away from a broken parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops working
&lt;/h2&gt;

&lt;p&gt;Not every ATS plays along, and I'd rather tell you now than have you burn an afternoon.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SmartRecruiters' list feed has no descriptions.&lt;/strong&gt; Covered above — plan for the N+1 or ship jobs without body text. And &lt;code&gt;log()&lt;/code&gt; the pagination totals loudly: a truncated &lt;code&gt;limit&lt;/code&gt;/&lt;code&gt;offset&lt;/code&gt; loop will quietly return page one and let you think you got the whole board.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workable is auth-gated now.&lt;/strong&gt; The old open feed wants a token. Same story with Recruitee. A year ago both were open; that window closed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this isn't a universal skeleton key. It's four platforms that happen to still be open, and they happen to cover a large slice of tech hiring. Good enough that I stopped writing careers-page scrapers entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ready-made version
&lt;/h2&gt;

&lt;p&gt;I packed all four platforms, the unified schema, and the auto-detect into an Apify actor — &lt;a href="https://apify.com/native_emblem/ats-job-feed-scraper" rel="noopener noreferrer"&gt;ATS Job Feed Scraper&lt;/a&gt;. But the endpoints above really are most of it. Build your own; it genuinely is a fun afternoon, and you'll understand every field.&lt;/p&gt;

&lt;p&gt;If you know another ATS that still serves an open feed, drop it in the comments and I'll add notes. I'm especially curious whether anyone has a live Workable path that doesn't need a token.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>api</category>
      <category>javascript</category>
      <category>jobs</category>
    </item>
  </channel>
</rss>
