<?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: DPJ Wholesale</title>
    <description>The latest articles on DEV Community by DPJ Wholesale (@dpjwholesale).</description>
    <link>https://dev.to/dpjwholesale</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%2F4168346%2Fed73e8bc-cea4-4759-86fc-b16573a239ae.png</url>
      <title>DEV Community: DPJ Wholesale</title>
      <link>https://dev.to/dpjwholesale</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dpjwholesale"/>
    <language>en</language>
    <item>
      <title>Re-collecting 65,000 product pages without hammering the source</title>
      <dc:creator>DPJ Wholesale</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:46:52 +0000</pubDate>
      <link>https://dev.to/dpjwholesale/re-collecting-65000-product-pages-without-hammering-the-source-5d1e</link>
      <guid>https://dev.to/dpjwholesale/re-collecting-65000-product-pages-without-hammering-the-source-5d1e</guid>
      <description>&lt;p&gt;We keep a wholesale catalog of roughly 65,000 consumer SKUs. Prices move, stock moves, images get replaced, and listings disappear. The catalog is only useful if it stays close to reality, which means re-collecting — continuously, and without becoming the kind of scraper everyone blocks.&lt;/p&gt;

&lt;p&gt;These are the parts that took the most iterations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full re-collection does not scale, and it is not even the right shape
&lt;/h2&gt;

&lt;p&gt;The first version walked every SKU on a schedule. It worked at 5,000 rows and fell apart at 65,000: the run took longer than the interval between runs, so the next one started before the last finished, and the source saw a constant, obviously robotic load.&lt;/p&gt;

&lt;p&gt;The fix was not "run it faster". It was to stop treating the catalog as one collection job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split by change rate, not by category
&lt;/h2&gt;

&lt;p&gt;SKUs do not change at the same speed. We bucket by observed volatility instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hot&lt;/strong&gt; — changed in the last N runs. Re-checked often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm&lt;/strong&gt; — changed sometime, stable lately. Re-checked on a slow rotation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold&lt;/strong&gt; — never changed since first collection. Re-checked rarely, and never ahead of the warm set.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The buckets are recomputed from history, not hand-assigned. A SKU graduates to hot the moment it changes, and decays back down after a run of no-ops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only store what actually changed
&lt;/h2&gt;

&lt;p&gt;Every collected record is compared field by field against the last stored version, and we keep a per-field change history rather than overwriting. Two things fall out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Re-collection becomes cheap to reason about — "did anything change" is a query, not a diff of two scrapes.&lt;/li&gt;
&lt;li&gt;The change history is itself a signal. A price that moves weekly is a different product to buy than one that has not moved in a year.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Be boring to the source
&lt;/h2&gt;

&lt;p&gt;Concretely, this is what keeps us unblocked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Respect the crawl delay, and back off on the first 429 or 503 rather than the third.&lt;/li&gt;
&lt;li&gt;Serialise per host. Parallelism across hosts is fine; parallelism inside one host is how you get rate-limited.&lt;/li&gt;
&lt;li&gt;Send a real, stable User-Agent with a contact URL. Not a browser string pretending to be Chrome.&lt;/li&gt;
&lt;li&gt;Cache the sitemap and category listings instead of re-deriving the URL set on every pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most reliable anti-blocking measure we found is simply not being interesting enough to block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries need to distinguish "gone" from "we failed"
&lt;/h2&gt;

&lt;p&gt;This one caused real damage. Our first retry logic treated every non-200 as a failure and retried, so a genuine 404 got retried forever while a transient network error was retried three times and then recorded as gone.&lt;/p&gt;

&lt;p&gt;We now separate them explicitly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permanent&lt;/strong&gt; — 404, 410. Record as removed, stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transient&lt;/strong&gt; — timeouts, connection resets, 5xx. Retry with backoff, and if it still fails, record &lt;em&gt;nothing&lt;/em&gt; rather than recording a deletion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last rule matters more than it looks. A network blip must never be allowed to delete a row, because the next run will not know the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency beats checkpointing
&lt;/h2&gt;

&lt;p&gt;We tried checkpoint files and kept losing runs to them. What worked instead was making every write idempotent — a re-run of the same batch produces the same stored state — and letting the bucket assignment decide what to pick up next. With idempotent writes, a crashed run is just a run you start again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do differently
&lt;/h2&gt;

&lt;p&gt;Start with the change history, not the current snapshot. We built the snapshot first and bolted change tracking on later, which meant re-collecting data we had already seen in order to reconstruct history we could have been keeping all along.&lt;/p&gt;




&lt;p&gt;The catalog this pipeline produces is at &lt;a href="https://www.dpjwholesale.com/" rel="noopener noreferrer"&gt;dpjwholesale.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Scoring demand against competition across 65,000 SKUs</title>
      <dc:creator>DPJ Wholesale</dc:creator>
      <pubDate>Tue, 13 Oct 2026 12:32:44 +0000</pubDate>
      <link>https://dev.to/dpjwholesale/scoring-demand-against-competition-across-65000-skus-2e6e</link>
      <guid>https://dev.to/dpjwholesale/scoring-demand-against-competition-across-65000-skus-2e6e</guid>
      <description>&lt;p&gt;We run a wholesale catalog covering roughly 65,000 consumer SKUs. The question we kept coming back to is not "what sells" but "what sells &lt;em&gt;and&lt;/em&gt; is not already saturated" — and those are different rankings.&lt;/p&gt;

&lt;p&gt;This is how we ended up scoring demand against competition per SKU.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version, and why it failed
&lt;/h2&gt;

&lt;p&gt;The first attempt ranked SKUs by order volume. It produced a useful-looking list and a bad decision: almost everything at the top was a commodity with dozens of equivalent listings, so buying deeper on it meant competing on price alone.&lt;/p&gt;

&lt;p&gt;Volume without a competition term is not an opportunity score. It is a popularity chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Splitting the problem in two
&lt;/h2&gt;

&lt;p&gt;We stopped treating "demand" as one number and split it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Demand&lt;/strong&gt; — order frequency plus reorder rate, where reorder rate is the share of accounts that bought the same SKU a second time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Competition&lt;/strong&gt; — how many equivalent listings exist for the same functional item, normalised by category.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The score is the ratio, not the sum. A high-demand SKU in a crowded category scores like a mediocre SKU in a thin one, which matches what actually happens when you try to sell it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reorder rate is the part people skip
&lt;/h2&gt;

&lt;p&gt;Bestseller rank tells you what sold once. Reorder rate tells you what a buyer chose again with their own money, which is the only signal that survives a season change, a price move, or a competitor listing the same item cheaper.&lt;/p&gt;

&lt;p&gt;In our data, reorder rate is a stronger predictor of whether a line survives two seasons than unit cost is. It is also the field most catalogs do not publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two properties that beat every other signal
&lt;/h2&gt;

&lt;p&gt;After enough iterations, two SKU properties kept outperforming everything else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Size consistency.&lt;/strong&gt; If the second unit does not fit the first, the customer does not come back. Storage totes and replacement lids dominate our home category for exactly this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A consumable attached.&lt;/strong&gt; Parts and consumables reorder without persuasion — compost accelerator, tote lids, crate mats, ear pads, replacement tent stakes. A line with a consumable attached keeps reordering even when the main unit does not.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What we do with the score
&lt;/h2&gt;

&lt;p&gt;Three buckets, treated differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proven reorders&lt;/strong&gt; — the same account bought it more than once. Buy deep; the repeat is the forecast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-time SKUs&lt;/strong&gt; — buy narrow and wide: one case per variant, many variants. That buys information, not inventory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seasonal and fashion&lt;/strong&gt; — a bet with a deadline. The price after the season boundary is the real price.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The stop rule
&lt;/h2&gt;

&lt;p&gt;Stop a SKU when its reorder rate falls below the cost of the shelf space it occupies — not when it stops selling entirely. A slow mover that never reorders is consuming capacity a proven repeat line would use better.&lt;/p&gt;




&lt;p&gt;The catalog and the scoring output are public at &lt;a href="https://www.dpjwholesale.com/" rel="noopener noreferrer"&gt;dpjwholesale.com&lt;/a&gt; if you want to poke at the numbers.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>data</category>
    </item>
  </channel>
</rss>
