<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rubén Jiménez</title>
    <description>The latest articles on DEV Community by Rubén Jiménez (@rubenayla).</description>
    <link>https://dev.to/rubenayla</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4078297%2Ffc081e5e-8895-4f0e-b3dc-439693f3b829.jpg</url>
      <title>DEV Community: Rubén Jiménez</title>
      <link>https://dev.to/rubenayla</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rubenayla"/>
    <language>en</language>
    <item>
      <title>Scraping 20,000 physical stores (most of it didn't work)</title>
      <dc:creator>Rubén Jiménez</dc:creator>
      <pubDate>Sat, 15 Aug 2026 18:39:55 +0000</pubDate>
      <link>https://dev.to/rubenayla/scraping-20000-physical-stores-most-of-it-didnt-work-44gm</link>
      <guid>https://dev.to/rubenayla/scraping-20000-physical-stores-most-of-it-didnt-work-44gm</guid>
      <description>&lt;p&gt;Last year I needed a specific door lock, a FAC 946RP, for a project at home. I knew which part I&lt;br&gt;
wanted. I could not find out which shop near me had one on the shelf, and that was the only thing I&lt;br&gt;
wanted to know.&lt;/p&gt;

&lt;p&gt;I got an Amazon listing with two-day delivery and a couple of dead forum threads. No shop. Finding&lt;br&gt;
the answer took forty minutes. The lock cost fifteen euros.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://partle.rubenayla.xyz/" rel="noopener noreferrer"&gt;Partle&lt;/a&gt; is my attempt at the missing answer. It is a search engine&lt;br&gt;
over the inventory of physical shops, and it currently holds over 1.3 million product listings&lt;br&gt;
across more than 19,000 store locations in 67 countries. Most of that data came from scraping.&lt;br&gt;
Most of what I learned came from the attempts that failed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Finding the shops is a separate problem from scraping them
&lt;/h2&gt;

&lt;p&gt;Before you can scrape a shop you have to know it exists, and shops are not in any database somebody&lt;br&gt;
publishes. They are in OpenStreetMap, so I query the Overpass API for hardware, DIY and garden&lt;br&gt;
retail, then filter out the chains to keep the independents.&lt;/p&gt;

&lt;p&gt;Querying a whole country does not scale, and it fails in a way that wastes a lot of time. The&lt;br&gt;
Netherlands returns about 1,800 elements in under a minute. Germany raised&lt;br&gt;
&lt;code&gt;Overpass query failed via both area and bbox&lt;/code&gt; after 1,093 seconds. France failed after 943. Italy,&lt;br&gt;
Spain and Poland were queued behind them, ready to do the same. While it is running you cannot tell&lt;br&gt;
it is failing, so that is forty-five minutes that looks like progress.&lt;/p&gt;

&lt;p&gt;The fix was querying subdivisions instead of countries, keyed by ISO 3166-2, the standard that gives&lt;br&gt;
each region inside a country a code like &lt;code&gt;DE-BY&lt;/code&gt; for Bavaria:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="n"&gt;area&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ISO3166-2"&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"DE-BY"&lt;/span&gt;&lt;span class="ss"&gt;][&lt;/span&gt;&lt;span class="n"&gt;admin_level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;query&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whole Germany&lt;/td&gt;
&lt;td&gt;failed after 1,093 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DE-HB (Bremen)&lt;/td&gt;
&lt;td&gt;26 elements in 6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DE-BY (Bavaria, the largest state)&lt;/td&gt;
&lt;td&gt;814 elements in 43 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I had been using bounding boxes before that, and switching also fixed a bug I had not connected to&lt;br&gt;
it. A bounding box is a rectangle in latitude and longitude, so it cannot express a border and&lt;br&gt;
quietly clips neighbouring countries into your results. It also cannot express Alaska, which crosses&lt;br&gt;
the antimeridian and so spans the full 360 degrees of longitude. An administrative area is the real&lt;br&gt;
polygon, so neither problem happens.&lt;/p&gt;

&lt;p&gt;Five whole-country entries became 89 subdivisions, and the work queue is now 144 regions. I pulled&lt;br&gt;
the codes from OpenStreetMap itself (&lt;code&gt;rel["ISO3166-2"~"^DE-"][admin_level=4]&lt;/code&gt;) rather than typing&lt;br&gt;
them out, because a mistyped subdivision code and a bounding box aimed at the wrong country produce&lt;br&gt;
equally plausible output, and the only thing that reveals either is reading the store names.&lt;/p&gt;
&lt;h2&gt;
  
  
  Most shops cannot be scraped at all
&lt;/h2&gt;

&lt;p&gt;Here is the number I wish I had measured in the first week. I took 205 real US stores from Illinois&lt;br&gt;
and Pennsylvania and asked how many had a website with a machine-readable catalogue on it. Three&lt;br&gt;
did. That is 1.5%.&lt;/p&gt;

&lt;p&gt;Anti-bot defences were not the obstacle. A neighbourhood hardware shop's website is five pages of&lt;br&gt;
contact details and opening hours, so there is nothing to extract, because the shop never put a&lt;br&gt;
catalogue online.&lt;/p&gt;

&lt;p&gt;What moved the number was writing extractors per e-commerce platform instead of per site. Shops do&lt;br&gt;
not build their own storefronts, they buy Shopify or Lightspeed or WooCommerce, and each of those&lt;br&gt;
has a predictable product endpoint. Same 205 stores, platform extractors instead of generic&lt;br&gt;
scraping: 21 stores, 10.2%. Seven times the yield.&lt;/p&gt;

&lt;p&gt;I wish I had measured that hit rate on a small sample before building for the whole set. A 1.5%&lt;br&gt;
yield and a 10.2% yield justify completely different amounts of engineering, and I spent weeks on&lt;br&gt;
the assumption rather than the measurement.&lt;/p&gt;
&lt;h2&gt;
  
  
  Anti-bot systems, ranked by how much they cost me
&lt;/h2&gt;

&lt;p&gt;The chains are the opposite problem. They have real catalogues with tens of thousands of products&lt;br&gt;
each, and varying appetite for being read by a robot. The five biggest catalogues on Partle are the&lt;br&gt;
Swiss sites of Conrad (electronics, 80,047 listings) and Hornbach (DIY, 33,839), the Swiss&lt;br&gt;
department store Manor (5,623), Decathlon (sports) and the Spanish online supplier Rationalstock&lt;br&gt;
(2,437).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No protection.&lt;/strong&gt; Several large retailers serve clean server-rendered HTML, where a plain Scrapy
spider works and keeps working. More of them do this than I expected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript rendering plus fingerprinting.&lt;/strong&gt; Handled with &lt;a href="https://camoufox.com/" rel="noopener noreferrer"&gt;Camoufox&lt;/a&gt;, an
anti-detect Firefox build. Two gotchas cost me an afternoon each: it advertises brotli in
&lt;code&gt;Accept-Encoding&lt;/code&gt; but mishandles the response, so I strip &lt;code&gt;br&lt;/code&gt; with a route intercept, and
&lt;code&gt;launch_server()&lt;/code&gt; mangles a null proxy config, so I build the launch options and call Node
directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Akamai Bot Manager.&lt;/strong&gt; Carrefour returns 403 to everything I have tried, Camoufox with randomised
fingerprints included. I stopped. Beating it means residential proxies, which means paying per
request to take data from someone who has made it very clear they do not want me to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every spider is also a maintenance liability. You write one, it works until the store redesigns its&lt;br&gt;
frontend, then you write it again. A shop that hands you a feed is worth ten that you scrape, and I&lt;br&gt;
should have started asking for feeds earlier.&lt;/p&gt;
&lt;h2&gt;
  
  
  The crawler does not run on the server
&lt;/h2&gt;

&lt;p&gt;The site runs on a Hetzner VPS with two cores and 3.7 GB of RAM, for about five euros a month, and&lt;br&gt;
it serves the frontend, the API and PostgreSQL. A parallel crawl on the same box spikes load and&lt;br&gt;
risks the kernel's out-of-memory killer taking production down.&lt;/p&gt;

&lt;p&gt;So the crawler runs on a Lenovo Legion Y540 in my garage, twenty workers, writing to the Hetzner&lt;br&gt;
database over an SSH tunnel. It is a residential connection with no uptime guarantee. That is fine&lt;br&gt;
for a batch job that can restart, and would be bad for anything serving live requests.&lt;/p&gt;

&lt;p&gt;Two crontab lines keep it going without me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20 3    * * *        cron_refresh.sh    # re-import every known store registry
40 1,7,13,19 * * *   cron_discover.sh   # drain whatever discovery work is pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The six-hourly discovery run is not really a schedule. Each run drains everything pending and exits,&lt;br&gt;
and a second copy exits immediately on the runner's own lock file (&lt;code&gt;flock&lt;/code&gt;). It is there so that a&lt;br&gt;
crashed run, a rebooted box, or a region I added to the queue last week gets picked up without&lt;br&gt;
anybody remembering it exists.&lt;/p&gt;

&lt;p&gt;I considered driving this with an LLM agent workflow and decided against it. An agent session ends&lt;br&gt;
when the terminal closes and it costs tokens per step, so it is not a scheduler, and pointing one at&lt;br&gt;
a multi-day crawl means paying a model to watch a shell loop. cron on an always-on box survives&lt;br&gt;
reboots and runs for years for free.&lt;/p&gt;
&lt;h2&gt;
  
  
  Server rendering, and why it was not enough
&lt;/h2&gt;

&lt;p&gt;Partle started as a Vite single-page app. It worked, but nothing could index it: someone searching&lt;br&gt;
"donde comprar cerrojo FAC Madrid" gets nothing from a client-rendered app, because there is no&lt;br&gt;
content in the HTML for a crawler to read.&lt;/p&gt;

&lt;p&gt;Migrating to Next.js App Router made every product (&lt;code&gt;/p/{id}-{slug}&lt;/code&gt;), store (&lt;code&gt;/s/{id}-{slug}&lt;/code&gt;) and&lt;br&gt;
profile page server-rendered, with JSON-LD structured data in the initial HTML: Product, Offer,&lt;br&gt;
LocalBusiness, GeoCoordinates.&lt;/p&gt;

&lt;p&gt;At the time of writing, Google has indexed zero of those pages. A new domain with no inbound links&lt;br&gt;
does not get crawled just because its markup is correct. Structured data did not get me traffic by&lt;br&gt;
itself, and I do not think it does for anyone. Server rendering is the first thing I would fix if I&lt;br&gt;
started again, and on its own it changed nothing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The MCP server is the part I care about
&lt;/h2&gt;

&lt;p&gt;Partle exposes a remote server for the Model Context Protocol (MCP), the standard assistants use to&lt;br&gt;
call external tools, over its Streamable HTTP transport at &lt;code&gt;https://partle.rubenayla.xyz/mcp/&lt;/code&gt;. It&lt;br&gt;
is published on the official registry as &lt;code&gt;xyz.rubenayla.partle/marketplace&lt;/code&gt;. Any MCP-capable&lt;br&gt;
assistant can connect and search products and stores. Reads need no auth; the public API is&lt;br&gt;
rate-limited to 100 requests per hour per IP, which is plenty for conversational use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"partle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://partle.rubenayla.xyz/mcp/"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publishing to the registry uses domain verification: generate an Ed25519 key pair, serve the public&lt;br&gt;
key at &lt;code&gt;/.well-known/mcp-registry-auth&lt;/code&gt;, publish a &lt;code&gt;server.json&lt;/code&gt; describing the server. It is&lt;br&gt;
straightforward and thinly documented. One warning from my own error log, since it cost me the&lt;br&gt;
ability to publish updates: keep the private key. I generated mine in &lt;code&gt;/tmp&lt;/code&gt;, authenticated, and&lt;br&gt;
deleted it as cleanup. The public half is still deployed and the private half is gone.&lt;/p&gt;

&lt;p&gt;I care about this more than about the website because an assistant talking to someone renovating a&lt;br&gt;
bathroom already has the context of the job. What is missing is a structured way to ask what is on a&lt;br&gt;
shelf nearby, which is a protocol problem, not a web design one. Cross-language search already works&lt;br&gt;
that way through a &lt;code&gt;semantic=true&lt;/code&gt; flag, so asking for a "drill" also matches "taladro" and&lt;br&gt;
"Bohrmaschine" — which matters when the catalogue spans 67 countries and every listing is in the&lt;br&gt;
language of the shop that wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not there yet
&lt;/h2&gt;

&lt;p&gt;Coverage is uneven, and I would rather say so than have you find out by searching. A handful of&lt;br&gt;
large chains and online catalogues account for most of the product listings. Most of those&lt;br&gt;
store locations are independent shops mapped in OpenStreetMap with no inventory listed, for the&lt;br&gt;
reason above: there is no catalogue online to read.&lt;/p&gt;

&lt;p&gt;The rest of the stack, briefly: FastAPI and SQLAlchemy behind nginx, packaged with uv (a Python&lt;br&gt;
package manager), a Next.js frontend in TypeScript, and one PostgreSQL instance doing both storage&lt;br&gt;
and full-text search, which has been enough at this size.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://partle.rubenayla.xyz/" rel="noopener noreferrer"&gt;partle.rubenayla.xyz&lt;/a&gt;, the API needs no key&lt;br&gt;
(&lt;a href="https://partle.rubenayla.xyz/api/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt;), and if you have an MCP-capable assistant you can&lt;br&gt;
point it at &lt;code&gt;https://partle.rubenayla.xyz/mcp/&lt;/code&gt; and search the same way I originally wanted to.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>scraping</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
