<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[JHNWR]]></title><description><![CDATA[Web Scraping and Data Extraction techniques and workflows]]></description><link>https://johnwrooney.substack.com</link><image><url>https://substackcdn.com/image/fetch/$s_!CeVb!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b9d3066-e051-4825-94fa-222fe45de501_1051x1051.png</url><title>JHNWR</title><link>https://johnwrooney.substack.com</link></image><generator>Substack</generator><lastBuildDate>Wed, 04 Nov 2026 07:38:17 GMT</lastBuildDate><atom:link href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2Ffeed" rel="self" type="application/rss+xml"/><copyright><![CDATA[John WR]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[johnwrooney@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[johnwrooney@substack.com]]></itunes:email><itunes:name><![CDATA[John WR]]></itunes:name></itunes:owner><itunes:author><![CDATA[John WR]]></itunes:author><googleplay:owner><![CDATA[johnwrooney@substack.com]]></googleplay:owner><googleplay:email><![CDATA[johnwrooney@substack.com]]></googleplay:email><googleplay:author><![CDATA[John WR]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[How websites detect Playwright]]></title><description><![CDATA[hiding in plain sight and being in the middle of the herd.]]></description><link>https://johnwrooney.substack.com/p/how-websites-detect-playwright</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/how-websites-detect-playwright</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Wed, 21 Oct 2026 21:24:33 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21PYpf%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>A real browser still has a fingerprint. And the fingerprint is only part of the access decision.</em></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21UzaO%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!UzaO!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!UzaO!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!UzaO!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!UzaO!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21UzaO%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1559996,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/220435764?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!UzaO!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!UzaO!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!UzaO!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!UzaO!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F20121ea7-195c-47b0-a9f2-cb70db5f3377_1536x1024.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>While testing my <a href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2Fp%2Fbuilding-my-browser-stack-on-low%3Fr%3D2bb11a">browser scraping stack</a>, I sent 1,000 requests to an online store. The browser workers stayed healthy, but 193 responses were HTTP 403s, mostly challenge pages.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>It&#8217;s easy to look at that result and say the site detected Playwright.</p><p>It might have picked up browser signals associated with automation. I can&#8217;t tell that from the response alone. The site also saw the IP address, how quickly I requested pages, where I started and what I visited next. My test even blocked scripts, which limited what a JavaScript challenge could do.</p><p>I&#8217;ve found it more useful to ask what the site could observe, and how those observations fit together.</p><h2><strong>Hiding in plain sight</strong></h2><p>I want the browser and session to fit among the traffic the site expects. Scrubbing every field to build a supposedly perfect fingerprint can work against that.</p><p>Changing every exposed value can create a combination that hardly any ordinary browser has. If it claims one operating system but renders text and reports graphics capabilities associated with another, those details don&#8217;t agree.</p><p>Chromium&#8217;s popularity makes it a natural starting point for this approach. Chrome alone accounted for 65.09% of worldwide desktop browser usage in Statcounter&#8217;s September 2026 figures. Add Chromium-based browsers such as Edge, Opera and Brave, and the family represents a much larger crowd than any competing desktop browser engine. <a href="iframe.php?url=https%3A%2F%2Fgs.statcounter.com%2Fbrowser-market-share%2Fdesktop%2Fworldwide">Statcounter desktop browser share</a>, <a href="iframe.php?url=https%3A%2F%2Fdeveloper.chrome.com%2Fdocs%2Fweb-platform%2Fchrome-chromium">Chrome and Chromium</a></p><p>That large population is a practical reason to build a stealth browser on Chromium, alongside compatibility and tooling. It doesn&#8217;t explain every project&#8217;s choice. Firefox-based options such as Camoufox exist too, and the site&#8217;s own traffic matters more than a global average. An unusual Chromium setup can stand out while an ordinary Firefox session fits in. <a href="iframe.php?url=https%3A%2F%2Fcamoufox.com%2F">Camoufox</a></p><p>There are three questions here:</p><ul><li><p>Can this browser or device be recognised again?</p></li><li><p>Does this activity look automated or abusive?</p></li><li><p>Does this site&#8217;s policy allow this particular request?</p></li></ul><p>A fingerprint can help answer the first two. It doesn&#8217;t decide the third on its own. A recognisable browser can be an ordinary returning customer. A common browser configuration can still send an unacceptable volume of requests.</p><p>The W3C calls this increasing the anonymity set. When more browsers share the same observable characteristics, any one of them is less distinctive. That&#8217;s the idea behind &#8220;blending in&#8221;. It still doesn&#8217;t prove a visitor is human or guarantee access. <a href="iframe.php?url=https%3A%2F%2Fwww.w3.org%2FTR%2Ffingerprinting-guidance%2F">W3C fingerprinting guidance</a></p><h2><strong>What a few lines of JavaScript can collect</strong></h2><p>You don&#8217;t need an elaborate obfuscated script to start observing a browser. Run this in your own page or its developer console:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;javascript&quot;,&quot;nodeId&quot;:&quot;ec8712ec-2050-45ce-ab0b-9e048db6f9b9&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-javascript">
const snapshot = {
  userAgent: navigator.userAgent,
  platform: navigator.platform,
  languages: [...navigator.languages],
  webdriver: navigator.webdriver,
  logicalProcessors: navigator.hardwareConcurrency,
  approximateMemoryGB: navigator.deviceMemory ?? null,
  touchPoints: navigator.maxTouchPoints,
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  screen: [screen.width, screen.height, screen.colorDepth],
  viewport: [innerWidth, innerHeight, devicePixelRatio],
  finePointer: matchMedia(&#8217;(pointer: fine)&#8217;).matches,
  hover: matchMedia(&#8217;(hover: hover)&#8217;).matches
};
console.log(snapshot);
</code></pre></div><p>These values aren&#8217;t all exact hardware measurements. Availability varies, and browsers may reduce or standardise what they expose. Unsupported memory reporting is a missing observation, not evidence of automation.</p><p>I&#8217;d compare these values together with a normal browser on the device being claimed. The input capabilities, viewport, rendering and browser features should make sense together; one unusual field doesn&#8217;t tell me much on its own.</p><p><code>navigator.webdriver</code> is an explicit automation signal. Its value depends on browser configuration and launch conditions; attaching a CDP client doesn&#8217;t universally set it to true. Removing that one observation also doesn&#8217;t remove the others. <a href="iframe.php?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FNavigator%2Fwebdriver">MDN: navigator.webdriver</a></p><h2><strong>Drawing a fingerprint with Canvas</strong></h2><p>Canvas fingerprinting asks the browser to render something and examines the result. Here&#8217;s a small example that draws text and shapes, then hashes the resulting pixels:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;javascript&quot;,&quot;nodeId&quot;:&quot;33538835-b423-4362-9ab7-e24dc2bf8338&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-javascript">async function canvasSample() {
  const canvas = document.createElement(&#8217;canvas&#8217;);
  canvas.width = 320;
  canvas.height = 80;
  const ctx = canvas.getContext(&#8217;2d&#8217;);
  if (!ctx) return null;
&#8203;
  ctx.fillStyle = &#8216;#e8edf2&#8217;;
  ctx.fillRect(0, 0, 320, 80);
  ctx.font = &#8216;19px sans-serif&#8217;;
  ctx.fillStyle = &#8216;#245b87&#8217;;
  ctx.fillText(&#8217;Browser rendering: Aa 0123 &#937;&#8217;, 8, 30);
  ctx.globalCompositeOperation = &#8216;multiply&#8217;;
  ctx.fillStyle = &#8216;rgba(220, 100, 40, 0.6)&#8217;;
  ctx.beginPath();
  ctx.arc(170, 35, 24, 0, Math.PI * 2);
  ctx.fill();
&#8203;
  const pixels = ctx.getImageData(0, 0, 320, 80).data;
  const digest = await crypto.subtle.digest(&#8217;SHA-256&#8217;, pixels);
  return Array.from(new Uint8Array(digest),
    byte =&gt; byte.toString(16).padStart(2, &#8216;0&#8217;)).join(&#8217;&#8216;);
}
console.log(await canvasSample());</code></pre></div><p>Use localhost or HTTPS for the Web Crypto call. This is an illustrative probe, not a commercial detector&#8217;s implementation.</p><p>Fonts, the way text is rasterised, graphics paths, browser versions and privacy protections can all affect the output. The hash gives me a compact value to compare between runs. It won&#8217;t tell me whether the browser is human, uniquely identify the device, or explain which input caused a difference.</p><p>Compare a normal desktop browser with your container, and repeat the measurement. If the hashes differ, you have something to investigate, though you still won&#8217;t know whether that difference mattered to the site that challenged you.</p><h2><strong>Graphics, fonts and audio</strong></h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21PYpf%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!PYpf!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!PYpf!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!PYpf!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!PYpf!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21PYpf%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1725222,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/220435764?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!PYpf!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!PYpf!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!PYpf!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!PYpf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F4740eeb9-96d8-405c-9a6b-494d65634ee8_1536x1024.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>WebGL can expose graphics capabilities and, when available, renderer information:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;javascript&quot;,&quot;nodeId&quot;:&quot;5e965c90-1d4c-463b-866e-a7c5e6e064a3&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-javascript">const gl = document.createElement(&#8217;canvas&#8217;).getContext(&#8217;webgl&#8217;);
const info = gl?.getExtension(&#8217;WEBGL_debug_renderer_info&#8217;);
console.log(gl ? {
  vendor: info ? gl.getParameter(info.UNMASKED_VENDOR_WEBGL) : null,
  renderer: info ? gl.getParameter(info.UNMASKED_RENDERER_WEBGL) : null,
  maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
  extensions: gl.getSupportedExtensions()
} : { webgl: false });</code></pre></div><p>Privacy settings can restrict the extension, and legitimate browsers can use software rendering. Those observations need context. Changing the renderer string also leaves the graphics implementation underneath it unchanged. <a href="iframe.php?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FWEBGL_debug_renderer_info">MDN: WebGL renderer information</a></p><p>Fonts provide another comparison. Without enumerating installed fonts, a script can request a candidate font and compare text widths with a fallback. Different metrics suggest that a different font rendered the text; equal metrics don&#8217;t prove absence. A short probe looks like this:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;javascript&quot;,&quot;nodeId&quot;:&quot;392a011b-ac60-47e0-b0e3-e7321fe62e51&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-javascript">await document.fonts.ready;
const ctx = document.createElement(&#8217;canvas&#8217;).getContext(&#8217;2d&#8217;);
const sample = &#8216;mmmmWWWW0123456789&#8217;;
for (const font of [&#8217;monospace&#8217;, &#8216;Arial, monospace&#8217;, &#8216;Georgia, monospace&#8217;]) {
  ctx.font = `24px ${font}`;
  console.log(font, ctx.measureText(sample).width);
}</code></pre></div><p>Audio fingerprinting works much the same way. An OfflineAudioContext can render a short oscillator through an audio-processing graph into a buffer without playing anything through the speakers. A script can inspect the samples to compare how browsers process the audio. This doesn&#8217;t directly reveal your microphone or identify a person. <a href="iframe.php?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FOfflineAudioContext">MDN: OfflineAudioContext</a></p><p>These measurements overlap, so you can&#8217;t treat each one as an independent vote and add them up. Real people can also have rare combinations. Treating every unusual setup as a bot would block some of them too.</p><h2><strong>Some of the decision happens before page JavaScript</strong></h2><p>The server sees the source IP of the connection reaching it, request headers, and transport behaviour. It can associate that IP with a network and previous traffic. A proxy changes part of that view; it doesn&#8217;t give the entire session a clean history.</p><p>TLS and HTTP behaviour can be fingerprinted too. Copying Chrome&#8217;s headers into a Python HTTP client doesn&#8217;t make its handshake match Chrome&#8217;s. With Playwright driving Chromium, browser requests normally use Chromium&#8217;s network stack. Writing the controlling script in Python doesn&#8217;t turn those into Python TLS handshakes.</p><p>Cloudflare describes combining transport fingerprints with aggregate request signals in its JA4 write-up. It shows how much more a detector can consider than page JavaScript, though it doesn&#8217;t tell me which signals caused my failures. <a href="iframe.php?url=https%3A%2F%2Fblog.cloudflare.com%2Fja4-signals%2F">Cloudflare: JA4 and inter-request signals</a></p><h2><strong>The page you visited first matters too</strong></h2><p>Think about two sessions. One arrives on a product page from a search result, follows a related category, then opens another product. Another starts with fresh storage and visits hundreds of deep product URLs at a steady rate.</p><p>Both sessions could report the same browser properties, but they&#8217;re using the site differently.</p><p>A site can associate requests it receives with cookies, accounts and other session signals. Scripts can report their current page and events. The referrer may give information about the preceding navigation, subject to browser policy. This doesn&#8217;t grant the site a list of everything you&#8217;ve visited elsewhere. <a href="iframe.php?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FDocument%2Freferrer">MDN: document.referrer</a></p><p>People arrive at deep URLs through search, bookmarks and shared links all the time. Starting there doesn&#8217;t prove automation, and visiting the homepage first won&#8217;t fix every blocked session. I&#8217;d look at the sequence of requests and how it compares with ordinary use of that part of the site.</p><h2><strong>Mouse speed, clicks and the journey between them</strong></h2><p>Browser scripts can observe pointer positions and timestamps, clicks, scrolling, focus and visibility. This local demonstration records a bounded sample and prints it when you call the returned stop function. It sends nothing to a server:</p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;javascript&quot;,&quot;nodeId&quot;:&quot;b9880f0d-4d69-4bf8-b689-87c5522f899d&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-javascript">function inspectInteractions() {
  const events = [];
  const controller = new AbortController();
  const started = performance.now();
  let previous = null;
  const record = value =&gt; {
    if (events.length &lt; 100) events.push(value);
  };
&#8203;
  addEventListener(&#8217;pointermove&#8217;, event =&gt; {
    const now = performance.now();
    const dt = previous ? now - previous.time : 0;
    record({
      type: &#8216;move&#8217;, ms: Math.round(now - started),
      pointer: event.pointerType,
      pixelsPerSecond: dt &gt; 0 ? Math.round(1000 * Math.hypot(
        event.clientX - previous.x, event.clientY - previous.y
      ) / dt) : null
    });
    previous = { x: event.clientX, y: event.clientY, time: now };
  }, { signal: controller.signal, passive: true });
&#8203;
  addEventListener(&#8217;click&#8217;, event =&gt; record({
    type: &#8216;click&#8217;, ms: Math.round(performance.now() - started),
    element: event.target instanceof Element ? event.target.tagName : null
  }), { signal: controller.signal });
&#8203;
  return () =&gt; {
    controller.abort();
    console.table(events);
  };
}
const stopInspection = inspectInteractions();
// Interact with this page, then call stopInspection().</code></pre></div><p>The script calculates mouse speed, but I haven&#8217;t shown that any vendor uses this formula or a particular &#8220;human speed&#8221; threshold. Event coalescing, device type and browser scheduling affect the samples. People also use keyboards, touchscreens and assistive technology, so a visitor who doesn&#8217;t move a mouse isn&#8217;t necessarily a bot. <a href="iframe.php?url=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FPointerEvent">MDN: PointerEvent</a></p><p>What interests me is the broader sequence: how quickly pages are requested, which links are followed, whether dozens of sessions move in lockstep, and whether activity continues unchanged through errors and challenges. Adding random mouse movements doesn&#8217;t make those patterns disappear.</p><h2><strong>A plausible fingerprint still doesn&#8217;t guarantee access</strong></h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21W111%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!W111!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!W111!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!W111!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!W111!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21W111%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1653441,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/220435764?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!W111!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!W111!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!W111!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!W111!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F61da7b0e-c964-44e7-bf3d-9d87329cf65d_1536x1024.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>Cloudflare documents combining headers, browser signals and session characteristics in its bot scoring, alongside multiple detection engines. It also describes a cookie used to incorporate request patterns over a session. This is one provider&#8217;s documented design, not a universal recipe for every protected website. <a href="iframe.php?url=https%3A%2F%2Fdevelopers.cloudflare.com%2Fbots%2Fconcepts%2Fbot-detection-engines%2F">Cloudflare detection engines</a></p><p>The site&#8217;s configured rules still determine what action to take. Challenge, block and allow decisions can vary by route, rate, account state and other security policies. A challenge page alone doesn&#8217;t identify the rule or observation behind it.</p><p>The order matters here. The browser has to receive and run a script before its observations are available. Cloudflare explicitly warns against requiring its JavaScript detection result on a visitor&#8217;s first request. Any decision on that request has to use what the system knows so far. <a href="iframe.php?url=https%3A%2F%2Fdevelopers.cloudflare.com%2Fcloudflare-challenges%2Fchallenge-types%2Fjavascript-detections%2F">Cloudflare JavaScript detections</a></p><h2><strong>What I would measure next</strong></h2><p>I&#8217;d compare an ordinary browser and the automated browser on a page I control, recording the environment and the exact browser versions. Then I&#8217;d change one factor at a time: network, browser configuration, session reuse, resource loading, or request rate.</p><p>The JavaScript examples above are observation tools. They don&#8217;t reproduce a commercial risk model, and passing them wouldn&#8217;t certify a browser as undetectable. Before publishing measured comparisons, I still need to run and record those demonstrations.</p><p>I want to know what changed between the working session and the failing one. That could be the browser properties, IP, request pattern or missing session state. The site may also have a rule I can&#8217;t identify from its response. Comparing those factors separately should give me more to work with than another unexplained challenge page.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Building my browser stack on low end hardware]]></title><description><![CDATA[How I built a self-hosted browser scraping cluster with Docker, Playwright and proxies&#8212;and what real-world tests revealed about performance and challenge pages]]></description><link>https://johnwrooney.substack.com/p/building-my-browser-stack-on-low</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/building-my-browser-stack-on-low</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Wed, 14 Oct 2026 20:54:31 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21B6um%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>What changed when I turned my self-hosted scraping browser into a small replicated service.</em></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21B6um%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!B6um!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!B6um!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!B6um!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!B6um!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21B6um%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1630451,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/220431315?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!B6um!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!B6um!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!B6um!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!B6um!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F379efcfc-cf48-49c5-823a-3e10de9de8f7_1536x1024.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>In <a href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2Fp%2Fi-built-a-self-hosted-browser-scraping">my last post</a>, I wrote about building a self-hosted browser scraping service around CloakBrowser, Playwright, Xvfb, Docker, and an authenticated proxy.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><p>That version worked. One browser process could run multiple isolated contexts, and each context could have its own cookies, storage, and session state.</p><p>The catch was that every context still shared the same browser process.</p><p>The question became: how many contexts should I run in one browser, and when should I start another browser?</p><p>The answer was sooner than I expected.</p><h2><strong>The first version had one large failure domain</strong></h2><p>The original setup looked roughly like this:</p><pre><code>Scraping script
      |
      v
Playwright server
      |
      v
One CloakBrowser process
      |
      v
Multiple browser contexts
      |
      v
Authenticated proxy</code></pre><p>A single browser with several contexts is much cheaper than starting a new browser for every URL. Each context has its own cookies, local storage, and browsing state, which is what I want for most scraping work.</p><p>But the contexts still share CPU, memory, renderer processes, and the network stack. If the browser becomes unhealthy, all of those contexts are affected.</p><p>There is also a scheduling problem. A CDP connection belongs to one browser process. Adding contexts creates more work inside that process, but it does not spread the work across the machine.</p><p>I needed browser processes to be the unit of capacity.</p><div class="callout-block" data-callout="true"><p>I&#8217;m running this on a HP ProDesk 405 G6. A small form factor desktop with a Ryzen 4650G and 32GB RAM</p></div><h2><strong>The new architecture</strong></h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%215nyr%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!5nyr!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!5nyr!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!5nyr!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!5nyr!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%215nyr%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1807573,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/220431315?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!5nyr!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!5nyr!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!5nyr!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!5nyr!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3b1a957b-4984-4780-b681-584af4f152e4_1536x1024.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The new version runs several browser replicas behind one CDP endpoint.</p><pre><code>Playwright or Puppeteer clients
              |
              v
       CDP broker
       /    |    \
      v     v     v
 Browser Browser Browser ...
 worker 1  worker 2  worker 3
      \     |     /
       Browser contexts</code></pre><p>The default configuration runs four browser workers.</p><p>The browser ports are private to the Docker network. Clients connect only to the broker, which is exposed on port 3000.</p><p>The client still connects like this:</p><pre><code>browser = await playwright.chromium.connect_over_cdp(
    &#8220;http://192.168.68.62:3000&#8221;
)</code></pre><p>The client does not need to know which browser process it is using.</p><h2><strong>What the broker does</strong></h2><p>The broker resolves the browser replicas through Docker DNS and checks their CDP discovery endpoints every two seconds.</p><p>When a client opens a new CDP connection, the broker chooses a healthy worker with available capacity. It prefers the worker with the fewest active connections, then the one least recently assigned.</p><p>The connection stays with that worker until it closes. The broker does not move individual contexts between browsers, so a batch using one CDP connection stays inside one browser process.</p><p>The default limits are:</p><p>When all workers are busy, a new connection waits in memory. Closing an existing connection wakes a waiting client.</p><p>If the queue fills or the timeout expires, the broker returns HTTP 503. A client gets a definite failure instead of waiting forever.</p><p>The broker enforces the connection limit, but it does not count or limit the contexts a client creates. My scrapers use ten contexts per connection. Another client could create more, so the current capacity model depends on clients following that convention.</p><h2><strong>CDP made the networking awkward</strong></h2><p>CDP is a WebSocket protocol, but clients also expect normal HTTP discovery endpoints.</p><p>The broker handles both:</p><pre><code>GET /json/version
GET /devtools/browser</code></pre><p>The first returns the public WebSocket URL. The second opens the long-lived connection that carries browser commands.</p><p>CloakBrowser binds its CDP server to loopback inside the worker container. That keeps the browser endpoint private, but it also means the broker cannot connect to it directly over the Docker network.</p><p>I added a small CDP gateway inside each worker. It exposes the loopback-only browser endpoint to the container network and rewrites the discovery response so clients receive the broker&#8217;s public address rather than an internal <code>127.0.0.1</code> address.</p><p>The client sees one endpoint. Internally, there are several browser processes and several proxy layers.</p><h2><strong>Proxy routing stays inside each worker</strong></h2><p>Each browser worker uses a local proxy:</p><pre><code>CloakBrowser
    |
    v
127.0.0.1:3128
    |
    v
Authenticated upstream proxy</code></pre><p>The local adapter reads the <code>PROXY</code> environment variable, adds the upstream Basic authentication header, and forwards HTTP and CONNECT traffic.</p><p>The browser only sees </p><p>http://127.0.0.1:3128</p><p>. Proxy credentials do not appear in the browser command line or CDP responses.</p><p>If the proxy variable is missing, malformed, or uses the wrong scheme, the adapter exits. The browser then fails instead of silently making direct requests.</p><p>This is safer than treating proxy routing as a best-effort setting.</p><p>It is not a complete security boundary yet. Kernel-level egress filtering is not implemented, so a deliberately modified process could still try to bypass the adapter. That is on the hardening list.</p><h2><strong>The real test</strong></h2><p>A synthetic test can tell me whether the queue works. It cannot tell me how the whole system behaves against a real site.</p><p>For the real test, I used CCL Online.</p><p>The test discovered URLs from CCL&#8217;s public sitemap, excluded paths disallowed by <code>robots.txt</code>, and navigated to 1,000 URLs through the browser service.</p><p>I used:</p><ul><li><p>Four CDP connections</p></li><li><p>Ten contexts per connection</p></li><li><p>Forty concurrent contexts</p></li><li><p>A 45-second navigation timeout</p></li><li><p><code>domcontentloaded</code> navigation</p></li><li><p>Images, fonts, media, scripts, and stylesheets blocked</p></li><li><p>Product-page validation using the returned HTML</p></li></ul><p>The test recorded each URL, status code, elapsed time, retry count, title, and error. It also polled the broker during the run.</p><p>The results were:</p><p>The service stayed up for the entire run. All four workers remained healthy, and no CDP connection waited in the broker queue.</p><h2><strong>The 403s were from CCL</strong></h2><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21kE3Q%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!kE3Q!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!kE3Q!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!kE3Q!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!kE3Q!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21kE3Q%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png" width="1456" height="971" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:971,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1722958,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/220431315?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!kE3Q!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 424w, https://substackcdn.com/image/fetch/$s_!kE3Q!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 848w, https://substackcdn.com/image/fetch/$s_!kE3Q!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 1272w, https://substackcdn.com/image/fetch/$s_!kE3Q!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F45854c83-6748-49c1-9c9e-21180578ffda_1536x1024.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p>The 193 HTTP 403 responses were mostly Cloudflare challenge pages. Their page title was:</p><pre><code>Just a moment...</code></pre><p>The broker did not run out of workers. The browser processes did not disappear. The queue did not fill. CCL accepted many requests and rejected others at its edge protection layer.</p><p>The failures also clustered around category and deals pages. Product pages performed better than several of those listing pages.</p><p>The test measured two separate things:</p><ol><li><p>How quickly my service could process concurrent browser navigations.</p></li><li><p>How the target site responded to that level of traffic through the configured proxy.</p></li></ol><p>The service processed 1,000 navigation attempts at 6.6 pages per second. It returned usable HTTP 200 pages for 801 of them.</p><p>I would not describe 6.6 pages per second as a universal scraping rate. It is the result for this machine, this proxy, this browser build, this concurrency level, and this target site.</p><h2><strong>The browser and the target had different limits</strong></h2><p>The useful result was not the throughput number by itself.</p><p>The broker handled forty active contexts without queueing. The four browser workers stayed healthy. The internal scheduling worked.</p><p>CCL&#8217;s Cloudflare layer became the limiting factor before the browser pool did.</p><p>Adding more replicas would not automatically improve the result. It might increase local capacity while also increasing the number of rejected requests.</p><p>There is a difference between sending more browser work and completing more useful scraping work.</p><p>The next version of the test should vary the concurrency and measure:</p><ul><li><p>Successful pages per second</p></li><li><p>403 rate</p></li><li><p>p95 navigation latency</p></li><li><p>Browser memory</p></li><li><p>CPU usage</p></li><li><p>Proxy behaviour</p></li><li><p>Results by page type</p></li></ul><p>A larger browser pool is useful only when the rest of the system can absorb it.</p><h2><strong>What I would try to reduce challenge pages</strong></h2><p>The first thing I&#8217;d revisit is resource blocking. This test blocked scripts as well as images, fonts, media, and stylesheets. That saved bandwidth, but it also meant a JavaScript-based challenge had no opportunity to run. The results show which navigations returned challenge pages under those settings; they don&#8217;t establish how many would still fail with scripts allowed.</p><p>I&#8217;d compare the current settings with scripts enabled, then with normal resource loading. I&#8217;d also give the page a bounded amount of time to reach a recognisable product or category page, rather than treating the first <code>domcontentloaded</code> event as the final outcome. I&#8217;d record both the initial response and the eventual page state. A page that reaches the content after a short wait is a different result from one that remains on a challenge or requires interaction.</p><h3><strong>Separate concurrency from request rate</strong></h3><p>Forty contexts tells me how much work can be in flight. It doesn&#8217;t set a predictable request rate: fast pages let each context request the next URL sooner.</p><p>I&#8217;d start with low concurrency and a shared per-domain rate limit across all workers, then increase the load in steps. If challenges increase, the scraper should slow down and allow a cooldown instead of immediately adding more retries. The comparison would be validated pages per minute, challenge rate, and bandwidth. A slower request rate could still finish the useful work sooner if fewer requests fail.</p><h3><strong>Check what identity each session actually keeps</strong></h3><p>The test already reuses a context and page across multiple URLs, so it retains cookies within that context. What I haven&#8217;t established is how stable the proxy exit IP is across those requests. A persistent context doesn&#8217;t guarantee a persistent network identity.</p><p>I&#8217;d compare the current proxy configuration with a session-sticky configuration, where available, keeping the browser context and exit IP together for a bounded batch. I&#8217;d measure the exit address before and after each test batch rather than assume the provider&#8217;s rotation behaviour. I&#8217;d also check that locale, timezone, and any browser overrides are consistent with the intended session. These are hypotheses to test, not a promise that a consistent session will avoid challenges.</p><h3><strong>Compare like-for-like pages</strong></h3><p>The failures clustered around category and deals pages, so an overall success rate hides useful differences. I&#8217;d build a fixed sample of product, category, and deals URLs and report each group separately.</p><p>For each experiment I&#8217;d change one variable, repeat the runs, and alternate their order with cooldowns. Otherwise a change in proxy reputation, time of day, or accumulated traffic could look like an improvement caused by the code.</p><h3><strong>Try different proxies and proxy types</strong></h3><p>Once I have a lower-load baseline, I&#8217;d compare datacenter, ISP, residential, and mobile proxies, and try more than one provider. I wouldn&#8217;t assume the more expensive option would produce better results. The question is which pool works reliably for this target at the request rate I need.</p><p>I&#8217;d keep the browser settings, URL sample, request rate, and exit country consistent where possible. For each option I&#8217;d compare sticky sessions with rotation between batches, while checking the actual exit IPs. Rotating on every request would be a separate experiment, because it changes session consistency as well as the IP pool.</p><p>The results should include challenge rate, validated pages per minute, latency, connection failures, bandwidth, and cost per usable record. I&#8217;d sample multiple exit IPs from each pool so one unusually good or bad address doesn&#8217;t decide the comparison. A cheaper proxy with more challenges might still be economical at a lower rate; an expensive one would need to earn its place through better usable output. These tests would establish what works for CCL under those conditions, rather than a universal ranking of proxy types.</p><h3><strong>Count the content, not just the status</strong></h3><p>The current test checks for an <code>h1</code> on HTTP 200 pages. That&#8217;s a useful sanity check, but it isn&#8217;t enough to prove the expected data arrived. I&#8217;d add page-type checks for product names, prices, or category listings, and classify persistent challenges, explicit denials, missing content, and navigation failures separately.</p><p>That would give me a better target than simply reducing 403s: more correct records per minute at an acceptable proxy cost. I&#8217;d start with scripts enabled and lower request rates, because those are two concrete limitations of this test. I haven&#8217;t run these comparisons yet, so I can&#8217;t say which change would improve the result.</p><h2><strong>What I would change next</strong></h2><p>The next tests need to cover failures and longer workloads. A short benchmark tells me whether the service can complete that run. It doesn&#8217;t tell me what happens after several days of scraping, or when a client disappears halfway through a batch.</p><h3><strong>Enforce browser capacity</strong></h3><p>The first change would be enforcing the context limit. Right now, one connection gets one worker, whether that client opens one context or forty. The broker cannot see that difference in its allocation counters. I&#8217;d like to track context creation and disposal, cap the number per connection, and test several scrapers competing for capacity.</p><h3><strong>Test cleanup and interrupted work</strong></h3><p>The broker releases its connection slot when the WebSocket handler exits. I still need to kill a client abruptly and check what happens to its pages and contexts, rather than relying on the scraper&#8217;s normal cleanup path.</p><p>I&#8217;d also restart a browser worker, the broker, and the whole machine with work in progress. Discovering a replacement worker restores capacity; it doesn&#8217;t recover the pages that were being scraped. The broker&#8217;s waiting connections live in memory, and it doesn&#8217;t store scraping jobs. Resuming interrupted work, recording partial results, and handling duplicates need to be designed into the scraper or a separate job queue.</p><h3><strong>Make health checks prove more</strong></h3><p>The current health check asks the browser&#8217;s <code>/json/version</code> endpoint for its WebSocket address. That proves CDP discovery is responding. It doesn&#8217;t prove the worker can create a context, navigate through the proxy, or return a usable page.</p><p>I&#8217;d add a small, controlled navigation check and record browser failures separately from proxy errors and target-site blocks. That would make the word &#8220;healthy&#8221; more useful when investigating a failed run.</p><h3><strong>Measure sustained use and useful output</strong></h3><p>The broker exposes snapshots through <code>/health</code>, <code>/workers</code>, and <code>/metrics</code>, but I want a history of queue waits, active contexts, memory, CPU usage, and navigation latency during a sustained workload. The Compose configuration sets shared memory for Chromium, but it doesn&#8217;t set total-memory or CPU limits, and its process limit is explicitly unlimited. I&#8217;d measure normal usage before choosing those limits and a policy for recycling workers.</p><p>I also want to test one, two, three, and four replicas against the same workload. Alongside successful pages per second and the 403 rate, I&#8217;d measure proxy bandwidth, retries, and cost per validated record. Four workers worked here, but the useful question is how much correct data they produce for the time and money spent.</p><h3><strong>Harden the containers</strong></h3><p>I want network-level egress controls that prevent traffic from bypassing the proxy adapter. I also want to replace the current <code>SYS_ADMIN</code> and <code>--no-sandbox</code> setup with a non-root browser user and Chromium sandboxing.</p><p>Those are the changes I&#8217;d make before giving the service more responsibility. The existing tests establish a working browser pool; the next round needs to establish how it behaves when clients fail, workloads compete, and the service stays busy for longer.</p><h2><strong>Summary</strong></h2><p>It&#8217;s already been useful for prototyping and building test scrapers. Having a browser service that&#8217;s always up, with decent stealth and proxy routing already configured, means I can connect a new script and start testing without rebuilding that setup for every project.</p><p>I now have four browser workers behind one endpoint, with connection limits, queueing, and worker discovery. In the CCL test, they handled 1,000 navigation attempts in about two and a half minutes and stayed healthy throughout. But 193 responses were challenge pages, so the throughput figure alone doesn&#8217;t tell me how useful the service is.</p><p>The next experiments are about getting more usable data from that capacity: allow scripts, control the request rate, check session consistency, and compare proxy providers and types. Alongside that, I need to test interrupted jobs, enforce context limits, and measure longer-running workloads. The number I want to improve is correct records per minute at a cost I&#8217;m willing to pay.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[I built a self-hosted browser scraping service. Here’s what it actually took.]]></title><description><![CDATA[stealth chrome on my server]]></description><link>https://johnwrooney.substack.com/p/i-built-a-self-hosted-browser-scraping</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/i-built-a-self-hosted-browser-scraping</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Sun, 24 May 2026 10:05:33 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%210W1c%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I wanted to understand how browser-based scraping actually works at the infrastructure level. Not just &#8220;use Playwright and point it at a URL&#8221; but what&#8217;s actually happening between your script and the page, what&#8217;s detectable and why, and what you&#8217;d need to build if you were doing this properly. The best way I know to understand a system is to build it.</p><p>This is what I learned.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21IMvT%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!IMvT!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 424w, https://substackcdn.com/image/fetch/$s_!IMvT!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 848w, https://substackcdn.com/image/fetch/$s_!IMvT!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 1272w, https://substackcdn.com/image/fetch/$s_!IMvT!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21IMvT%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png" width="1456" height="794" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/b9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:794,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:9586332,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/199050848?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!IMvT!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 424w, https://substackcdn.com/image/fetch/$s_!IMvT!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 848w, https://substackcdn.com/image/fetch/$s_!IMvT!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 1272w, https://substackcdn.com/image/fetch/$s_!IMvT!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb9431081-ce63-4631-9d52-35f2b06bd135_2816x1536.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>The hardware</h2><p>I&#8217;m running this on a HP ProDesk 405 G6 &#8212; a small form factor desktop with a Ryzen 4650G and 32GB RAM sitting on a <s>shelf</s> floor (!) at home. It&#8217;s not a powerful machine by any measure, but it&#8217;s x86_64 Linux, always on, and draws very little power. For a persistent browser service it&#8217;s more than enough.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%210W1c%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!0W1c!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 424w, https://substackcdn.com/image/fetch/$s_!0W1c!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 848w, https://substackcdn.com/image/fetch/$s_!0W1c!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 1272w, https://substackcdn.com/image/fetch/$s_!0W1c!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%210W1c%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic" width="1456" height="819" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/cabd1d7e-4830-449c-89b8-5d70817c62c8.heic&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:819,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:1540288,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/heic&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/199050848?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!0W1c!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 424w, https://substackcdn.com/image/fetch/$s_!0W1c!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 848w, https://substackcdn.com/image/fetch/$s_!0W1c!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 1272w, https://substackcdn.com/image/fetch/$s_!0W1c!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fcabd1d7e-4830-449c-89b8-5d70817c62c8.heic 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><h2>The control layer is not the browser</h2><p>The first thing worth understanding clearly: Playwright, Puppeteer, and Selenium are control libraries. They are not browsers. Playwright speaks CDP (Chrome DevTools Protocol) to whatever binary you point it at. The binary and the control library are completely separate concerns.</p><p>This matters for architecture. When you call <code>playwright.launch()</code>, it starts a browser process locally and connects to it. When you call <code>playwright.connect("ws://...")</code>, you&#8217;re connecting to a browser process running somewhere else &#8212; the control library stays local, the binary runs remotely. The browser itself has no idea which one you used. From the page&#8217;s perspective, the experience is identical either way.</p><pre><code><code># local &#8212; Playwright launches the browser itself
browser = await p.chromium.launch()

# remote &#8212; Playwright connects to a browser running elsewhere
browser = await p.chromium.connect("ws://192.168.1.100:3000")

# from here, the API is identical
context = await browser.new_context()
page = await context.new_page()
await page.goto("https://example.com")
</code></code></pre><p>The design decision this enables: separate the browser from the code that drives it. Run a persistent Playwright server on a dedicated machine, expose it as a WebSocket endpoint, and write scraping scripts that connect to it as clients. Multiple scripts, multiple projects, one browser service. That&#8217;s the architecture.</p><h2>Why the binary matters</h2><p>When a browser is put into automation mode &#8212; which happens the moment Playwright launches or connects to it &#8212; it&#8217;s supposed to advertise that fact. <code>navigator.webdriver = true</code> is in the W3C WebDriver spec. It&#8217;s not a side effect of Playwright doing something unusual. The spec requires it.</p><p>There are three layers at which automation can be detected:</p><p><strong>JavaScript-visible properties.</strong> <code>navigator.webdriver</code>, the shape of <code>navigator.plugins</code>, the presence or absence of <code>window.chrome</code>. These can be overridden in JS before the page loads, but the overrides are themselves detectable. The property descriptor is wrong. The prototype chain doesn&#8217;t match what a native property would look like. Antibot systems check for exactly this.</p><p><strong>Binary-level signals.</strong> Internal automation flags, the CDP debugging port being open on localhost. Pages can probe open ports via timing differences in connection failures &#8212; a connection to <code>127.0.0.1:9222</code> that&#8217;s refused immediately is different from one that times out.</p><p><strong>Network fingerprints.</strong> TLS handshake characteristics and HTTP/2 settings are determined by the browser&#8217;s network stack, compiled in. You cannot change them from JS, from headers, or from Playwright settings. They require recompiling the browser.</p><p>The patching hierarchy follows from this: JS override is detectable. Binary-level patch is not, because there&#8217;s nothing overriding anything &#8212; the signal was never emitted. Projects like CloakBrowser (Chromium) and Camoufox (Firefox) work at the source level precisely because that&#8217;s the only level where you can actually remove the signals rather than mask them.</p><p>The practical implication: for serious work, the choice of binary matters as much as the choice of control library. I went with CloakBrowser.</p><p>Getting it into the container means installing Playwright&#8217;s own Chromium first to create the versioned slot directories, then replacing the binaries:</p><pre><code><code># Install Playwright's Chromium to create the versioned slot directories
# then replace both with CloakBrowser
RUN npx playwright install chromium \
    &amp;&amp; SLOT=$(ls /root/.cache/ms-playwright/ | grep '^chromium-') \
    &amp;&amp; CHROME_DIR="/root/.cache/ms-playwright/$SLOT/chrome-linux64" \
    &amp;&amp; rm -rf "$CHROME_DIR" \
    &amp;&amp; mkdir -p "$CHROME_DIR" \
    &amp;&amp; cp -r /browsers/chromium/. "$CHROME_DIR/" \
    &amp;&amp; chmod +x "$CHROME_DIR/chrome" \
    &amp;&amp; HS_SLOT=$(ls /root/.cache/ms-playwright/ | grep '^chromium_headless_shell-') \
    &amp;&amp; HS_DIR="/root/.cache/ms-playwright/$HS_SLOT/chrome-headless-shell-linux64" \
    &amp;&amp; rm -rf "$HS_DIR" \
    &amp;&amp; mkdir -p "$HS_DIR" \
    &amp;&amp; cp -r /browsers/chromium/. "$HS_DIR/" \
    &amp;&amp; mv "$HS_DIR/chrome" "$HS_DIR/chrome-headless-shell" \
    &amp;&amp; chmod +x "$HS_DIR/chrome-headless-shell"
</code></code></pre><p>Why two slots? That&#8217;s the thing that took the longest to figure out &#8212; more on that below.</p><h2>Headed, not headless</h2><p>The default for automated browsers is headless &#8212; no display, no window, no rendering overhead. It&#8217;s also one of the more reliable detection signals. The <code>HeadlessChrome</code> string in the User-Agent, differences in canvas rendering, different screen property values, different WebGL capabilities. Headless browsers have a distinct fingerprint that antibot vendors have studied for years.</p><p>The alternative is running headed but without a physical display, using Xvfb (X Virtual Framebuffer). The browser renders into a virtual display. From the inside, nothing about it looks headless &#8212; because it isn&#8217;t. The rendering pipeline, the screen dimensions, the font stack all behave as they would on a real desktop.</p><p>The Dockerfile sets this up:</p><pre><code><code># Start Xvfb on display :99 with a 1920x1080 24-bit framebuffer
Xvfb :99 -screen 0 1920x1080x24 -ac

# Browser processes use that display
ENV DISPLAY=:99
</code></code></pre><p>The tradeoff is slightly higher resource usage. For a dedicated scraping machine it doesn&#8217;t matter. The decision is simple: headed via Xvfb is strictly better for evasion. The only reason to use headless is convenience.</p><p></p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21StiN%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!StiN!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 424w, https://substackcdn.com/image/fetch/$s_!StiN!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 848w, https://substackcdn.com/image/fetch/$s_!StiN!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 1272w, https://substackcdn.com/image/fetch/$s_!StiN!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21StiN%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png" width="1456" height="830" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:830,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:320685,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/199050848?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!StiN!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 424w, https://substackcdn.com/image/fetch/$s_!StiN!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 848w, https://substackcdn.com/image/fetch/$s_!StiN!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 1272w, https://substackcdn.com/image/fetch/$s_!StiN!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F84b0ed91-6268-4f59-9ac4-8308378c2e7d_1759x1003.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a></figure></div><p><em>The output of the code I generated to test it.</em></p><h2>Why supervisord inside Docker rather than a simpler setup</h2><p>Docker gives you portability and reproducibility. The browser setup is complex enough &#8212; Xvfb, Playwright server, patched binary, runtime dependencies, font packages &#8212; that managing it by hand on each machine doesn&#8217;t make sense. One Dockerfile, one build, same result anywhere.</p><p>The wrinkle is that a browser service is not a single process. It&#8217;s at minimum two: Xvfb and the Playwright server. Docker&#8217;s default assumption is one process per container. You need a process supervisor to manage startup ordering and handle crashes cleanly.</p><pre><code><code>[program:xvfb]
command=Xvfb :99 -screen 0 1920x1080x24 -ac
autorestart=true
priority=10          ; starts first

[program:playwright]
command=/start-playwright.sh
autorestart=true
priority=20          ; starts after xvfb
startsecs=3          ; give xvfb time to be ready
</code></code></pre><p>Supervisord handles startup ordering via priority values and restarts processes that crash. If Xvfb goes down, the Playwright server goes down with it &#8212; supervisord brings them back up in the right order.</p><p>One detail worth noting: getting environment variables to propagate correctly through supervisord into child processes is more fragile than it looks. The Playwright startup environment variables live in a shell script (<code>start-playwright.sh</code>) rather than supervisord&#8217;s <code>environment</code> directive, because the script is explicit about what&#8217;s set and reliable in a way the directive wasn&#8217;t.</p><h2>The thing that actually took the longest</h2><p>Not the architecture and not the detection evasion. The most time-consuming part was a single discovery: Playwright maintains two separate Chromium slot directories, which caught me out and made me see I wasn&#8217;t running headful and why I was getting 403s.</p><p><code>chromium-1223/chrome-linux64/</code> &#8212; the full Chromium binary.<br><code>chromium_headless_shell-1223/chrome-headless-shell-linux64/</code> &#8212; a stripped headless-only build.</p><p>When running headless, Playwright prefers the headless shell. I&#8217;d replaced the full Chromium slot with CloakBrowser. Playwright was ignoring it and launching the headless shell from the untouched slot. The version reported was 148 (Playwright&#8217;s bundled build), not 146 (CloakBrowser). Nothing in the error output explained this. I found it by watching <code>ps aux</code> during an active connection and reading the full binary path in the process arguments.</p><pre><code><code># what I expected to see
/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome

# what was actually running
/root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
</code></code></pre><p>Fix: replace both slots. The headless shell slot needs its binary renamed to <code>chrome-headless-shell</code> to match what Playwright expects. This is documented nowhere I could find.</p><h2>Concurrency: contexts not instances</h2><p>A single browser instance runs multiple isolated contexts. Each context has separate cookies, separate storage, separate session state &#8212; effectively separate browser profiles sharing one process. For most scraping work, one instance with a pool of contexts is the right model.</p><p>The async queue pattern works well. Workers pull URLs from the queue, create a context, scrape, close the context, move on. A 403 doesn&#8217;t block the worker &#8212; it requeues the URL with a backoff delay and immediately picks up the next job.</p><pre><code><code>async def worker(worker_id, browser, queue, results):
    while True:
        url, attempt = queue.get_nowait()
        result, should_retry = await scrape(browser, url)

        if result:
            results.append(result)
        elif should_retry and attempt &lt; MAX_RETRIES:
            await asyncio.sleep(2 * attempt)
            await queue.put((url, attempt + 1))

        queue.task_done()

# spin up N workers against the same browser instance
workers = [
    asyncio.create_task(worker(i, browser, queue, results))
    for i in range(CONCURRENCY)
]
await queue.join()
</code></code></pre><p>At 16 concurrent contexts on the ProDesk the machine copes fine. Proxy credentials go in <code>new_context()</code> per context &#8212; residential proxies with rotating IPs for each context and retries in my script, meant any bad IPs that failed or got blocked just retried with a new context and new IP.</p><p>A full connection looks like this:</p><pre><code><code>async with async_playwright() as p:
    browser = await p.chromium.connect("ws://192.168.1.100:3000")

    context = await browser.new_context(
        proxy={
            "server": "http://proxy-provider:port",
            "username": "your-username",
            "password": "your-password",
        },
        locale="en-GB",
        timezone_id="Europe/London",
        viewport={"width": 1920, "height": 1080},
        user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
    )

    # block resources you don't need &#8212; cuts proxy bandwidth significantly
    await context.route(
        "**/*",
        lambda route: route.abort()
        if route.request.resource_type in ("image", "media", "font", "stylesheet")
        else route.continue_()
    )

    page = await context.new_page()
    await page.goto("https://example.com", wait_until="domcontentloaded")
    # do your scraping
    await context.close()
</code></code></pre><h2>Scrapy integration</h2><p>The natural next step for me is wiring this into Scrapy via scrapy-playwright, which supports remote browser connections via <code>PLAYWRIGHT_CONNECT_URL</code>. The connection works but the problem is proxy auth &#8212; Chromium doesn&#8217;t correctly apply proxy credentials passed via context kwargs when scrapy-playwright manages contexts on a remote connection. The workaround is a local CONNECT proxy inside the container that accepts connections from the browser and injects upstream credentials without SSL interception. It works, but it&#8217;s a workaround for something that shouldn&#8217;t need one.</p><p><strong>TODO:</strong> Resolve this properly and document a clean Scrapy setup.</p><h2>What&#8217;s next</h2><p>One machine, one endpoint and one pool of contexts so the obvious scaling step is multiple machines. Docker Swarm or similar, multiple browser server nodes behind a single endpoint, context creation distributed across the pool.</p><p><strong>TODO:</strong> Multi-node browser distribution via Docker Swarm.</p><p>The repo is at https://github.com/jhnwr/browser-service. CloakBrowser needs to be sourced from their releases page &#8212; the binary isn&#8217;t something I can redistribute. The Dockerfile handles everything else, including replacing both Playwright Chromium slots with the patched build.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[I built an AI scaffolding system for Scrapy. Here’s what actually works and what’s still broken.]]></title><description><![CDATA[Searching for the perfect agent workflow]]></description><link>https://johnwrooney.substack.com/p/i-built-an-ai-scaffolding-system</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/i-built-an-ai-scaffolding-system</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Thu, 07 May 2026 09:34:07 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21l7BO%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>There&#8217;s a certain kind of task that is not hard but is annoying enough that you start wondering why you&#8217;re still doing it yourself. For me, that task was setting up Scrapy projects.</p><p>It&#8217;s not complicated. You create a folder, initialise the project, get your settings right, install the right packages, build out page objects, wire up your items. Every time. From scratch. And if you&#8217;re building against multiple domains &#8212; which is common &#8212; you do most of it again. Nothing about it requires thought. It&#8217;s just time, and repetition, and an open invitation for small mistakes to creep in.</p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21l7BO%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!l7BO!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 424w, https://substackcdn.com/image/fetch/$s_!l7BO!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 848w, https://substackcdn.com/image/fetch/$s_!l7BO!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 1272w, https://substackcdn.com/image/fetch/$s_!l7BO!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21l7BO%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png" width="668" height="645" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:645,&quot;width&quot;:668,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:61215,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:true,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/196749211?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!l7BO!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 424w, https://substackcdn.com/image/fetch/$s_!l7BO!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 848w, https://substackcdn.com/image/fetch/$s_!l7BO!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 1272w, https://substackcdn.com/image/fetch/$s_!l7BO!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F86001723-7d8a-4178-b8b2-4807a86770a7_668x645.png 1456w" sizes="100vw" fetchpriority="high"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">pretty straight forward</figcaption></figure></div><p></p><p>I wanted to see if AI could reliably do this, and do it efficiently.</p><p>That was about 6 months ago. Since then I&#8217;ve built several MCP servers (not good for project building), what feels like hundreds of CLI tools, a set of agent skills that scaffold full Scrapy projects in Claude, and I&#8217;m currently testing <a href="iframe.php?url=https%3A%2F%2Fopenspec.dev%2F">OpenSpec</a> &#8212; a project specification framework that guides the AI through the entire build from scratch. This post is what I&#8217;ve learned. Not a polished success story. More of an honest log of what worked, what broke, and what I&#8217;m still figuring out.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21KYNY%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!KYNY!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 424w, https://substackcdn.com/image/fetch/$s_!KYNY!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 848w, https://substackcdn.com/image/fetch/$s_!KYNY!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 1272w, https://substackcdn.com/image/fetch/$s_!KYNY!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 1456w" sizes="100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21KYNY%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png" width="1456" height="764" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:764,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:856189,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:false,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/196749211?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!KYNY!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 424w, https://substackcdn.com/image/fetch/$s_!KYNY!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 848w, https://substackcdn.com/image/fetch/$s_!KYNY!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 1272w, https://substackcdn.com/image/fetch/$s_!KYNY!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F3230fd24-339b-4ff1-8bc5-b54511506379_3827x2009.png 1456w" sizes="100vw"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">latest iteration</figcaption></figure></div><h2>The first attempt was a mess</h2><p>The obvious thing to try first is just... asking. Open a new Claude conversation, describe what you want, and see what comes back.</p><p>What came back was not great.</p><p>The problem is that the AI is working from documentation. It knows the Scrapy docs, it knows the scrapy-poet docs, and it can produce code that looks like it belongs in a tutorial. But it does not know how <em>I</em> build spiders. It doesn&#8217;t understand the page object pattern the way I use it. Specifically, it kept trying to inject page objects incorrectly &#8212; putting logic in the wrong place, using wrong imports, handling custom items and returns in ways that would never run cleanly. Every time I fed it back the error, it would try to fix it, make a different mistake, and produce something worse than what it started with.</p><p>That death spiral &#8212; error, self-correction attempt, deeper error &#8212; is the defining failure mode of unstructured AI code generation. And the fix is not better prompting. The fix is structure.</p><h2>Building the skill stack</h2><p>My first structured approach was to build a set of agent skills &#8212; small, specialised instruction sets that each handle one part of the build process. The idea was to break the project into logical units and give the AI a tight brief for each one.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21ziAL%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!ziAL!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 424w, https://substackcdn.com/image/fetch/$s_!ziAL!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 848w, https://substackcdn.com/image/fetch/$s_!ziAL!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 1272w, https://substackcdn.com/image/fetch/$s_!ziAL!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21ziAL%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png" width="1456" height="753" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:753,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:254256,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/196749211?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!ziAL!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 424w, https://substackcdn.com/image/fetch/$s_!ziAL!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 848w, https://substackcdn.com/image/fetch/$s_!ziAL!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 1272w, https://substackcdn.com/image/fetch/$s_!ziAL!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1e439e8f-1bb0-4fd0-b69b-209d06e9d7ac_1801x931.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">Way too many..</figcaption></figure></div><p>Getting the granularity right was harder than I expected.</p><p>A skill that tries to do too much loses its focus. Early on I tried combining project scaffolding with dependency installation and settings configuration all in one. It mostly worked, but the more decisions the skill had to make, the more it started cutting corners or making assumptions I hadn&#8217;t approved. A skill that tries to do too little produces too little &#8212; you end up with a lot of steps that don&#8217;t add up to a working project.</p><p>What I landed on was six skills:</p><ol><li><p>Create the project/install dependencies</p></li><li><p>Build the spider</p></li><li><p>Create the item</p></li><li><p>Create the base of the page objects</p></li><li><p>Generate the page object code</p></li><li><p>Wire everything together</p></li></ol><p>Each one maps to a stage I would naturally think about when building a spider by hand. That&#8217;s the test I&#8217;d apply to anyone trying to do something similar: if you can&#8217;t describe the skill as a step in your own mental model of the task, it&#8217;s probably either too big or misaligned with how the work actually flows.</p><p>One other thing I learned: <strong>some tasks belong in a script, not a skill.</strong> The clearest example is HTML cleanup. I have specific things I want to strip from the page before extraction &#8212; and I cannot let the AI decide which ones. If it gets that wrong, data disappears and nothing in the pipeline tells you why. So I wrote a Python script that handles it deterministically. The skill calls the script. The AI never makes that call itself. I tried doing the same thing for schema extraction at one point, pulling structured data out of JSON-LD and similar, but it ended up being one layer of abstraction too many. The script approach is only worth it when the decision about <em>what to do</em> is too important to leave to inference.</p><h2>Order mattered</h2><p>One of my early mistakes nearly convinced me the whole approach wasn&#8217;t going to work.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21Um3n%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!Um3n!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 424w, https://substackcdn.com/image/fetch/$s_!Um3n!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 848w, https://substackcdn.com/image/fetch/$s_!Um3n!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 1272w, https://substackcdn.com/image/fetch/$s_!Um3n!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21Um3n%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png" width="1456" height="857" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/e8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:857,&quot;width&quot;:1456,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:181138,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/196749211?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!Um3n!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 424w, https://substackcdn.com/image/fetch/$s_!Um3n!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 848w, https://substackcdn.com/image/fetch/$s_!Um3n!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 1272w, https://substackcdn.com/image/fetch/$s_!Um3n!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe8c9c09b-ca78-4f7c-9b2b-ff68519f2342_1768x1041.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">cart before horse</figcaption></figure></div><p>I hadn&#8217;t specified the order in which the project should be built. I&#8217;d listed everything that needed to happen, but not when. The AI tried to create page objects before the actual project existed. Which means it couldn&#8217;t use the Scrapy downloader, couldn&#8217;t get access to any pages, and had no project structure to put the page objects into. It tried to fix the errors, failed, and looped.</p><p>The fix was obvious in hindsight: if you&#8217;re encoding how to build something, encode the sequence. Not just the steps. Scrapy projects I build have a strict execution order &#8212; project first, then spider, then items, then page objects, then wiring. That order has to be explicit, because the AI will not infer it reliably from context alone.</p><p>For skills this was an orchestrator who controlled everyting, and for the spec driven development it was including the order in a simple manner.</p><h2>Where I&#8217;m heading: OpenSpec</h2><p>The skills approach works. But I&#8217;ve been testing something I think will work better.</p><div class="captioned-image-container"><figure><a class="image-link image2 is-viewable-img" target="_blank" href="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21nJDe%21%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png" data-component-name="Image2ToDOM"><div class="image2-inset"><picture><source type="image/webp" srcset="https://substackcdn.com/image/fetch/$s_!nJDe!,w_424,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 424w, https://substackcdn.com/image/fetch/$s_!nJDe!,w_848,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 848w, https://substackcdn.com/image/fetch/$s_!nJDe!,w_1272,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 1272w, https://substackcdn.com/image/fetch/$s_!nJDe!,w_1456,c_limit,f_webp,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 1456w" sizes="auto, 100vw"><img src="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21nJDe%21%2Cw_1456%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png" width="724" height="766" data-attrs="{&quot;src&quot;:&quot;https://substack-post-media.s3.amazonaws.com/public/images/8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png&quot;,&quot;srcNoWatermark&quot;:null,&quot;fullscreen&quot;:null,&quot;imageSize&quot;:null,&quot;height&quot;:766,&quot;width&quot;:724,&quot;resizeWidth&quot;:null,&quot;bytes&quot;:86753,&quot;alt&quot;:null,&quot;title&quot;:null,&quot;type&quot;:&quot;image/png&quot;,&quot;href&quot;:null,&quot;belowTheFold&quot;:true,&quot;topImage&quot;:false,&quot;internalRedirect&quot;:&quot;https://johnwrooney.substack.com/i/196749211?img=https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png&quot;,&quot;isProcessing&quot;:false,&quot;align&quot;:null,&quot;offset&quot;:false}" class="sizing-normal" alt="" srcset="https://substackcdn.com/image/fetch/$s_!nJDe!,w_424,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 424w, https://substackcdn.com/image/fetch/$s_!nJDe!,w_848,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 848w, https://substackcdn.com/image/fetch/$s_!nJDe!,w_1272,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 1272w, https://substackcdn.com/image/fetch/$s_!nJDe!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8df3489b-1b24-4f9d-9661-5afc887a376c_724x766.png 1456w" sizes="auto, 100vw" loading="lazy"></picture><div class="image-link-expand"><div class="pencraft pc-display-flex pc-gap-8 pc-reset"><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container restack-image buttonBase-GK1x3M"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke-width="1.5" stroke="var(--color-fg-primary)" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" class="icon-noB79L"><g><path d="M2.53001 7.81595C3.49179 4.73911 6.43281 2.5 9.91173 2.5C13.1684 2.5 15.9537 4.46214 17.0852 7.23684L17.6179 8.67647M17.6179 8.67647L18.5002 4.26471M17.6179 8.67647L13.6473 6.91176M17.4995 12.1841C16.5378 15.2609 13.5967 17.5 10.1178 17.5C6.86118 17.5 4.07589 15.5379 2.94432 12.7632L2.41165 11.3235M2.41165 11.3235L1.5293 15.7353M2.41165 11.3235L6.38224 13.0882"></path></g></svg></button><button tabindex="0" type="button" class="pencraft pc-reset pencraft icon-container view-image buttonBase-GK1x3M"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-maximize2 lucide-maximize-2 icon-noB79L"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" x2="14" y1="3" y2="10"></line><line x1="3" x2="10" y1="21" y2="14"></line></svg></button></div></div></div></a><figcaption class="image-caption">carefully crafted spec</figcaption></figure></div><p></p><p>The difference is conceptual. Skills handle specific tasks. OpenSpec is a framework for a set of <em>guidelines for how the whole project should be built</em> &#8212; closer to a reference document than a set of instructions. It encodes the gotchas, the order, the patterns I want, the decisions I&#8217;ve already made. When I say &#8220;do this, not that,&#8221; it goes in the spec. When I say &#8220;when you&#8217;re doing X, make sure Y comes first,&#8221; that goes in the spec. As models get better, I feel like specs will start to gain the edge even more.</p><p>The advantage over skills is that you can copy the spec into any new project folder and the AI has everything it needs to build the project in my way. You&#8217;re not threading together a chain of skill calls. You&#8217;re handing the AI a complete picture of what a good project looks like and asking it to produce one.</p><p>I&#8217;m still tweaking it. Different models interpret the same instructions differently &#8212; I&#8217;ve been testing with Claude Sonnet 4.6 and I haven&#8217;t tried GPT-5.4 or 5.5 yet, which would be an interesting comparison. Some rules that work perfectly with one model need to be more explicit with another. So the spec is not done; <strong>it&#8217;s a living document.</strong></p><h2>What the workflow looks like today</h2><p>Right now, starting a new Scrapy project looks like this:</p><ol><li><p>Create a new project folder</p></li><li><p><code>uv init</code>, then install the Scrapy packages I need</p></li><li><p>Copy the spec into the folder</p></li><li><p>Prompt: <em>&#8220;Create a Scrapy project with custom items. You decide the fields. Here&#8217;s the product URL, here&#8217;s the list page URL.&#8221;</em></p></li></ol><p>That&#8217;s it. Sometimes I&#8217;ll add: <em>&#8220;Also create an SQLite pipeline and save items to the database.&#8221;</em> SQLite is just cleaner than JSON files accumulating everywhere, and it&#8217;s easy to query and inspect.</p><p>The thing that surprised me most &#8212; and it&#8217;s a modest surprise &#8212; was how well adding new domains worked. When a second domain uses the same item schema, I can drop it into the existing project without rebuilding anything. The AI handles it cleanly, and that near-freebie was one of the genuine wins in the whole process. Running multiple spiders across multiple domains from a single project, without repeating the setup work, was the original goal.</p><h2>What nobody talks about</h2><p>There&#8217;s a lot of conversation right now about the perfect agentic workflow. How to chain tools together, how to get the AI to work more autonomously, how to reduce the amount of human intervention in any given process, and how to delegate to subagents.</p><p>What I&#8217;d push back on slightly is the idea that rigid rules are what you&#8217;re building toward.</p><p>Web scraping is not a stable target. Websites change. Anti-bot systems evolve. The structure of a page that worked last month might be completely different next month. A scaffold that&#8217;s too rigid will produce projects that need constant manual patching once they hit the real world. What you actually want is rules that are rigid in the right places &#8212; the architecture, the patterns, the execution order &#8212; and flexible in the places where the domain demands it.</p><p>That balance is harder to get right than just making the AI do more. And I don&#8217;t think enough people who are building AI workflows for technical work are talking about it honestly.</p><p>I&#8217;m going to keep iterating on the spec and will share a more detailed breakdown once it&#8217;s further along. If you&#8217;re building something similar &#8212; or tried it and hit different walls &#8212; I&#8217;d be interested to hear what you ran into.</p><div><hr></div><p><em>John Rooney is Head of Developer Advocacy at Zyte. He writes about web scraping, AI tooling, and developer workflows at <a href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2F">JHNWR on Substack</a>.</em></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Web Scraping has fundamentally changed (for) me]]></title><description><![CDATA[How I work now]]></description><link>https://johnwrooney.substack.com/p/web-scraping-has-fundamentally-changed</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/web-scraping-has-fundamentally-changed</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Wed, 22 Apr 2026 06:34:32 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21CeVb%21%2Cw_256%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3b9d3066-e051-4825-94fa-222fe45de501_1051x1051.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The world of extracting and parsing web data looked very different when I began this journey nearly 7 years ago. It&#8217;s taken me on a wild ride of learning, working with clients, employment and creation and I&#8217;m extremely grateful. It is how I earn my living and what I live and breathe each day.</p><p>However, the challenges I face now are vastly different to those I used to see, even just a few years ago. We are all aware of the changes that AI brings, and I feel it very acutely and this is represented in the work I do and what I spend my time thinking about and learning.</p><p>The difficulties in web scraping are generally the same, regardless of who you are and what you are trying to achieve - the solo developer who needs data for their non profit faces the same antibot protection, the same browser fingerprinting and techniques and the same parsing woes and maintanence issues that the largest of businesses do, except they see it on an entirely different scale.</p><p>At the lower end of the request count, in the early thousands or 10&#8217;s of thousands, these issues don&#8217;t represent as much of a time sink. It&#8217;s feasible to manually fix your scraping code when something changes, and update your stealth browser when it starts to get detected. </p><p>Yet when scaled both vertically to millions of requests, and horizontally, to hundreds of websites, these common issues are amplified to a point where it&#8217;s nearly impossible to manage without significant investment in expertise and time. And it&#8217;s this issue that I find more and more interesting to me and what problems I want to solve.<br><br>It makes sense really. The cutting edge AI tooling provides a whole world of possibilities to solving these issues and as a relatively new way of working, the enjoyment for making headway in this space is much more real than solving the same things over and over.</p><p>But bypassing antibot protection and working out how to scrape data from websites is still a real issue. And a very real one to those who are working on a small or very limited budget. But the thing for me is, and I know working for Zyte has coloured my perception to this somewhat and that needs to be taken into account, I just find that generally speaking this is a solved problem - let me explain.</p><p>As I&#8217;ve spent more time with people who are literal experts in antibot tech, and the more I&#8217;ve seen of how to get access the more that problem moves away from the space I want to work in.</p><p>Without wanting to downplay it, gaining access programmatically to websites to scrape data is generally easy, until you scale up. You either need a browser or you don&#8217;t. And if you do, you need smart session management paired with good IPs. It has become much more now a pay to play experience, with the infrastructure costs growing and the management time for these things increasing. </p><p>This isn&#8217;t to say there aren&#8217;t real and interesting problems to solve - of course there are, but it just isn&#8217;t a world I find myself in, nor one I want to exist in any more than the periphery of (no offense to my colleagues who keep our product going, they are incredible).<br><br>All of this only applies to a certain percentage of websites, and a lower percent than you might think. A LOT of sites are very easy to scrape, with minimal scraping techniques required outside of using the modern libraries, like a good TLS fingerprint friendly HTTP client. Take shopify for example, huge company and powers millions of online stores, yet you just need to add &#8220;/products.json?limit=250&amp;page=1&#8221; to the main shopify url and there you go, all the product data, neatly in JSON format. You can scrape a full store in 10 requests. </p><p>So where does that actually leave me and what challenges and issues I enjoy solving and creating things for now? It&#8217;s AI and Agentic, workflows and efficiency, effectiveness and simplicity. And some more linkedin buzzwords.</p><p>Jokes aside, I&#8217;ve been spending a lot of time optimising scraper creation, designing workflows for multiple usecases, creating CLI tools with specific uses to steamline agents with the goal to make web data more accessible to everyone, and accessible in whichever place or format that person wishes to work in.</p><p>This means I lean heavily on our web scraping API. But the more I do (and yes I can see the costs) it just makes sense to me to hand that part off to a SAAS/3rd party so I don&#8217;t need to be in that world.</p><p>I hope this resonates with you all in some way. I still scrape a lot, I live it everyday, I&#8217;ve just naturally shifted to a different area that one I used to be in. I barely look at what  websites I&#8217;m scraping anymore, as I never consider them an issue. I do look at my AI and my agents to see what is possible, how to work with them in the best way, and what tools I can create to make it easier and more efficient. </p><p>These are the sorts of things I&#8217;ll be writing about here. I will of course still write about the tehcnical side of scraping at scale, what is happening in the world of web data, but as and when I find somthing interesting to share.</p><p>See you soon. John</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><p></p>]]></content:encoded></item><item><title><![CDATA[Reverse Engineering CSS Sprite Price Obfuscation]]></title><description><![CDATA[Weird ways to hide data]]></description><link>https://johnwrooney.substack.com/p/reverse-engineering-css-sprite-price</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/reverse-engineering-css-sprite-price</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Thu, 05 Mar 2026 10:10:54 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21CeVb%21%2Cw_256%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3b9d3066-e051-4825-94fa-222fe45de501_1051x1051.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>What is a CSS sprite?</h2><p>A CSS sprite is an image that contains multiple smaller images combined into a single file. Instead of making separate HTTP requests for each individual image, you load one file and use CSS <code>background-position</code> to show only the portion you want.</p><p>The classic use case is UI icons. If you have twenty toolbar icons, you arrange them in a grid in a single PNG and reference them like this:</p><pre><code><code>.icon-search {
    background-image: url('/sprites/ui.png');
    background-position: -40px 0px;
    width: 20px;
    height: 20px;
}
</code></code></pre><p>The negative offset shifts the image left or up so that the correct icon appears within the element&#8217;s visible area. Change the offset and you get a different icon from the same file. One network request, many images. It was a significant performance optimisation in the era before HTTP/2, and it&#8217;s still widely used today.</p><p>The same technique works for anything graphical &#8212; including digits.</p><h2>Using sprites to hide data</h2><p>If you render numbers as sprite composites rather than text, they&#8217;re visible to humans but absent from the HTML as readable values. A price of $9.99 becomes three empty divs, each pointed at a different offset in a digit spritesheet. The browser paints the correct pixels. A scraper reading the DOM finds nothing useful.</p><p>This is the technique I came across recently on a marketplace site. What made it particularly interesting was an additional layer on top: the CSS class names were randomised on every page load, making any static mapping approach useless. This post is about how the system works and how to break it.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p><h2>How its constructed</h2><p>The technique has three moving parts.</p><p><strong>The spritesheet</strong> is a single image &#8212; in the case I examined, 600&#215;84px &#8212; containing all ten digits laid out at fixed coordinates, sometimes with the same digit appearing at multiple positions in different sizes or styles.</p><p><strong>The CSS</strong> maps obfuscated class names to <code>background-position</code> values pointing into that image. Each price digit is rendered by an empty element with three classes applied: one controlling layout, one setting the background image, one setting the position. Only the position class carries the information:</p><pre><code><code>.rJtKq { width: 7px; float: left; height: 15px; }
.kAsHe { background-image: url(//cdn.example.com/a8f3k2.jpg); }
.dEoRz { background-position: -280px -65px; }
</code></code></pre><p><strong>The data</strong> is stored in a JavaScript variable as a sequence of class names rather than a number. A price of $3.99 is encoded as:</p><pre><code><code>rJtKq kAsHe dEoRz;V;mZkRg kAsHe rJtKq;mZkRg kAsHe rJtKq
</code></code></pre><p>Semicolons separate digit positions. <code>V</code> is the decimal separator. Each segment contains the three classes for that digit position &#8212; only the one with a <code>background-position</code> rule matters for decoding.</p><p>What makes this resilient is that the class names are randomised on every page load. The class pointing to <code>-280px -65px</code> might be called <code>dEoRz</code> on one request and something completely different on the next. The spritesheet URL also appears to be hashed, suggesting it rotates too, though this is harder to confirm without observing multiple sessions. There is no static dictionary you can build and reuse.</p><h2>Breaking it</h2><p>The obfuscation rotates the names, but it cannot rotate the pixels. The digit glyphs are fixed &#8212; a 3 always looks like a 3. That is the seam.</p><p>The approach is:</p><ol><li><p>Parse the inline <code>&lt;style&gt;</code> block to extract the current session&#8217;s <code>class &#8594; background-position</code> mapping</p></li><li><p>Download the spritesheet</p></li><li><p>For each background position, crop the corresponding pixel cell and compute a fingerprint</p></li><li><p>Match the fingerprint against a reference dictionary of known digit glyphs</p></li></ol><p>The fingerprinting works because the digits are rendered at a fixed small size &#8212; 7&#215;15px in this case &#8212; in a consistent pixel font. You don&#8217;t need OCR. You binarise the cell (converting each pixel to dark or light, which strips out JPEG compression noise) and hash the result:</p><pre><code><code>def cell_hash(img_array, x, y, w=7, h=15):
    cell = img_array[y:y+h, x:x+w]
    binary = (cell.mean(axis=2) &lt; 128).astype(np.uint8)
    return hashlib.md5(binary.tobytes()).hexdigest()
</code></code></pre><p>The hash is stable across page loads even though the class names and spritesheet URL change. As long as the site doesn&#8217;t change its font rendering &#8212; which is a far more expensive operation than rotating class names &#8212; the reference dictionary stays valid indefinitely.</p><h2>Building the reference dictionary</h2><p>You build it once from a single page load where you have ground truth for at least a few digit glyphs.</p><p>The source of that ground truth is the site itself. Discounted listings expose plain-text prices in the JavaScript because the server needs to render a &#8220;was / now&#8221; price and the discount calculation happens client-side. Those raw numbers leak into the page data even when everything else is sprite-encoded. You take those known values, map their class names to pixel hashes, and use them to seed the reference.</p><p>In the page I examined, that gave me confirmed hashes for most digits immediately. The remaining ones I identified by reading the pixel patterns as ASCII art &#8212; at 7&#215;15px, the shapes are distinctive enough to read directly:</p><pre><code><code>.  .  .  #  #  .  .
.  .  #  .  .  #  .
.  .  .  .  .  #  .
.  .  .  .  #  .  .
.  .  .  .  .  #  .
.  .  .  .  .  #  .
.  .  #  #  #  .  .
</code></code></pre><p>One thing worth noting: some digits appeared at multiple positions in the spritesheet with slightly different rendering, producing different hashes for the same digit value. The reference dictionary needs to accommodate this &#8212; multiple hashes mapping to the same digit.</p><h2>Decoding</h2><p>Each page load requires exactly two HTTP requests: one for the HTML, one for the spritesheet. The decode process is then pure in-memory work:</p><ol><li><p>For each encoded price string, split on <code>;</code> to get digit segments</p></li><li><p>For each segment, find the class that has a <code>background-position</code> rule in this session&#8217;s CSS</p></li><li><p>Crop the pixel cell at those coordinates in the spritesheet</p></li><li><p>Hash it and look up in the reference dictionary</p></li><li><p>Assemble the digits around the decimal separator</p></li></ol><p>This runs in milliseconds per page regardless of how many listings it contains.</p><h2>A regex trap worth knowing about</h2><p>One bug that&#8217;s easy to miss: in this site, the entire product listing array is serialised as a single line of JavaScript. When I first wrote the extraction regex using <code>.*?</code> to locate <code>precoCss</code> fields, it worked in testing but silently broke on real data.</p><p>The lazy wildcard <code>.*?</code> still crosses object boundaries when everything is on one line. So a listing with no <code>precoCss</code> of its own &#8212; one of the plain-text discount listings &#8212; would match the <code>precoCss</code> from the <em>next</em> object in the array and get assigned the wrong price entirely. The failure was silent because a price was returned, just the wrong one.</p><p>The fix is to extract individual JSON objects before parsing fields within them:</p><pre><code><code>objects = re.findall(r'\{[^{}]+\}', html)
for obj in objects:
    price_css = re.search(r'"precoCss":"([^"]+)"', obj)
</code></code></pre><p><code>[^{}]</code> cannot cross object boundaries regardless of line structure. It&#8217;s a good habit whenever you&#8217;re using regex on concatenated or minified JSON.</p><h2>Why this is interesting</h2><p>Most anti-scraping measures create friction &#8212; they slow you down, require infrastructure to solve challenges, or force you to render JavaScript. This one is architecturally different. It accepts that the page will be fetched and instead attempts to make the data unreadable once you have it.</p><p>It&#8217;s also cheap to deploy. Generating randomised class names and a matching CSS block on each render is a small server-side operation. The spritesheet itself is static &#8212; only the filename rotates.</p><p>The weakness is that it relies entirely on keeping the class-to-position mapping secret, while necessarily including that mapping in the page it&#8217;s trying to protect. The CSS, the spritesheet, and the encoded data are all present in the response. Once you recognise the pattern and fingerprint the pixels rather than chasing the class names, the randomisation stops mattering.</p><p>I think whilst easy enough to decode, it serves it&#8217;s main purpose - putting a barrier up to stop the majority.</p><p>John.</p><p></p><div><hr></div><h2>Quick reference: how to handle this in the wild</h2><p>If you encounter a site using CSS sprite obfuscation for data, here is the end-to-end process.</p><p><strong>1. Confirm you&#8217;re dealing with sprites</strong></p><p>Inspect the element containing the data you want. If you see a row of empty or whitespace-only elements each with opaque class names, and the corresponding CSS maps those classes to <code>background-position</code> offsets into an image, it&#8217;s sprites. Check the page source for a JavaScript variable containing semicolon-separated class name sequences &#8212; that&#8217;s the encoded data.</p><p><strong>2. Find your ground truth</strong></p><p>Look for any place on the page where the real values leak as plain text. Discount or &#8220;was/now&#8221; prices are a common source, since client-side arithmetic requires the raw number. Aggregate stats (min, max, average) are another. You only need a handful of known values to seed the reference &#8212; ideally covering a spread of different digits.</p><p><strong>3. Build the reference dictionary</strong></p><p>For each known value, use your ground truth to identify which CSS class corresponds to each digit. Download the spritesheet, crop the pixel cell at each class&#8217;s <code>background-position</code> offset, binarise it, and store an MD5 hash of the result mapped to the digit. Do this once and save it to disk.</p><pre><code><code>def cell_hash(img_array, x, y, w, h):
    cell = img_array[y:y+h, x:x+w]
    binary = (cell.mean(axis=2) &lt; 128).astype(np.uint8)
    return hashlib.md5(binary.tobytes()).hexdigest()
</code></code></pre><p>If you can&#8217;t find enough ground truth, render the cells as ASCII art (dark pixel = <code>#</code>, light pixel = <code>.</code>) and read them directly. At small sizes with a pixel font, digits are identifiable by eye.</p><p><strong>4. Decode on each page load</strong></p><p>For every page you scrape: parse the inline <code>&lt;style&gt;</code> block to get the current session&#8217;s <code>class &#8594; background-position</code> mapping, download the spritesheet, hash each cell, and look up against your reference dictionary. The class names will be different every time &#8212; the pixel hashes won&#8217;t be.</p><p><strong>5. Watch for cross-object regex bleed</strong></p><p>If the encoded data is embedded in a minified or single-line JavaScript array, be careful with <code>.*?</code> in your extraction regex. It will cross object boundaries and silently assign the wrong data to the wrong record. Extract individual objects with <code>re.findall(r'\{[^{}]+\}', html)</code> first, then parse fields within each one.</p><p><strong>6. Know when to rebuild the reference</strong></p><p>The reference dictionary stays valid as long as the site&#8217;s font rendering doesn&#8217;t change. Class name rotation, URL hashing, and spritesheet filename changes don&#8217;t affect it &#8212; only a change to the actual glyph shapes would. If you start getting <code>?</code> characters in your output, re-examine a page with known values and check whether the pixel hashes have changed.</p>]]></content:encoded></item><item><title><![CDATA[My return and what to expect in 2026]]></title><description><![CDATA[A few changes but back to the content I enjoyed creating.]]></description><link>https://johnwrooney.substack.com/p/my-return-and-what-to-expect-in-2026</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/my-return-and-what-to-expect-in-2026</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Tue, 27 Jan 2026 07:20:43 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Fyoutube%2Fw_728%2Cc_limit%2FyUlq6AzfkII" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><em>I appreciate many of you would have signed up to my mailing list a while ago. This is my plan of what is to come, and your opportunity to decide if you&#8217;d like to stay subscribed.</em></p><div><hr></div><p>One year ago I decided that I needed a change in my day job. Using contacts in the web scraping industry I was able to line up a few interviews and eventually as of April last year started to work for Zyte full time as head of Devrel.</p><p>This was a dream role for me - I had the opportunity to create content full time, share more about data extract and learn from developers who did it full time as well as expand my own skillset into areas like conference speaking.</p><p>However, this meant I needed to take a step back from my own channel for a while. I wanted to focus on my job, and while I felt like I had achieved most of I wanted to from my own channel in my head I was always planning to return, I just didn&#8217;t know what it would look like.</p><p>So last week I released a new video for the first time in about 8 months - the longest gap since the channel was created in 2019.</p><div id="youtube2-yUlq6AzfkII" class="youtube-wrap" data-attrs="{&quot;videoId&quot;:&quot;yUlq6AzfkII&quot;,&quot;startTime&quot;:null,&quot;endTime&quot;:null}" data-component-name="Youtube2ToDOM"><div class="youtube-inner"><iframe src="iframe.php?url=https%3A%2F%2Fwww.youtube-nocookie.com%2Fembed%2FyUlq6AzfkII%3Frel%3D0%26amp%3Bautoplay%3D0%26amp%3Bshowinfo%3D0%26amp%3Benablejsapi%3D0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe></div></div><p>I am back creating, scraping data and sharing my methods, but with more knowledge, and more industry insights than before.</p><p>I&#8217;ve also moved my blog/mailing list to substack, and have chosen to put my written content here.</p><h2>So whats next?</h2><p>I have many video ideas, and have a few created already ready to go. Back to weekly cadence, back to creating and sharing, and a new feeling of no pressure for views/subs - and no more sponsors.</p><p>See you all next week.</p><p><strong>John</strong></p><p></p><div class="subscription-widget-wrap-editor" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe&quot;,&quot;language&quot;:&quot;en&quot;}" data-component-name="SubscribeWidgetToDOM"><div class="subscription-widget show-subscribe"><div class="preamble"><p class="cta-caption">Thanks for reading JHNWR! Subscribe for free to receive new posts and support my work.</p></div><form class="subscription-widget-subscribe"><input type="email" class="email-input" name="email" placeholder="Type your email&#8230;" tabindex="-1"><input type="submit" class="button primary" value="Subscribe"><div class="fake-input-wrapper"><div class="fake-input"></div><div class="fake-button"></div></div></form></div></div>]]></content:encoded></item><item><title><![CDATA[Coming soon]]></title><description><![CDATA[This is JHNWR.]]></description><link>https://johnwrooney.substack.com/p/coming-soon</link><guid isPermaLink="false">https://johnwrooney.substack.com/p/coming-soon</guid><dc:creator><![CDATA[John WR]]></dc:creator><pubDate>Sat, 24 Jan 2026 08:05:02 GMT</pubDate><enclosure url="iframe.php?url=https%3A%2F%2Fsubstackcdn.com%2Fimage%2Ffetch%2F%24s_%21CeVb%21%2Cw_256%2Cc_limit%2Cf_auto%2Cq_auto%3Agood%2Cfl_progressive%3Asteep%2Fhttps%253A%252F%252Fsubstack-post-media.s3.amazonaws.com%252Fpublic%252Fimages%252F3b9d3066-e051-4825-94fa-222fe45de501_1051x1051.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>This is JHNWR.</p><p class="button-wrapper" data-attrs="{&quot;url&quot;:&quot;https://johnwrooney.substack.com/subscribe?&quot;,&quot;text&quot;:&quot;Subscribe now&quot;,&quot;action&quot;:null,&quot;class&quot;:null}" data-component-name="ButtonCreateButton"><a class="button primary" href="iframe.php?url=https%3A%2F%2Fjohnwrooney.substack.com%2Fsubscribe%3F"><span>Subscribe now</span></a></p>]]></content:encoded></item></channel></rss>