<?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: kavela</title>
    <description>The latest articles on DEV Community by kavela (@kavelaltd).</description>
    <link>https://dev.to/kavelaltd</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3841559%2Fe6231d4d-9c33-45b2-a533-74abcfbc6048.png</url>
      <title>DEV Community: kavela</title>
      <link>https://dev.to/kavelaltd</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kavelaltd"/>
    <language>en</language>
    <item>
      <title>Launching PlatformTrack: a weekly-synced streaming availability index for 17 platforms x 57 countries</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Thu, 28 May 2026 21:06:31 +0000</pubDate>
      <link>https://dev.to/kavelaltd/launching-platformtrack-a-weekly-synced-streaming-availability-index-for-17-platforms-x-57-2llk</link>
      <guid>https://dev.to/kavelaltd/launching-platformtrack-a-weekly-synced-streaming-availability-index-for-17-platforms-x-57-2llk</guid>
      <description>&lt;p&gt;Streaming availability is a deceptively simple question. &lt;em&gt;Does Netflix carry The Bear in Turkey? How many movies does Disney+ offer in Saudi Arabia versus Germany? Which country has the deepest streaming catalog in the world?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answers are not where you'd expect. The deepest Netflix catalog in the world isn't in the United States — it's in &lt;strong&gt;the Netherlands&lt;/strong&gt; (9,871 titles), followed by Slovakia, Bulgaria, Spain and Portugal. The US ranks 35th out of 56 markets we track. That's the kind of finding that's hard to surface unless someone is collecting the data systematically, week after week.&lt;/p&gt;

&lt;p&gt;We just launched &lt;a href="https://platformtrack.com/" rel="noopener noreferrer"&gt;&lt;strong&gt;PlatformTrack&lt;/strong&gt;&lt;/a&gt; to do exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;PlatformTrack is a public index of streaming-platform catalog presence. For every &lt;code&gt;(platform, country)&lt;/code&gt; pair we monitor, it records:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total catalog size (movies + series)&lt;/li&gt;
&lt;li&gt;Movie/series split&lt;/li&gt;
&lt;li&gt;Popularity-ranked sample of titles&lt;/li&gt;
&lt;li&gt;A weekly snapshot for historical trend analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Coverage at launch: &lt;strong&gt;17 platforms × 57 countries = 969 pairs&lt;/strong&gt;, refreshed every Monday at 03:00 UTC.&lt;/p&gt;

&lt;p&gt;The site surfaces it across a few page types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/country/{slug}/&lt;/code&gt; — which platforms serve this country and how deep each catalog is&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/platform/{slug}/&lt;/code&gt; — which countries this platform reaches and how its catalog scales&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/platform/{slug}/{country}/&lt;/code&gt; — the cross-section: catalog composition, top titles, snapshot trend&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/compare/{a}-vs-{b}/{country}/&lt;/code&gt; — head-to-head platform comparison in a market&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/ranking/&lt;/code&gt; and &lt;code&gt;/ranking/platforms/&lt;/code&gt; — global leaderboards&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The interesting build choices were on the data side.&lt;/p&gt;

&lt;h3&gt;
  
  
  Source: TMDB Watch Providers
&lt;/h3&gt;

&lt;p&gt;All catalog measurements come from TMDB's &lt;a href="https://developer.themoviedb.org/reference/movie-watch-providers" rel="noopener noreferrer"&gt;Watch Providers&lt;/a&gt; feed, which is sourced upstream from JustWatch. The canonical query is the Discover endpoint with a &lt;code&gt;watch_region&lt;/code&gt; and &lt;code&gt;with_watch_providers&lt;/code&gt; filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /3/discover/{movie|tv}
    ?watch_region={ISO}
    &amp;amp;with_watch_providers={provider_id}
    &amp;amp;sort_by=popularity.desc
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first page returns &lt;code&gt;total_results&lt;/code&gt;, which is the catalog size. Subsequent pages are used to harvest the popularity-ranked sample. We don't walk every title; we record the total and snapshot the top ~40 per pair for downstream features (exclusivity calc, trending bands, "top titles" lists).&lt;/p&gt;

&lt;p&gt;At 17 platforms × 57 countries × 2 media types × ~2 pages, that's ~3,800 API calls per sync. At 4 req/sec to stay under TMDB's rate limit, the full sync runs in about 17 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: 5-table schema
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;pt_platforms&lt;/span&gt;     &lt;span class="c1"&gt;-- tmdb_provider_id, slug, name, logo_url, platform_type&lt;/span&gt;
&lt;span class="n"&gt;pt_countries&lt;/span&gt;     &lt;span class="c1"&gt;-- iso_code, slug, name, region&lt;/span&gt;
&lt;span class="n"&gt;pt_availability&lt;/span&gt;  &lt;span class="c1"&gt;-- platform_id, country_id, title_count, movie_count, series_count,&lt;/span&gt;
                 &lt;span class="c1"&gt;-- exclusive_count, last_synced&lt;/span&gt;
&lt;span class="n"&gt;pt_top_titles&lt;/span&gt;    &lt;span class="c1"&gt;-- the popularity-ranked sample per pair&lt;/span&gt;
&lt;span class="n"&gt;pt_snapshots&lt;/span&gt;     &lt;span class="c1"&gt;-- the append-only history table for time-series queries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The split between &lt;code&gt;pt_availability&lt;/code&gt; (live, overwritten weekly) and &lt;code&gt;pt_snapshots&lt;/code&gt; (append-only) is what makes historical analysis cheap. Country pages show the current state; deep-link analyses pull from snapshots.&lt;/p&gt;

&lt;h3&gt;
  
  
  Frontend: WordPress + virtual URLs
&lt;/h3&gt;

&lt;p&gt;We needed pSEO at scale (hundreds of programmatic pages) without the overhead of real WP posts for each. The plugin (&lt;code&gt;pt-engine&lt;/code&gt;) registers rewrite rules that route URLs like &lt;code&gt;/platform/netflix/turkey/&lt;/code&gt; to virtual templates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_rewrite_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'^platform/([^/]+)/([^/]+)/?$'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'index.php?pt_page_type=platform_country'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;
    &lt;span class="s1"&gt;'&amp;amp;pt_platform=$matches[1]&amp;amp;pt_country=$matches[2]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'top'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;template_include&lt;/code&gt; filter maps &lt;code&gt;pt_page_type&lt;/code&gt; to a PHP template file in the plugin. No posts in &lt;code&gt;wp_posts&lt;/code&gt;, no editing overhead, instant updates when the data table changes.&lt;/p&gt;

&lt;p&gt;The trade-off: WP's native sitemap generator doesn't see these URLs, so the plugin generates its own at &lt;code&gt;/sitemap-pt-{index,countries,platforms,cross}.xml&lt;/code&gt; from the data tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sync pipeline
&lt;/h3&gt;

&lt;p&gt;The weekly sync is a Python script on the same host as MySQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;countries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;movie_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;movie_sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;discover_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;movie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmdb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;tv_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;tv_sample&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;discover_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tmdb_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;upsert_availability&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;movie_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tv_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;replace_top_titles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;movie_sample&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tv_sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;movie_total&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tv_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;movie_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tv_total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# per-country, so a mid-run failure leaves coherent state
&lt;/span&gt;&lt;span class="nf"&gt;compute_exclusives&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the sync, an IndexNow ping submits the changed URLs to Bing and Yandex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some findings from launch week
&lt;/h2&gt;

&lt;p&gt;A few patterns emerged immediately from the first full sync that are worth reporting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Europe splits into three tiers.&lt;/strong&gt; The UK runs 141,533 title-availability records across 13 platforms. The Western Balkans run 11,000–17,000 across 5 platforms. The middle 15 markets cluster between 35,000 and 55,000. It's a sharp three-step distribution, not a smooth curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GCC bundle is real.&lt;/strong&gt; Bahrain, Kuwait, Qatar and Oman cluster inside a 290-title band — essentially identical supply, despite being separately licensed markets on paper. The UAE and Saudi Arabia anchor the deep end (17,601 and 15,797 titles respectively).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netflix's catalog is mid-pack in the US.&lt;/strong&gt; This was the most counter-intuitive finding. The US Netflix catalog is smaller than every European mid-tier market and most of MENA. The deepest Netflix catalogs sit in markets where Netflix is the only major streaming option, because third-party content hasn't been pulled into competing services.&lt;/p&gt;

&lt;p&gt;We wrote these up in three research briefs on the launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://platformtrack.com/research/streaming-in-the-gcc-2026/" rel="noopener noreferrer"&gt;Streaming in the GCC: catalog depth in 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platformtrack.com/research/streaming-fragmentation-europe-2026/" rel="noopener noreferrer"&gt;Streaming fragmentation in Europe&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platformtrack.com/research/netflix-global-footprint-2026/" rel="noopener noreferrer"&gt;Netflix global footprint&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is positioned for journalists, academics and analysts — every figure is citation-ready with an "How to cite" block and CC-BY 4.0 licensing on the aggregate data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Two things are queued for the next few weeks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Historical trend reports&lt;/strong&gt; — once the snapshot table has eight weeks of data, country-level catalog growth/decline becomes a publishable beat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More platforms&lt;/strong&gt; — Hulu, Peacock and Rakuten Viki are on the shortlist. The blocker is TMDB watch-provider coverage, not our pipeline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you build streaming products, do regional licensing analysis, or just want to know whether your subscription is actually larger or smaller in your country than in your friend's: &lt;a href="https://platformtrack.com/" rel="noopener noreferrer"&gt;platformtrack.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The underlying availability data is also exposed via the &lt;a href="https://rapidapi.com/streamwatchhub-streamwatchhub-default/api/streamwatchhub-movies-and-series" rel="noopener noreferrer"&gt;StreamWatchHub API on RapidAPI&lt;/a&gt; if you want programmatic access — per-title lookups and bulk endpoints.&lt;/p&gt;

&lt;p&gt;Methodology, data dictionary and full caveats in the &lt;a href="https://platformtrack.com/research/" rel="noopener noreferrer"&gt;Research&lt;/a&gt; section.&lt;/p&gt;

&lt;p&gt;Questions, corrections or custom data requests: &lt;code&gt;research@platformtrack.com&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>api</category>
      <category>datascience</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>Build a "Where to Watch" feature in 50 lines with the StreamWatchHub API</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Sat, 23 May 2026 17:47:14 +0000</pubDate>
      <link>https://dev.to/kavelaltd/build-a-where-to-watch-feature-in-50-lines-with-the-streamwatchhub-api-5aob</link>
      <guid>https://dev.to/kavelaltd/build-a-where-to-watch-feature-in-50-lines-with-the-streamwatchhub-api-5aob</guid>
      <description>&lt;p&gt;Most teams shipping a "where can I watch X?" feature run into the same set of problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JustWatch does not offer a public API.&lt;/li&gt;
&lt;li&gt;TMDB watch-providers covers the major markets only, without rent prices or deep links.&lt;/li&gt;
&lt;li&gt;Live sports availability requires a separate broadcaster feed.&lt;/li&gt;
&lt;li&gt;Platform names, regional catalogs, and deep-link formats all need to be reconciled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;StreamWatchHub&lt;/strong&gt; consolidates this into a single REST endpoint and one consistent schema, covering every streaming service plus live football broadcasters across 10 countries. The API is now live on &lt;a href="https://rapidapi.com/kavela-kavela-default/api/streamwatchhub-movies-series/pricing" rel="noopener noreferrer"&gt;RapidAPI&lt;/a&gt; with a free tier (1,000 calls/month), so it can be evaluated without a payment method.&lt;/p&gt;

&lt;p&gt;This post walks through wiring a production-ready "where to watch" feature in roughly 50 lines of Node.js plus a small React component.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the API returns
&lt;/h2&gt;

&lt;p&gt;A single call per title, with the monetization type flagged on every offer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://streamwatchhub.p.rapidapi.com/v1/search?q=the+bear&amp;amp;country=US"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-RapidAPI-Key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-RapidAPI-Host: streamwatchhub.p.rapidapi.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response (trimmed):&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;"results"&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;"tmdb_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;136315&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The Bear"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"series"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2022&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"offers"&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;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hulu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flatrate"&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://hulu.com/series/..."&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="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disney"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"flatrate"&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://disneyplus.com/..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apple"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$2.99"&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://tv.apple.com/..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;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;No per-platform SDKs, no scraping fallbacks. The same response shape applies to movies, series, and football matches — sports responses substitute &lt;code&gt;kickoff_at&lt;/code&gt; and &lt;code&gt;broadcasters[]&lt;/code&gt; for &lt;code&gt;offers[]&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Sign up and obtain an API key
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Visit the &lt;a href="https://rapidapi.com/kavela-kavela-default/api/streamwatchhub-movies-series/pricing" rel="noopener noreferrer"&gt;RapidAPI listing&lt;/a&gt; and select the free plan.&lt;/li&gt;
&lt;li&gt;Copy the issued RapidAPI key and export it: &lt;code&gt;export RAPIDAPI_KEY=...&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  2. A minimal Node client
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// swh.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HOST&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;streamwatchhub.p.rapidapi.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/v1`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;whereToWatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/search`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;country&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-RapidAPI-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RAPIDAPI_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-RapidAPI-Host&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SWH &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No client library is required. The full surface is seven GET endpoints (&lt;code&gt;/search&lt;/code&gt;, &lt;code&gt;/title/{id}/offers&lt;/code&gt;, &lt;code&gt;/series/{id}/season/{n}/offers&lt;/code&gt;, &lt;code&gt;/match/{id}/broadcasters&lt;/code&gt;, &lt;code&gt;/fixtures&lt;/code&gt;, &lt;code&gt;/platforms&lt;/code&gt;, &lt;code&gt;/changes&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A drop-in React component
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;whereToWatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./swh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;WhereToWatch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;setErr&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;whereToWatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setErr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Couldn't load options.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading…&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;No streaming match in &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;small&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;(&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;)&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;small&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h3&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"noopener"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
              &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;strong&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; — &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
              &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; · &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Usage: &lt;code&gt;&amp;lt;WhereToWatch title="The Bear" country="US" /&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current coverage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;14,000+ titles&lt;/strong&gt; (movies and TV series), refreshed daily&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;70+ providers&lt;/strong&gt; — Netflix, Disney+, HBO Max, Apple TV, OSN+, Shahid, Movistar Plus+, Kinopoisk, Now TV, Sky Go, Paramount+, and others&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 countries&lt;/strong&gt; at launch — US, GB, DE, ES, IT, FR, TR, BR, IN, SA (more being added)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live football&lt;/strong&gt; — 23 leagues including the Big Five plus UCL / UEL / UECL and the Saudi Pro League, with per-country broadcaster resolution (the same fixture airs on different channels in each market, and the API resolves the correct one)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidence scores&lt;/strong&gt; on broadcaster matches so low-confidence entries can be filtered out client-side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep links&lt;/strong&gt; that open the correct detail page on each platform&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Roadmap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated &lt;strong&gt;Sports&lt;/strong&gt; RapidAPI listing is launching in the coming weeks; football fixtures are already accessible inside the current Movies &amp;amp; Series listing.&lt;/li&gt;
&lt;li&gt;Additional countries are scheduled: Mexico, Argentina, Australia, Japan, and Korea.&lt;/li&gt;
&lt;li&gt;A natural-language &lt;code&gt;/recommend&lt;/code&gt; endpoint is under evaluation ("where can I watch the Champions League final in Romania tonight?") — the team is still assessing whether it adds enough value beyond the structured endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🌐 Landing and documentation: &lt;strong&gt;&lt;a href="https://streamwatchhub.com/api/" rel="noopener noreferrer"&gt;streamwatchhub.com/api&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;💚 Free tier on RapidAPI: &lt;strong&gt;&lt;a href="https://rapidapi.com/kavela-kavela-default/api/streamwatchhub-movies-series/pricing" rel="noopener noreferrer"&gt;Movies &amp;amp; Series listing&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;📦 Source examples (cURL, Node, Python, PHP): &lt;strong&gt;&lt;a href="https://github.com/Kaveladev/streamwatchhub" rel="noopener noreferrer"&gt;github.com/Kaveladev/streamwatchhub&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback is welcome before the v1 schema is frozen. One open question for readers: how should &lt;code&gt;confidence_score&lt;/code&gt; be exposed — as a raw number, a &lt;code&gt;low / medium / high&lt;/code&gt; enum, or hidden entirely below a threshold? Comments are appreciated.&lt;/p&gt;

&lt;p&gt;Thanks for reading.&lt;/p&gt;

</description>
      <category>api</category>
      <category>javascript</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a 32-URL economy microsite on top of a 754,000-row SQLite dataset</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Wed, 20 May 2026 22:15:40 +0000</pubDate>
      <link>https://dev.to/kavelaltd/building-a-32-url-economy-microsite-on-top-of-a-754000-row-sqlite-dataset-326g</link>
      <guid>https://dev.to/kavelaltd/building-a-32-url-economy-microsite-on-top-of-a-754000-row-sqlite-dataset-326g</guid>
      <description>&lt;h1&gt;
  
  
  Building a 32-URL economy microsite on top of a 754,000-row SQLite dataset
&lt;/h1&gt;

&lt;p&gt;We launched &lt;a href="https://gdpindex.org" rel="noopener noreferrer"&gt;GDPIndex&lt;/a&gt;, a reference site for the twenty G20 economies. The numeric data sits in a 152 MB SQLite file (216 countries × 102 indicators × 754,809 rows of annual country-data). The interesting part of the build is not the dataset itself but how we exposed only a tiny, focused slice of it — and why we resisted the urge to publish thousands of pages.&lt;/p&gt;

&lt;p&gt;This post is about the WordPress side of that decision: how the site routes 32 URLs against a half-million-row store without going near &lt;code&gt;WP_Query&lt;/code&gt;, and how we split structured numbers from country-specific editorial narrative so each page has a different skeleton.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 32 URLs
&lt;/h2&gt;

&lt;p&gt;The same SQLite already powers a much larger reference site — &lt;a href="https://historysaid.com" rel="noopener noreferrer"&gt;HistorySaid&lt;/a&gt; — with about 600 indexed URLs across 216 countries and 9 economic themes. GDPIndex is a deliberate subset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;20 country pages&lt;/strong&gt; at &lt;code&gt;/{country}/economy/&lt;/code&gt; (G20: 19 nations + EU)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 ranking pages&lt;/strong&gt; at &lt;code&gt;/rankings/{indicator}/&lt;/code&gt; covering the GDP-family series&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 static&lt;/strong&gt; (about, methodology, privacy, terms)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That cap is the point. Google now treats large auto-generated reference sites with suspicion, and we already had the per-country data — what was missing was &lt;em&gt;editorial reason for each page to exist&lt;/em&gt;. Twenty G20 economies, each with a structural story that has been the subject of decades of literature, is a defensible scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtual routing without &lt;code&gt;WP_Query&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;WordPress's rewrite system is great for posts, taxonomies, and the bits of the URL space that map onto database records. It is terrible for serving 30 URLs whose content is computed at request time from data in a sibling table. Every workaround we have seen — custom post types, ACF-driven templates, rewrite endpoints — ends up either creating 30 dummy posts to "host" the URLs or fighting the &lt;code&gt;parse_request&lt;/code&gt; lifecycle.&lt;/p&gt;

&lt;p&gt;A cleaner approach: never let WordPress think about these URLs at all. Hook &lt;code&gt;template_redirect&lt;/code&gt; at priority 0, parse &lt;code&gt;$_SERVER['REQUEST_URI']&lt;/code&gt; yourself, and short-circuit the response if the path matches one of your virtual routes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GI_Router&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;GI_DB&lt;/span&gt; &lt;span class="nv"&gt;$db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_sitemaps_enabled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_false'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'parse_request'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'early_intercept'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;add_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'template_redirect'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'handle_request'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle_request&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strtok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'REQUEST_URI'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'?'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$segs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;explode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
        &lt;span class="nv"&gt;$route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$hs_route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$hs_route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nf"&gt;status_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wp_query&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$wp_query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_404&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$wp_query&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;is_page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nf"&gt;get_header&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="nf"&gt;get_stylesheet_directory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/templates/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$route&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'template'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nf"&gt;get_footer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resolver is a single switch on path shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$g20&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gi_g20_map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// ['turkey' =&amp;gt; 'turkiye', ...]&lt;/span&gt;
    &lt;span class="nv"&gt;$gdp_inds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gi_gdp_indicators&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// /{country}/economy&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'economy'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$g20&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'country_economy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'template'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'country-economy'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'url_slug'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="s1"&gt;'db_slug'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$g20&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;
            &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Bare /{country}/ → 301 to /economy&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$g20&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'redirect'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/economy/'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// /rankings/{indicator}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'rankings'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$segs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nv"&gt;$gdp_inds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// not our URL — let WP handle it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;null&lt;/code&gt; is critical: anything we don't claim falls through to WordPress, so &lt;code&gt;/wp-admin/&lt;/code&gt;, &lt;code&gt;/wp-json/&lt;/code&gt;, the favicon, and &lt;code&gt;robots.txt&lt;/code&gt; all keep working.&lt;/p&gt;

&lt;p&gt;We also intercept &lt;code&gt;sitemap.xml&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt;, and &lt;code&gt;llms.txt&lt;/code&gt; on &lt;code&gt;parse_request&lt;/code&gt; (earlier than &lt;code&gt;template_redirect&lt;/code&gt;), because WordPress 5.5+ ships a core sitemap at &lt;code&gt;/wp-sitemap.xml&lt;/code&gt; and will 301 you there before your template hook runs. &lt;code&gt;wp_sitemaps_enabled&lt;/code&gt; → &lt;code&gt;false&lt;/code&gt; disables that core handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two slugs per country
&lt;/h2&gt;

&lt;p&gt;The dataset and the URL space don't agree on slugs. SQLite has &lt;code&gt;russian-federation&lt;/code&gt;, &lt;code&gt;turkiye&lt;/code&gt;, &lt;code&gt;korea&lt;/code&gt;, &lt;code&gt;european-union&lt;/code&gt;. The URLs we want are &lt;code&gt;russia&lt;/code&gt;, &lt;code&gt;turkey&lt;/code&gt;, &lt;code&gt;south-korea&lt;/code&gt;, &lt;code&gt;eu&lt;/code&gt;. The router holds a single canonical map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;gi_g20_map&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'argentina'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'argentina'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'australia'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'australia'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="s1"&gt;'russia'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'russian-federation'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'south-korea'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'korea'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'turkey'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'turkiye'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'united-kingdom'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'united-kingdom'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'united-states'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'united-states'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every layer that touches a country goes through this map: the router resolves URL → DB slug, the data layer queries by DB slug, the page renderer flips back to the URL slug when emitting internal links, and the JSON-LD &lt;code&gt;BreadcrumbList&lt;/code&gt; uses URL slugs end-to-end. Putting the map in one function and refusing to allow ad-hoc string substitution anywhere else is what keeps the site consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two custom tables: structure plus narrative
&lt;/h2&gt;

&lt;p&gt;The page template is shared across all 20 countries (hero + KPI strip + chart-bearing sections + FAQ + peer-rank table), but the &lt;em&gt;content&lt;/em&gt; of each section is different per country. The "middle-income trap" section appears on Türkiye, China and Brazil; it does not appear on Germany. So we cannot store sections as a fixed schema — we store them as JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wp_gi_country&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;iso3&lt;/span&gt; &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;headline_question&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;intro_html&lt;/span&gt; &lt;span class="nb"&gt;LONGTEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sections_json&lt;/span&gt; &lt;span class="nb"&gt;LONGTEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- [{heading, body_html, charts:[...], tables:[...]}]&lt;/span&gt;
    &lt;span class="n"&gt;faq_json&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- [{q, a}]&lt;/span&gt;
    &lt;span class="n"&gt;word_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;generated_at&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked&lt;/span&gt; &lt;span class="nb"&gt;TINYINT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wp_gi_ranking&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;analysis_html&lt;/span&gt; &lt;span class="nb"&gt;LONGTEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;word_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;generated_at&lt;/span&gt; &lt;span class="nb"&gt;DATETIME&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single PHP page template iterates &lt;code&gt;sections_json&lt;/code&gt; and renders each section with its own chart and table specs. Chart specs reference the indicator slug (&lt;code&gt;gdp&lt;/code&gt;, &lt;code&gt;gdp-per-capita&lt;/code&gt;, &lt;code&gt;gdp-growth&lt;/code&gt;, …); the template loads the time-series from SQLite at render time and ships a Chart.js config to the browser as a &lt;code&gt;data-config&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;This separation also matters for editorial workflow: numbers come from SQLite (refreshed nightly from the source feed), and narrative lives in MySQL alongside the rest of WordPress. We can re-render the narrative without rebuilding the data, and we can refresh the data without touching the narrative.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page-level "linker matrix"
&lt;/h2&gt;

&lt;p&gt;To make 20 country pages a useful cluster instead of 20 dead ends, the template emits the same internal-link block on every page, but with the &lt;em&gt;current country removed&lt;/em&gt; and a peer-selection step on top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$peers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gi_g20_map&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$url_slug&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nb"&gt;shuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$peers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$peers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a static link to &lt;code&gt;/rankings/gdp/&lt;/code&gt; from every country page, and a &lt;code&gt;&amp;lt;th&amp;gt;Country&amp;lt;/th&amp;gt;&lt;/code&gt; cell on every ranking row that links back to &lt;code&gt;/{country}/economy/&lt;/code&gt;. Every node in the 29-page graph (20 country + 9 ranking) has at least eight outbound internal links, and every node has at least nine inbound links from peer pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sitemap and JSON-LD discipline
&lt;/h2&gt;

&lt;p&gt;With 32 URLs and a small fixed shape, the sitemap is a 75-line PHP function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'loc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'changefreq'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'weekly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'priority'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'1.0'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'loc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/about/'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'changefreq'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'yearly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'priority'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0.4'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gi_g20_map&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'loc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/economy/'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                   &lt;span class="s1"&gt;'changefreq'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'monthly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'priority'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0.9'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;gi_gdp_indicators&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$ind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'loc'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;home_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/rankings/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$ind&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                   &lt;span class="s1"&gt;'changefreq'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'monthly'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'priority'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'0.7'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$urls&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each country page emits an &lt;code&gt;@graph&lt;/code&gt; JSON-LD payload with five nodes: &lt;code&gt;Article&lt;/code&gt;, &lt;code&gt;Country&lt;/code&gt;, one &lt;code&gt;StatisticalDataset&lt;/code&gt; per data table cited, &lt;code&gt;FAQPage&lt;/code&gt;, and &lt;code&gt;BreadcrumbList&lt;/code&gt;. Ranking pages add &lt;code&gt;ItemList&lt;/code&gt; whose &lt;code&gt;itemListElement[].url&lt;/code&gt; points back at the country profile. Inspecting any page with Google's Rich Results Test surfaces all five entity types correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we kept out
&lt;/h2&gt;

&lt;p&gt;A few things we explicitly did not build because they would have undermined the focus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No author pages or post archives.&lt;/strong&gt; This is a reference site; it has no blog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No tag taxonomy.&lt;/strong&gt; Each country has a fixed analytical angle; adding a "tags" axis would dilute the structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No automatic monthly republish.&lt;/strong&gt; Data refreshes nightly, but the narrative is only regenerated when an inflection year appears in the underlying series or when a major policy event materially changes the structural picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No comment system.&lt;/strong&gt; It is a citation surface, not a forum.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's published
&lt;/h2&gt;

&lt;p&gt;Live at &lt;a href="https://gdpindex.org" rel="noopener noreferrer"&gt;gdpindex.org&lt;/a&gt;. Twenty country profiles, nine ranking pages, three static pages. Total page count: 32. Average word count per country profile: ~4,500.&lt;/p&gt;

&lt;p&gt;The same pattern (custom router → custom tables → bespoke per-instance schema) generalises to any focused reference site sitting on top of structured data. If you find yourself fighting &lt;code&gt;WP_Query&lt;/code&gt; to expose computed content, take five hours and write the router instead. WordPress is happy to step out of the way once you ask it firmly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gdpindex.org" rel="noopener noreferrer"&gt;gdpindex.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>seo</category>
      <category>data</category>
    </item>
    <item>
      <title>How we repurposed a Cloudflare Email Worker domain into a SaaS without taking it down</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Mon, 18 May 2026 12:08:29 +0000</pubDate>
      <link>https://dev.to/kavelaltd/how-we-repurposed-a-cloudflare-email-worker-domain-into-a-saas-without-taking-it-down-5glk</link>
      <guid>https://dev.to/kavelaltd/how-we-repurposed-a-cloudflare-email-worker-domain-into-a-saas-without-taking-it-down-5glk</guid>
      <description>&lt;p&gt;Last week we did something we'd been putting off for months: we took a domain that was working — &lt;code&gt;newmail.click&lt;/code&gt;, a throwaway-inbox utility wired to a Cloudflare Email Worker — and repurposed it into a paid SaaS without unplugging anything.&lt;/p&gt;

&lt;p&gt;The result is a domain doing two completely unrelated jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apex&lt;/strong&gt; (&lt;code&gt;newmail.click&lt;/code&gt;) — a Next.js email signature builder with click + open tracking, $19/mo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;inbox.newmail.click&lt;/code&gt;&lt;/strong&gt; — the original throwaway-inbox worker, untouched, still serving the PBN bot that depends on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both share one zone, one MX record set, one set of nameservers. Neither knows the other exists. Here is how we got there, and why we'd do it again instead of registering a fresh domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The original &lt;code&gt;newmail.click&lt;/code&gt; was a 50-line Cloudflare Worker: catch-all &lt;code&gt;*@newmail.click&lt;/code&gt; MX → KV store with a 7-day TTL → a single &lt;code&gt;GET /?to=&amp;amp;token=&lt;/code&gt; read endpoint that another bot polled. Total operational surface: one Worker, one KV namespace, one auth token.&lt;/p&gt;

&lt;p&gt;It worked. It also did exactly nothing for SEO. The apex was a 404. Google had no reason to index anything. The domain was burning $12/year as glorified plumbing.&lt;/p&gt;

&lt;p&gt;Meanwhile we had a half-finished signature builder sitting on a different domain that wasn't going to rank for anything because the name was forgettable. &lt;code&gt;newmail.click&lt;/code&gt; is short, memorable, and topically adjacent (email → email signatures). The smart move was to merge them.&lt;/p&gt;

&lt;p&gt;The dumb move was the obvious one: kill the inbox worker, point everything at the SaaS. We weren't doing that. The PBN bot uses the inbox worker daily. Migrating it would be a week of work to save fifteen minutes of DNS surgery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The trick is recognizing that &lt;strong&gt;email routing and HTTP routing are independent layers on the same domain&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;newmail&lt;/span&gt;.&lt;span class="n"&gt;click&lt;/span&gt;           &lt;span class="n"&gt;A&lt;/span&gt; → &lt;span class="n"&gt;hetzner&lt;/span&gt; (&lt;span class="n"&gt;Next&lt;/span&gt;.&lt;span class="n"&gt;js&lt;/span&gt; &lt;span class="n"&gt;SaaS&lt;/span&gt;)
&lt;span class="n"&gt;www&lt;/span&gt;.&lt;span class="n"&gt;newmail&lt;/span&gt;.&lt;span class="n"&gt;click&lt;/span&gt;       &lt;span class="n"&gt;A&lt;/span&gt; → &lt;span class="n"&gt;hetzner&lt;/span&gt; (&lt;span class="n"&gt;Next&lt;/span&gt;.&lt;span class="n"&gt;js&lt;/span&gt; &lt;span class="n"&gt;SaaS&lt;/span&gt;)
&lt;span class="n"&gt;inbox&lt;/span&gt;.&lt;span class="n"&gt;newmail&lt;/span&gt;.&lt;span class="n"&gt;click&lt;/span&gt;     &lt;span class="n"&gt;CNAME&lt;/span&gt; → &lt;span class="n"&gt;Cloudflare&lt;/span&gt; &lt;span class="n"&gt;Worker&lt;/span&gt;
*@&lt;span class="n"&gt;newmail&lt;/span&gt;.&lt;span class="n"&gt;click&lt;/span&gt;         &lt;span class="n"&gt;MX&lt;/span&gt; → &lt;span class="n"&gt;Cloudflare&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="n"&gt;Routing&lt;/span&gt; → &lt;span class="n"&gt;Worker&lt;/span&gt; → &lt;span class="n"&gt;KV&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare Email Routing operates on MX records. The Next.js app operates on A records. As long as you don't try to route HTTP for &lt;code&gt;inbox.*&lt;/code&gt; to the SaaS, or MX for the apex to anywhere weird, the two stacks are invisible to each other.&lt;/p&gt;

&lt;p&gt;The handoff plan in our notes is exactly three DNS changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apex + www A records → hetzner &lt;code&gt;77.42.127.243&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inbox&lt;/code&gt; subdomain unchanged&lt;/li&gt;
&lt;li&gt;MX records unchanged&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the entire migration. No worker rewrite, no KV export, no downtime on the inbox side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SaaS
&lt;/h2&gt;

&lt;p&gt;The signature builder itself is boring on purpose. Next.js 16, better-sqlite3, NextAuth (Google), Stripe checkout, PM2 process on port 3120. We resisted every urge to put it on a "real" database or a serverless platform. SQLite holds the entire signature catalog, the tracking events, and the user records in one file. When it stops holding them, we'll move it. Probably never.&lt;/p&gt;

&lt;p&gt;The tracking layer is the only mildly interesting part. Every link in a generated signature is wrapped with &lt;code&gt;/r/{hash}&lt;/code&gt; for click redirects, and we attach a &lt;code&gt;/p/{hash}.png&lt;/code&gt; pixel for opens. Hash is a deterministic short ID per (user, link, signature_version) tuple so signatures stay stable across email clients that aggressively cache images.&lt;/p&gt;

&lt;p&gt;The pSEO surface is 30 EN profession-specific pages (&lt;code&gt;/freelance-designer-email-signature&lt;/code&gt;, &lt;code&gt;/real-estate-agent-email-signature&lt;/code&gt;, etc.), each 2,800–3,600 words with sector stats, cold-outreach tactics, 10–12 FAQ blocks and FAQPage JSON-LD. Content fill ran through DeepSeek because Gemini's keys were dead at the time. We'd recommend the same — DeepSeek long-form output handled the 90k-word generation pass for under a dollar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why share a domain at all
&lt;/h2&gt;

&lt;p&gt;The temptation when launching anything new is to register a clean domain and avoid the cognitive overhead of multi-purpose DNS. We've done this enough times to recognize the trap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new domain has zero crawl history. Apex &lt;code&gt;newmail.click&lt;/code&gt; had three years of MX-record stability — that's not authority, but it's &lt;em&gt;not nothing&lt;/em&gt; either, and Google's spam classifier notices.&lt;/li&gt;
&lt;li&gt;One zone means one set of DNSSEC keys, one set of CAA records, one Cloudflare account to keep paid. We have eighteen active domains. Every reduction matters.&lt;/li&gt;
&lt;li&gt;The brand association — "newmail" as an email-adjacent product name — works &lt;em&gt;better&lt;/em&gt; with both products coexisting. The throwaway-inbox usage signal feeds the apex's topical relevance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is a slightly more elaborate runbook ("don't touch the MX records when you migrate the A records"). We can live with that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;p&gt;One thing in retrospect: we should have set up the apex landing on the &lt;em&gt;target&lt;/em&gt; server (hetzner) from the start, not on the static-hosting box (contabo) where the placeholder currently sits. The plan calls for a DNS swap at cutover, which means a TLS cert reissue on the new server. If the apex had always been on hetzner, the SaaS could go live by replacing the nginx vhost config — zero DNS propagation wait.&lt;/p&gt;

&lt;p&gt;The other thing: we removed the Turkish locale before launch because we wanted the EN signal to be unambiguous for Google. In hindsight this was probably premature; the 30 EN profession pages were going to dominate the keyword distribution regardless. We'll add &lt;code&gt;/tr/*&lt;/code&gt; back if the TR market shows demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Domains aren't products. They're addresses. A single domain can run a Worker on one MX layer, a Next.js app on its apex, and a pSEO surface on its subpaths simultaneously, and no part of that requires you to delete the others.&lt;/p&gt;

&lt;p&gt;If you have a domain that's underperforming as a single-purpose utility, look at what &lt;em&gt;else&lt;/em&gt; it could host before you go shopping for a new one. Most of the time the answer is "more than you think."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The signature builder is at &lt;a href="https://newmail.click" rel="noopener noreferrer"&gt;newmail.click&lt;/a&gt; — free tier is unlimited, Pro adds tracking for $19/mo or $149/yr.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How we recovered from a 30,000 to 5 Google deindex on a programmatic SEO site</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Fri, 15 May 2026 06:58:59 +0000</pubDate>
      <link>https://dev.to/kavelaltd/how-we-recovered-from-a-30000-to-5-google-deindex-on-a-programmatic-seo-site-40io</link>
      <guid>https://dev.to/kavelaltd/how-we-recovered-from-a-30000-to-5-google-deindex-on-a-programmatic-seo-site-40io</guid>
      <description>&lt;p&gt;In April, HistorySaid.com had ~30,000 URLs in Google's index. By May 13, the count was five. Five total URLs.&lt;/p&gt;

&lt;p&gt;This is the story of what happened — what we think we know about why, and the surgery we performed to recover. If you run a programmatic SEO site on WordPress, especially one with templated cross-product pages (country × indicator, asset × asset, region × year), the same trap is sitting under your traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The site, briefly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://historysaid.com" rel="noopener noreferrer"&gt;HistorySaid.com&lt;/a&gt; is a programmatic reference site built on WordPress, but the content lives in custom tables, not &lt;code&gt;wp_posts&lt;/code&gt;. There's a virtual router that intercepts URLs and renders pages out of a SQLite database of countries, indicators, figures, signals, comparisons, decades, superlatives — the long-tail combinatorial surface area you'd expect from a "every country × every indicator × every decade" play.&lt;/p&gt;

&lt;p&gt;At peak, the sitemap held about 30,120 URLs spread across these page types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;URL pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Country × Indicator&lt;/td&gt;
&lt;td&gt;~22,000&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/{country}/{indicator}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare (A vs B)&lt;/td&gt;
&lt;td&gt;~7,300&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/compare/{a}-vs-{b}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Country hub&lt;/td&gt;
&lt;td&gt;216&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/{country}/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ranking (per indicator)&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/ranking/{indicator}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Figure&lt;/td&gt;
&lt;td&gt;151&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/figure/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pillar (insight hub)&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/insights/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal (analytical post)&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/signals/{slug}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inquiry (long-form Q&amp;amp;A)&lt;/td&gt;
&lt;td&gt;~16&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/why/{slug}&lt;/code&gt;, &lt;code&gt;/how/{slug}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first three categories were the volume play. They were also the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;We didn't see a manual action. There was no penalty notice, no message in Google Search Console. The index count just slid. April: 5,000+. Early May: ~1,200. May 13: 5.&lt;/p&gt;

&lt;p&gt;The signature of this is well-known among programmatic SEO operators: Google's &lt;em&gt;scaled content abuse&lt;/em&gt; classifier. It's a system, not a manual reviewer. It samples a property, decides "this site is producing thin templated content at scale," and broadly deindexes it. We've seen the same playbook hit two other sites in our network this year before we recognized the shape.&lt;/p&gt;

&lt;p&gt;The reason is geometric. If a classifier samples 200 URLs from a 30,000-URL site and 195 of them are &lt;code&gt;/{country}/{indicator}&lt;/code&gt; pages with &lt;code&gt;&amp;lt;2,000&lt;/code&gt; characters of unique content surrounding identical data tables, the verdict is set. The other 5,000 high-quality pages don't get evaluated individually — they go down with the sample.&lt;/p&gt;

&lt;p&gt;We confirmed the diagnosis by category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;22,000 country × indicator pages&lt;/strong&gt;: identical template, ~1,200 chars unique text per page (mostly programmatic sentences like "Turkey's inflation rate was X% in 2023").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;7,300 compare pages&lt;/strong&gt;: similar pattern, paired data, even less unique copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;216 country hubs&lt;/strong&gt;: had real data density, but Google never got to evaluate them individually.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Surgery 1: shed the templated mass
&lt;/h2&gt;

&lt;p&gt;The recovery move was counterintuitive: don't try to fatten 30,000 thin pages. Trim them to 700. Give the classifier a small, dense, unique-data site to re-evaluate.&lt;/p&gt;

&lt;p&gt;We did this in two passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;May 13 pass&lt;/strong&gt; dropped the worst offenders. In the SEO plugin's robots filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;rankmath_robots&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$thin_types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'indicator_decade'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'indicator_superlative'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'indicator'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'compare'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'type'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$thin_types&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'noindex'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'follow'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'follow'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$robots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The matching HTML meta and &lt;code&gt;X-Robots-Tag&lt;/code&gt; header went out at template render time, not on a delay. The sitemap router returned &lt;code&gt;410 Gone&lt;/code&gt; for the matching shards so Google would stop trying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;serve_sitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'decades'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'superlatives'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'indicators'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'compare'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;status_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Robots-Tag: noindex'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;?xml version=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;1.0&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;?&amp;gt;&amp;lt;urlset xmlns=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&amp;lt;/urlset&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this pass, 738 URLs remained in the sitemap (~2.5% of the original).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;May 15 pass&lt;/strong&gt; went further. The first sweep wasn't enough; the classifier seemed to be re-evaluating and lingering on rankings, assets, groups, and patterns — all of which still had templated bodies under unique titles. We added them to the &lt;code&gt;$thin_types&lt;/code&gt; list and the sitemap guard. The indexable spine became 583 URLs: the 216 country hubs, 151 figures, 67 pillars, 102 signals, 16 long-form inquiries, 26 snippets, 5 evergreen pages.&lt;/p&gt;

&lt;p&gt;The non-spine pages aren't gone — they still resolve, they still take internal links, they just carry &lt;code&gt;noindex,follow&lt;/code&gt;. Google can crawl through them to discover the spine. They just don't compete for index slots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surgery 2: thicken the spine with real data + analysis
&lt;/h2&gt;

&lt;p&gt;Trimming alone isn't enough. The 583 survivor pages also had to clearly &lt;em&gt;not&lt;/em&gt; look templated. Country hubs were the worst offenders here: the same hero, the same data table, the same auto-generated paragraph.&lt;/p&gt;

&lt;p&gt;We built a small enrichment pipeline that does two things per page:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pulls a small &lt;em&gt;unique data block&lt;/em&gt; from a different source than the existing tables.&lt;/li&gt;
&lt;li&gt;Generates a 130–180 word analytical paragraph that cites the numbers from that block.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fresh data source was &lt;a href="https://db.nomics.world" rel="noopener noreferrer"&gt;DBnomics&lt;/a&gt;, which aggregates IMF, OECD, World Bank, ECB, BIS series under a free, no-key API. For each of the 216 country hubs we pulled three series from IMF's &lt;em&gt;World Economic Outlook&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.db.nomics.world/v22/series/IMF/WEO:latest/{ISO3}.NGDP_RPCH
GET https://api.db.nomics.world/v22/series/IMF/WEO:latest/{ISO3}.PCPIPCH
GET https://api.db.nomics.world/v22/series/IMF/WEO:latest/{ISO3}.LUR
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Real GDP growth, CPI inflation, unemployment rate.)&lt;/p&gt;

&lt;p&gt;The 15 most recent observations from each series got stored as JSON in the page's enrichment row, then rendered as three small two-column tables under a &lt;em&gt;Data &amp;amp; analysis&lt;/em&gt; heading. Then a DeepSeek prompt was given the same JSON plus the page's existing global rankings and asked to write a single 130–180 word paragraph that cited at least one specific year and value.&lt;/p&gt;

&lt;p&gt;The validator did the work the LLM wouldn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;must_digits&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;230&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;len&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;BANNED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;banned&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;must_digits&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-digits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;must_digits&lt;/code&gt; was the formatted version of the headline values from the data block — if the model didn't reference at least one of them, the paragraph was rejected and retried. &lt;code&gt;BANNED&lt;/code&gt; killed em-dashes, "in conclusion", "overall", first-person plurals, and other LLM tells.&lt;/p&gt;

&lt;p&gt;The enrichment table was a single row per spine page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wp_hs_enrichment&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;subject_type&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;-- country_hub, figure, ranking, pillar&lt;/span&gt;
  &lt;span class="n"&gt;subject_slug&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;    &lt;span class="c1"&gt;-- the page slug&lt;/span&gt;
  &lt;span class="n"&gt;paragraph&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;-- the 130-180w analysis&lt;/span&gt;
  &lt;span class="n"&gt;paragraph_long&lt;/span&gt; &lt;span class="nb"&gt;LONGTEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- the 1500-2500w long-form (top 100 only)&lt;/span&gt;
  &lt;span class="n"&gt;data_json&lt;/span&gt; &lt;span class="nb"&gt;LONGTEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- the cached data block&lt;/span&gt;
  &lt;span class="n"&gt;word_count&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;prompt_version&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subject_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject_slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single template partial (&lt;code&gt;enrichment-block.php&lt;/code&gt;) reads this row, renders the data tables specific to the page type, then renders the paragraph (or the long-form HTML, if it exists for that page). The four spine templates (&lt;code&gt;country.php&lt;/code&gt;, &lt;code&gt;figure-single.php&lt;/code&gt;, &lt;code&gt;ranking.php&lt;/code&gt;, &lt;code&gt;pillar.php&lt;/code&gt;) each &lt;code&gt;include&lt;/code&gt; the partial with just two lines of context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$hs_enrich_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'country_hub'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$hs_enrich_slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'slug'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/enrichment-block.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 520 of the 583 spine pages this short-paragraph pass was enough. For the top 100 by data depth and importance — top 40 countries by global top-20 rank coverage, top 40 figures by length of their key-contributions JSON, top 20 pillars chosen to span categories — we ran a second deeper pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deep pass
&lt;/h2&gt;

&lt;p&gt;The same pipeline shape, but the prompt asked for 1500–2500 words in semantic HTML, with required &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; sections (&lt;code&gt;Macro snapshot&lt;/code&gt;, &lt;code&gt;Historical arc&lt;/code&gt;, &lt;code&gt;Where the country leads and lags&lt;/code&gt;, &lt;code&gt;Linked thinkers and historical figures&lt;/code&gt;, &lt;code&gt;Comparable peers&lt;/code&gt;, &lt;code&gt;Forward look&lt;/code&gt;). Each section had a 250–400 word target. Internal link slots were enumerated in the prompt so the model would weave 4–6 anchor tags to peer countries and linked figures using exact slugs.&lt;/p&gt;

&lt;p&gt;Validation here was structural: word count between 1200 and 3000, at least 4 &lt;code&gt;&amp;lt;h2&amp;gt;&lt;/code&gt; tags, no code fences, no banned phrases. Output was wrapped in &lt;code&gt;wp_kses()&lt;/code&gt; allowing only &lt;code&gt;h2/h3/p/ul/li/strong/em/a[href]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Average output: 1,750 words per top-100 page. Switzerland came in at 1,773 words; Galbraith's page at 1,611. The deep pages render the long-form HTML in place of the short paragraph automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-linking the survivors
&lt;/h2&gt;

&lt;p&gt;A 583-page editorial spine only works if the pages reinforce each other. We added a "Connected on HistorySaid" pill row below the analysis paragraph on every enriched page, generated server-side from three SQLite joins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a country hub: linked historical figures (via &lt;code&gt;figure_countries&lt;/code&gt;) + inquiries citing this country (via a REST endpoint that queries &lt;code&gt;wp_hs_inquiries&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;On a figure page: linked countries (reverse of the above).&lt;/li&gt;
&lt;li&gt;On a ranking: top-4 country profiles for that indicator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pill row is rendered by the same &lt;code&gt;enrichment-block.php&lt;/code&gt; partial, server-side, no JS — Google sees it on first crawl. Internal anchor count on a typical country hub went from ~12 to ~22 outbound links to other spine pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submission
&lt;/h2&gt;

&lt;p&gt;IndexNow handled Bing and Yandex immediately. A single POST per endpoint with 738 URLs in the body and both returned &lt;code&gt;200&lt;/code&gt;/&lt;code&gt;202&lt;/code&gt;. Google's &lt;code&gt;Indexing API&lt;/code&gt; is a different animal — it requires a service account added as an Owner of the GSC property, has a 200-URL/day quota per property, and only acknowledges "URL_UPDATED" pings, not Search-Console-level submission.&lt;/p&gt;

&lt;p&gt;We queued the 583 spine survivors with the top 100 (deep-enriched) at the front and the rest behind. A daily cron at 00:00 UTC submits 150 URLs per day:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 0 * * * /usr/bin/python3 /root/pipelines/historysaid-indexing/submit_daily.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Day 1 hits the top 100 plus 50 of the rest. Day 4 finishes the queue. Top-priority pages get the explicit Google "URL_UPDATED" signal first, and on the day they're freshest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we expect and what we don't
&lt;/h2&gt;

&lt;p&gt;The honest answer about recovery from a scaled-content classifier verdict is that the signal turnaround takes 2 to 4 weeks. Indexable page count doesn't snap back. What we should see, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Within 7 days: GSC &lt;code&gt;Crawl stats&lt;/code&gt; shows the indexable-page crawl rate rising.&lt;/li&gt;
&lt;li&gt;Within 14 days: in &lt;code&gt;Indexing &amp;gt; Pages&lt;/code&gt;, the "Discovered – currently not indexed" bucket starts shifting to "Crawled – currently not indexed."&lt;/li&gt;
&lt;li&gt;Between 21 and 28 days: re-evaluation completes, indexed count starts climbing back.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If 28 days from May 15 the index is still flat at single digits, the remaining lever is trimming further — rankings, snippets, even some of the lower-data country hubs. Smaller is fine. A 200-URL curated site that gets cited by AI overviews and Wikipedia is worth more than 30,000 indexed pages no one reads.&lt;/p&gt;

&lt;p&gt;The pages we're betting on most are linked below. They're the ones with the deepest data, the longest analysis, and the most outbound internal links to the rest of the spine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/switzerland/" rel="noopener noreferrer"&gt;Switzerland&lt;/a&gt; — top 20 in eight global indicators&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/canada/" rel="noopener noreferrer"&gt;Canada&lt;/a&gt; — natural resources + currency stability profile&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/japan/" rel="noopener noreferrer"&gt;Japan&lt;/a&gt; — long-arc demographics and debt&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/singapore/" rel="noopener noreferrer"&gt;Singapore&lt;/a&gt; — sovereign wealth and trade leverage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/sweden/" rel="noopener noreferrer"&gt;Sweden&lt;/a&gt; — small-economy global rank density&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/figure/adam-smith" rel="noopener noreferrer"&gt;Adam Smith&lt;/a&gt; — invisible hand, division of labor&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/figure/john-maynard-keynes" rel="noopener noreferrer"&gt;John Maynard Keynes&lt;/a&gt; — depression-era policy invention&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://historysaid.com/insights/richest-countries-in-the-world" rel="noopener noreferrer"&gt;Richest countries in the world&lt;/a&gt; — pillar comparison hub&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we'd do differently if we started over
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;First: build the spine before the long tail. We launched with the 30,000 templated pages live and the 583 spine pages buried inside them. The classifier's sample skewed thin from day one. Had the spine launched first and matured before the combinatorial pages were added, the verdict would likely have gone the other way.&lt;/p&gt;

&lt;p&gt;Second: instrument the data block earlier. The DBnomics integration would have taken the same week if we'd done it before the deindex as it took after. Pages with real, unique, cited time-series at launch are demonstrably not templated, even if everything around them is. The classifier sees this; we just gave it nothing to see for too long.&lt;/p&gt;

&lt;p&gt;Five URLs in the index is a number you don't forget. We'd rather not see it again.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>wordpress</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to find which platform streams a film, series, or live sport in Turkey</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Tue, 12 May 2026 15:36:39 +0000</pubDate>
      <link>https://dev.to/kavelaltd/how-to-find-which-platform-streams-a-film-series-or-live-sport-in-turkey-g85</link>
      <guid>https://dev.to/kavelaltd/how-to-find-which-platform-streams-a-film-series-or-live-sport-in-turkey-g85</guid>
      <description>&lt;p&gt;When users in Turkey try to watch a specific film, series, or live football match, the first question is always the same: which platform actually streams it right now? Netflix, Disney+, BluTV, Exxen, TOD, Tabii, beIN Connect, Amazon Prime, or one of the newer regional services? Here is a short, practical guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Use a "where to watch" tool
&lt;/h2&gt;

&lt;p&gt;Several sites are purpose-built for this. For the Turkish market, &lt;a href="https://nereden-izlenir.com/" rel="noopener noreferrer"&gt;Nereden Izlenir&lt;/a&gt; covers films, series, and live sports (domestic and European competitions) in one search. JustWatch covers the same problem globally, but local Turkish streaming services and regional league rights are typically more complete on a Turkey-focused source.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Search each platform's own catalog
&lt;/h2&gt;

&lt;p&gt;If you already subscribe to one or two services, opening the app and searching directly is the fastest path. The downside is obvious: you can't see catalogs you don't have access to, and several Turkish streaming services limit catalog previews for non-subscribers.&lt;/p&gt;

&lt;h2&gt;
  
  
  3) Track broadcast rights for live sport
&lt;/h2&gt;

&lt;p&gt;Football rights change hands more often than people expect. Within a single season, a league can move between linear TV, OTT, and hybrid bundles. The reliable approach is to check broadcaster info at the match level rather than relying on a season-start assumption — &lt;a href="https://nereden-izlenir.com/futbol/" rel="noopener noreferrer"&gt;the football section of Nereden Izlenir&lt;/a&gt; is one source that tracks this per-fixture.&lt;/p&gt;

&lt;h2&gt;
  
  
  4) Ask an AI assistant — but verify
&lt;/h2&gt;

&lt;p&gt;ChatGPT, Gemini, and Claude can answer "where is X streaming in Turkey", but their training data lags. A library deal that flipped last month will not be reflected. Cross-check the answer against a current database before subscribing.&lt;/p&gt;

&lt;h2&gt;
  
  
  5) Community sources
&lt;/h2&gt;

&lt;p&gt;Reddit r/TurkishTV, the Turkish forum Donanim Haber, and Ekşi Sözlük entries are surprisingly strong for older or obscure titles. When a film leaves every major service for a long stretch, the community usually surfaces where it ended up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick recap
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New film or series:&lt;/strong&gt; start with a where-to-watch tool, then check platform catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live football:&lt;/strong&gt; never assume — verify at the match-page level, rights shift mid-season.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older title:&lt;/strong&gt; combine community sources with platform search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For deeper guides and live fixture-level broadcaster info: &lt;a href="https://nereden-izlenir.com/" rel="noopener noreferrer"&gt;nereden-izlenir.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nereden-izlenir.com/" rel="noopener noreferrer"&gt;nereden-izlenir.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>streaming</category>
      <category>turkey</category>
      <category>sports</category>
      <category>media</category>
    </item>
    <item>
      <title>Building a 2D guillotine cutting optimizer for sheet goods on Capacitor + React</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Fri, 08 May 2026 06:02:33 +0000</pubDate>
      <link>https://dev.to/kavelaltd/building-a-2d-guillotine-cutting-optimizer-for-sheet-goods-on-capacitor-react-184f</link>
      <guid>https://dev.to/kavelaltd/building-a-2d-guillotine-cutting-optimizer-for-sheet-goods-on-capacitor-react-184f</guid>
      <description>&lt;p&gt;We build small, single-purpose tools at KAVELA LTD. &lt;strong&gt;CutList&lt;/strong&gt; is one of them — give it a stock sheet size and a list of pieces you need to cut, and it returns a layout that fits everything onto as few sheets as possible, with a printable PDF the carpenter can take to the saw.&lt;/p&gt;

&lt;p&gt;It's the kind of problem that looks trivial until you start coding it. This post walks through the parts that took the most iteration: why we constrained the solver to &lt;em&gt;guillotine&lt;/em&gt; cuts, how we handled kerf width without making the math a nightmare, and the gotchas that come with shipping jsPDF inside a Capacitor app for users who write in Turkish, Greek, Russian, Czech, and Polish.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "panel cutting" actually means
&lt;/h2&gt;

&lt;p&gt;A carpenter buys plywood, MDF, melamine, or acrylic in standard sheet sizes — 2440×1220 mm in most of Europe, 4×8 ft in North America. A project needs dozens of smaller rectangular pieces: cabinet sides, shelves, drawer fronts, backs.&lt;/p&gt;

&lt;p&gt;The job is to figure out how to lay those pieces onto the smallest number of stock sheets, then print a cut diagram so the operator can run the panel saw without thinking about it.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;2D bin packing&lt;/strong&gt;, a classic NP-hard problem. The literature is decades deep. The catch is that real-world panel saws don't make arbitrary cuts — they make &lt;em&gt;guillotine&lt;/em&gt; cuts, which constrains the search space substantially.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guillotine cuts: the constraint that makes everything tractable
&lt;/h2&gt;

&lt;p&gt;A guillotine cut goes all the way across the sheet from edge to edge in one pass. You cannot cut an L-shape, you cannot cut around a piece, you cannot start in the middle. Every cut splits one rectangle into two rectangles.&lt;/p&gt;

&lt;p&gt;This matches how panel saws actually work. It also dramatically simplifies the algorithm: any valid layout can be represented as a binary tree of cuts. The solver doesn't have to consider arbitrary nestings — it only has to decide, for each rectangle, whether to split horizontally or vertically and where.&lt;/p&gt;

&lt;p&gt;The pure 2D bin-packing literature has prettier solutions (the Maximal Rectangles algorithm, skyline heuristics, or full ILP solvers) that produce tighter layouts when arbitrary cuts are allowed. None of those layouts can actually be produced on a panel saw. We deliberately rejected them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solver: shelf-based with first-fit-decreasing
&lt;/h2&gt;

&lt;p&gt;The algorithm that ships is shelf-based. It's not the absolute tightest packer, but it is the one that produces layouts an operator can actually cut.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sort all pieces by longest edge, descending.&lt;/li&gt;
&lt;li&gt;For each piece, try to place it on an existing "shelf" (a horizontal strip across the current sheet) where it fits.&lt;/li&gt;
&lt;li&gt;If no shelf fits, open a new shelf at the height of the piece.&lt;/li&gt;
&lt;li&gt;If the new shelf doesn't fit on the current sheet, open a new sheet.&lt;/li&gt;
&lt;li&gt;Within each shelf, pieces are placed left-to-right.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Variations we tried and rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best-fit instead of first-fit&lt;/strong&gt; — marginal improvement on utilization, much slower, occasionally produced non-guillotine layouts depending on rotation handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch-and-bound&lt;/strong&gt; — got tighter layouts on small inputs but didn't terminate in reasonable time once piece count crossed ~40. Carpenters routinely throw 60–100 pieces at us.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ant colony optimization&lt;/strong&gt; — fun to write, terrible to debug, gave answers that varied between runs (which carpenters hated — they want the same input to produce the same cutsheet).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shipped algorithm gives 75–90% sheet utilization on typical inputs, runs in under 50 ms for 100 pieces on a phone, and is deterministic. Carpenters care more about determinism and cuttability than about a 3% utilization gain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kerf: the millimeter that ruins everything
&lt;/h2&gt;

&lt;p&gt;A saw blade has thickness. When you cut a 2440 mm sheet in half, you don't get two 1220 mm pieces — you get two pieces that together measure 2440 minus the kerf, typically 3 mm for a panel saw blade.&lt;/p&gt;

&lt;p&gt;Naive solvers ignore kerf. They produce layouts that don't physically work — the operator follows the diagram, makes the cuts, and discovers their last piece is 3 mm short.&lt;/p&gt;

&lt;p&gt;The way we handle it: &lt;strong&gt;kerf is added to every piece's effective dimensions during packing&lt;/strong&gt;. If the user requests a 600×400 piece with a 3 mm kerf, the packer treats it as 603×403 during placement. The PDF still labels the piece 600×400 (the actual finished dimension), but the cut layout has the kerf reserved between every pair of adjacent pieces.&lt;/p&gt;

&lt;p&gt;This works because in a guillotine layout, every piece has cuts on at most two edges (the other two are the sheet edge or share a cut with a neighbor). Adding kerf to every dimension over-reserves slightly on edge pieces — about 1.5 mm of waste per outer edge — which is acceptable. The alternative (tracking which edges are "shared cuts" and only adding kerf there) is bookkeeping-heavy and error-prone.&lt;/p&gt;

&lt;p&gt;The setting is exposed as a single number: kerf in millimeters. Default 3. We do not pretend to know your blade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rotation and grain
&lt;/h2&gt;

&lt;p&gt;Some pieces can be rotated to fit better; some cannot. Veneered plywood and melamine have a grain direction — rotating a piece 90° to save a few cm of waste produces a finished cabinet with mismatched grain on the door fronts.&lt;/p&gt;

&lt;p&gt;The packer accepts a &lt;code&gt;canRotate&lt;/code&gt; flag per piece. When false, the piece is placed in its original orientation only. When true, the packer tries both orientations and picks whichever fits. The UI surfaces this as a checkbox per piece (default: rotate allowed) plus a "lock all rotations" toggle for cabinet projects.&lt;/p&gt;

&lt;p&gt;We don't try to be clever about partial grain matching. The user knows their material; we trust them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capacitor + jsPDF + Turkish: the font VFS rabbit hole
&lt;/h2&gt;

&lt;p&gt;The PDF export uses jsPDF. By default, jsPDF ships with the standard PostScript "core fonts" — Helvetica, Times, Courier — which &lt;strong&gt;cannot render Turkish-specific characters&lt;/strong&gt;. ş, ğ, İ, ı, ç, Ö all render as garbage or blanks.&lt;/p&gt;

&lt;p&gt;This is not a Capacitor bug or a bundling bug. The standard PostScript fonts genuinely don't have those glyphs. You have to embed a font that does.&lt;/p&gt;

&lt;p&gt;The fix is jsPDF's Virtual File System (VFS). You load a TTF file, base64-encode it, register it as a VFS asset, and tell jsPDF to use it. The standard Roboto family covers Latin Extended (Turkish, Czech, Polish), Greek, and Cyrillic — exactly the locales we ship to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jsPDF&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jspdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;robotoVFS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./roboto-vfs.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// base64-encoded TTFs&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makePDF&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;jsPDF&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addFileToVFS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto-Regular.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;robotoVFS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;regular&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addFileToVFS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto-Bold.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;robotoVFS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto-Regular.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto-Bold.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roboto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VFS file is large — 424 KB after base64 encoding for Regular + Bold — and that's the cost of supporting non-Latin scripts in a client-side PDF generator. It's a one-time hit per app install, gzipped to ~280 KB on the wire if you ship it as part of the bundle.&lt;/p&gt;

&lt;p&gt;A small build-time gotcha: the openmaptiles GitHub mirror reliably hosts Apache-2.0 Roboto TTFs at predictable paths. The &lt;code&gt;google/fonts&lt;/code&gt; repo paths shift around and 404 from time to time. Don't burn an evening trying to fetch from there — point your build script at the mirror.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real Google Play Billing inside a Capacitor app
&lt;/h2&gt;

&lt;p&gt;CutList offers a Pro tier (PDF export, WhatsApp share, unlimited saved projects) via subscription. The implementation uses &lt;code&gt;cordova-plugin-purchase&lt;/code&gt; v13, which works inside Capacitor when configured correctly.&lt;/p&gt;

&lt;p&gt;The non-obvious parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The subscription products must exist in Play Console before the app can fetch them.&lt;/strong&gt; During development this is the most common confusion — the SDK's &lt;code&gt;purchase()&lt;/code&gt; call returns &lt;code&gt;PRODUCT_NOT_FOUND&lt;/code&gt; even though the code is clearly correct. The fix is to create the SKUs in Play Console and wait ~2 hours for propagation. There is no way to test the billing flow without Play Console SKUs; the emulator can't simulate it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Price comes from the Play Billing offer, not from your code.&lt;/strong&gt; Hardcoding "₺50/month" in the UI breaks for users whose Play Store account is in a different region. Read the localized price string from the SKU offer object and render that.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The "Restore Purchases" button is required by Play policy.&lt;/strong&gt; Users who reinstall, log in on a new device, or switch accounts need a way to re-acknowledge an existing subscription. The plugin exposes a &lt;code&gt;refresh()&lt;/code&gt; method; wire a button to it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Local entitlement cache + server verification.&lt;/strong&gt; The plugin gives you a local &lt;code&gt;isPro&lt;/code&gt; boolean. For a single-user offline tool like ours, that's enough — losing entitlement after a reinstall is fixed by the Restore button. Multi-device sync would need a server. We don't have one for this app, deliberately.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's deliberately missing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud sync.&lt;/strong&gt; Saved projects live in localStorage. Reinstall the app and they're gone. We considered Firebase. We decided that the kind of carpenter who uses CutList does not want a Google account dependency for their cabinet job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-stock-size optimization.&lt;/strong&gt; The solver assumes a single sheet size per project. If you have offcuts in two sizes, run the solver twice. The math for true multi-bin packing across heterogeneous bins is a real research problem; we won't fake it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3D / kitchen layout.&lt;/strong&gt; Out of scope. CutList is a sheet-cutting tool, not a cabinet designer. There are full CAD products for that — we don't compete with them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For domain-constrained optimization problems, &lt;strong&gt;the constraint is the algorithm.&lt;/strong&gt; Guillotine cuts looked like a limitation; they were the thing that made the solver shippable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism over peak optimality&lt;/strong&gt; when humans use the output. A 3% better layout that changes between runs is worse than a stable layout users can plan around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded fonts in client-side PDF are non-negotiable&lt;/strong&gt; for any app supporting locales beyond ASCII. Plan the 400+ KB into your bundle budget from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscription billing in mobile hybrid apps is mostly Play Console configuration&lt;/strong&gt;, not code. Half a day reading the Billing v6 docs saves a week of debugging "why doesn't purchase() work."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CutList is on Google Play and the landing page is at &lt;a href="https://kavela.pro/apps/Cutlist/" rel="noopener noreferrer"&gt;kavela.pro/apps/Cutlist&lt;/a&gt;. Single-purpose, offline-first, no account, no telemetry beyond standard Play install metrics. The free tier covers small projects; Pro removes the saved-project cap and unlocks PDF export.&lt;/p&gt;

&lt;p&gt;If you build optimization tools or panel-saw operators and want to compare notes on packing heuristics, drop a comment.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>react</category>
      <category>mobile</category>
      <category>algorithms</category>
    </item>
    <item>
      <title>Matching camera pixels to RAL codes on Android: building a color matcher with CIE Lab and DeltaE</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Fri, 08 May 2026 05:56:54 +0000</pubDate>
      <link>https://dev.to/kavelaltd/matching-camera-pixels-to-ral-codes-on-android-building-a-color-matcher-with-cie-lab-and-deltae-mi3</link>
      <guid>https://dev.to/kavelaltd/matching-camera-pixels-to-ral-codes-on-android-building-a-color-matcher-with-cie-lab-and-deltae-mi3</guid>
      <description>&lt;p&gt;We build small, single-purpose tools at KAVELA LTD. One of them is &lt;strong&gt;RAL Picker&lt;/strong&gt; — point your phone camera at a furniture surface, a wall, a sample fan-deck, and the closest RAL Classic code shows up in a stable readout. It sits at the intersection of two unforgiving problems: cameras lie about color, and "closest color" only means something if you pick the right color space.&lt;/p&gt;

&lt;p&gt;This post walks through the parts of the build that took the most iteration: why we throw away RGB before matching, why we don't trust a single pixel, and how a 213-color lookup ended up cheaper than the camera preview itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just match in RGB
&lt;/h2&gt;

&lt;p&gt;The naive version is two lines: read the pixel under the crosshair, run a Euclidean distance over the RAL table, return the smallest. We shipped that first. It was wrong in interesting ways.&lt;/p&gt;

&lt;p&gt;RGB is a display format, not a perceptual one. Two colors that look obviously different to a human can be RGB-near; two colors that look like the same beige can be RGB-far. The classic example is dark olive vs. dark teal — they're miles apart visually, surprisingly close in 8-bit RGB.&lt;/p&gt;

&lt;p&gt;For anything that has to agree with human judgment — paint matching, fabric, swatches — you want a perceptually uniform color space. CIE Lab is the standard one. Equal numerical distance ≈ equal perceived difference. The metric you compute over Lab is called &lt;strong&gt;ΔE&lt;/strong&gt; (delta-E).&lt;/p&gt;

&lt;p&gt;So the matching pipeline becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Camera frame → sRGB pixel&lt;/li&gt;
&lt;li&gt;sRGB → linear RGB (undo the gamma curve)&lt;/li&gt;
&lt;li&gt;Linear RGB → CIE XYZ (a 3×3 matrix, D65 illuminant)&lt;/li&gt;
&lt;li&gt;XYZ → Lab (a non-linear function with a cube-root term)&lt;/li&gt;
&lt;li&gt;Lab distance against every RAL color in the table → smallest wins&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–4 happen once per measurement. Step 5 runs against 213 RAL Classic entries — but only after we've &lt;strong&gt;pre-computed&lt;/strong&gt; their Lab values once at app start. Matching at runtime is a 213-iteration loop over three floats. The camera preview costs more than the match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;rgbToLab&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;FloatArray&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;srgbToLinear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;gl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;srgbToLinear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;srgbToLinear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;255f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// sRGB D65 → XYZ&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.4124f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.3576f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.1805f&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;y&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2126f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.7152f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.0722f&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;z&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.0193f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;gl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.1192f&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;bl&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.9505f&lt;/span&gt;

    &lt;span class="c1"&gt;// XYZ → Lab (D65 reference white)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fx&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;labF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;0.95047f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;labF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.00000f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fz&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;labF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1.08883f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;floatArrayOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="mf"&gt;116f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;fy&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;16f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="mf"&gt;500f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fx&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;fy&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="mf"&gt;200f&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fy&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;fz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;srgbToLinear&lt;/code&gt; is the standard piecewise gamma function; &lt;code&gt;labF&lt;/code&gt; is the cube-root-with-linear-tail. Both fit in five lines. The whole conversion is ~20 lines of Kotlin and runs in microseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  ΔE 76 vs ΔE 2000: why we kept the simple one
&lt;/h2&gt;

&lt;p&gt;The ΔE formula has gone through three revisions. ΔE 76 is plain Euclidean distance in Lab. ΔE 94 weights chroma and hue. ΔE 2000 (CIEDE2000) adds rotation terms to fix asymmetries in the blue region and is the modern standard for graphic-arts work.&lt;/p&gt;

&lt;p&gt;We tried 2000. We shipped with 76.&lt;/p&gt;

&lt;p&gt;The reason is honest: &lt;strong&gt;the camera is a much bigger source of error than the metric.&lt;/strong&gt; Auto white balance is shifting the color cast by ΔE 5–10 frame to frame. Sensor noise on a single pixel is ΔE 2–4. The difference between ΔE 76 and ΔE 2000 across the 213 RAL Classic colors is, in practice, a few edge-case calls — enough to matter for a print shop, not enough to matter when the user is holding a phone three feet from a couch under fluorescent light.&lt;/p&gt;

&lt;p&gt;Spending CPU on CIEDE2000 to shave error the camera can't deliver was a misallocation. We'll revisit if we add tristimulus colorimeter input — but that's a different product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sampling: one pixel is a lie
&lt;/h2&gt;

&lt;p&gt;A single pixel under the crosshair is too noisy to read straight. JPEG compression artifacts, sensor noise, and the fact that real-world surfaces have sub-pixel grain mean that two consecutive frames on the same target can return colors that are visibly different.&lt;/p&gt;

&lt;p&gt;Our v1 averaged a 5×5 patch under the reticle. Better, but still wrong on textured surfaces — woodgrain, brushed metal, fabric weave. The patch averages across high-frequency detail and drifts toward gray.&lt;/p&gt;

&lt;p&gt;The current heuristic: take the central pixel and a few neighbors at a fixed offset, drop the highest and lowest L* outliers, then average. It's not statistically rigorous — it's a cheap noise filter that respects the user's intent ("I pointed at &lt;em&gt;that&lt;/em&gt; spot, not that spot blurred with its neighbors"). The CameraX preview pipeline gives us the frame as a &lt;code&gt;YUV_420_888&lt;/code&gt; image; we sample directly from the Y/U/V planes rather than converting the whole frame to RGB and then sampling, which would be order-of-magnitude wasteful for a 5-point read.&lt;/p&gt;

&lt;p&gt;The next iteration on the v1.5 backlog is &lt;strong&gt;multi-point sampling&lt;/strong&gt; — three sample points in a small triangle under the crosshair, with the user able to see the auxiliary points. Average the three Lab values, return the match. It trades a small UI complication for noticeably more stable readings on textured material. The matching code already accepts a list of Lab samples, so the change is mostly UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RAL table is small enough that everything is free
&lt;/h2&gt;

&lt;p&gt;213 colors. Pre-converted to Lab at app start, that's 213 × 3 floats = ~2.5 KB in memory. The full RAL Classic name table with multilingual names is the larger asset — DE/IT/FR/EN/TR labels per code, around 30 KB in resources.&lt;/p&gt;

&lt;p&gt;Match cost: 213 × (3 subtractions + 3 squarings + 1 sqrt) per measurement. On a Pixel 6 it's well under a millisecond. The bottleneck for us is camera preview throughput (~30 fps), not the matcher.&lt;/p&gt;

&lt;p&gt;This is worth saying because it would be easy to over-engineer. A k-d tree in Lab space sounds clever and would be slower than the linear scan at this size — branch mispredictions and cache misses on a tree traversal lose to a tight loop over a contiguous float array. Bigger RAL sets — RAL Design (1625 colors), RAL Effect (490) — are on the v1.5+ backlog. Even at 1625, a linear scan over pre-computed Lab values is sub-millisecond. We won't reach for spatial indexing until we have a reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAL → HEX/RGB/CMYK: the inverse problem (sort of)
&lt;/h2&gt;

&lt;p&gt;In v1.4 we added a detail sheet: tap any RAL Classic card and you get HEX, RGB, and CMYK values for that code, each one-tap copyable.&lt;/p&gt;

&lt;p&gt;HEX and RGB are direct — RAL Classic ships with sRGB equivalents in its specification. CMYK is harder, and we ship it with a disclaimer.&lt;/p&gt;

&lt;p&gt;The honest answer: there is no single "correct" CMYK for a RAL code without a target ICC profile. CMYK is a process color space; the actual output depends on the printer, the ink set, the paper, and the rendering intent. What we ship is an indicative sRGB-to-CMYK conversion using a standard naive transform — useful for "I need a starting CMYK in Illustrator," not for "I'm going to color-manage a press run from this." The detail sheet shows the disclaimer next to the CMYK row. A designer who needs a press-accurate CMYK will run the swatch through their own ICC pipeline. We're not pretending to replace that.&lt;/p&gt;

&lt;h2&gt;
  
  
  CameraX, not Camera2
&lt;/h2&gt;

&lt;p&gt;Native Camera2 gives more control but the wiring is a small project. CameraX with the &lt;code&gt;Preview&lt;/code&gt; + &lt;code&gt;ImageAnalysis&lt;/code&gt; use-case binding is one screen of setup, handles lifecycle automatically, and gives us the &lt;code&gt;YUV_420_888&lt;/code&gt; analysis frames we need without writing a state machine.&lt;/p&gt;

&lt;p&gt;The one rough edge worth flagging: when you bundle &lt;code&gt;play-services-ads&lt;/code&gt; (we added an AdMob banner in v1.3) alongside CameraX, Gradle dependency resolution will try to merge &lt;code&gt;com.google.guava:guava:31.x&lt;/code&gt; (pulled by ads) with CameraX's &lt;code&gt;com.google.guava:listenablefuture&lt;/code&gt; stub. Build fails with &lt;code&gt;Cannot access ListenableFuture&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to use &lt;strong&gt;&lt;code&gt;play-services-ads-lite&lt;/code&gt;&lt;/strong&gt; instead of the full &lt;code&gt;play-services-ads&lt;/code&gt;. &lt;code&gt;lite&lt;/code&gt; drops the mediation transitive deps, including the conflicting Guava artifacts. Banner ads work fine; mediation networks would need the full dependency, but we don't run mediation. One-line fix in &lt;code&gt;build.gradle.kts&lt;/code&gt;, but it took an evening to find — leaving it here so the next person doesn't burn the same evening.&lt;/p&gt;

&lt;h2&gt;
  
  
  R8 is off, on purpose, for now
&lt;/h2&gt;

&lt;p&gt;We deploy from a 12 GB VPS. R8 minification under the standard Android Gradle Plugin defaults runs out of heap on this box — the daemon dies mid-pass, the build dies with it. AAB without R8 is ~13.8 MB; with R8 it would be 4–5 MB. Play's dynamic delivery softens the install-size impact, but it's still a real cost.&lt;/p&gt;

&lt;p&gt;Two ways out, both on the v1.5 backlog: build on a beefier machine (WSL on a 32 GB workstation, or GitHub Actions with the Play SA key as a secret), keep deploying from VPS via SCP. We'll likely move to the CI route — the 30-second AAB upload from VPS is fine, the 4-minute Gradle build is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's on the backlog
&lt;/h2&gt;

&lt;p&gt;The next two cycles are aimed at things that move the matching needle, not surface area:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-point sampling&lt;/strong&gt; (described above) — bigger stability win than any algorithm change at this point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broader RAL sets&lt;/strong&gt; — Classic is 213 colors and covers the most common asks; RAL Design (1625) is what professional painters actually want. Same pipeline, larger table, same sub-millisecond match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Color export&lt;/strong&gt; — CSV and Adobe &lt;code&gt;.ase&lt;/code&gt; swatch export from history + favorites. Designer/painter workflow. The data structure is already there; it's a serializer + share intent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We've avoided the temptation to chase ΔE 2000 or k-d trees. The error budget on this app is dominated by the camera, not the math, and the math is small enough that "linear scan over pre-computed Lab" is the right answer for the foreseeable future.&lt;/p&gt;




&lt;p&gt;RAL Picker landing page: &lt;a href="https://kavela.pro/apps/ralpicker/" rel="noopener noreferrer"&gt;kavela.pro/apps/ralpicker&lt;/a&gt; (Play Store link + screenshots there). It's a single-purpose tool — camera in, RAL code out, no account, no upload, no telemetry beyond the AdMob banner. The full RAL Classic table works offline.&lt;/p&gt;

&lt;p&gt;If you build perceptual color tools and want to compare notes on sampling strategies or larger RAL sets, drop a comment.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>computervision</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Pruning a Sitemap From 327,621 URLs to 2,483 (And Why That Was the Right Call)</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Thu, 07 May 2026 19:49:40 +0000</pubDate>
      <link>https://dev.to/kavelaltd/pruning-a-sitemap-from-327621-urls-to-2483-and-why-that-was-the-right-call-61o</link>
      <guid>https://dev.to/kavelaltd/pruning-a-sitemap-from-327621-urls-to-2483-and-why-that-was-the-right-call-61o</guid>
      <description>&lt;p&gt;&lt;a href="https://evrangeguide.com" rel="noopener noreferrer"&gt;evrangeguide.com&lt;/a&gt; is a programmatic SEO site for EV charging routes — 27 countries, 370 cities, 24 vehicles, and over two thousand canonical inter-city routes. When we totaled the URL space across every page-type combination, we got &lt;strong&gt;327,621 URLs&lt;/strong&gt;. We listed every one of them in the sitemap.&lt;/p&gt;

&lt;p&gt;Google's response was decisive: it indexed almost none of them, and the crawl-budget metrics in Search Console got worse week over week.&lt;/p&gt;

&lt;p&gt;This is the story of cutting that sitemap by 99.2% — down to 2,483 URLs — and why scarcity beat coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;ev-engine is the same family of virtual-routing plugin we use across &lt;a href="https://dev.to/kavelaltd/how-we-built-a-programmatic-seo-engine-serving-80k-pages-on-wordpress-without-using-wpposts-2kgn"&gt;the network&lt;/a&gt;. No &lt;code&gt;wp_posts&lt;/code&gt;. The data lives in custom tables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ev_routes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,044&lt;/td&gt;
&lt;td&gt;inter-city routes (from-to pairs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ev_cities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;370&lt;/td&gt;
&lt;td&gt;city metadata + lat/lng&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ev_countries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;country hubs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ev_vehicles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;EV models with battery + range specs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ev_route_stations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~2.36M&lt;/td&gt;
&lt;td&gt;charger stops along each route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ev_route_vehicles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;147K&lt;/td&gt;
&lt;td&gt;per-vehicle stop plans (route × vehicle)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From these, the router (&lt;code&gt;EV_Router&lt;/code&gt;) projects URL patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/route/{from}-to-{to}/&lt;/code&gt; — 2,044 base routes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/route/{from}-to-{to}/{vehicle}/&lt;/code&gt; — 2,044 × 24 = ~49K route-vehicle pages&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/route/{from}-to-{to}/{tier}/&lt;/code&gt; — long/mid/short-range tier views&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/city/{slug}/&lt;/code&gt;, &lt;code&gt;/country/{slug}/&lt;/code&gt;, &lt;code&gt;/ev/{vehicle}/&lt;/code&gt;, &lt;code&gt;/glamour/{slug}/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Index pages: &lt;code&gt;/routes/&lt;/code&gt;, &lt;code&gt;/cities/&lt;/code&gt;, &lt;code&gt;/countries/&lt;/code&gt;, &lt;code&gt;/vehicles/&lt;/code&gt;, &lt;code&gt;/glamour/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Multilingual prefixes for &lt;code&gt;tr/de/fr/es&lt;/code&gt; (UI-translated, body still EN)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Multiplied across language prefixes and tier slices, the total grew past 327K.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we listed all of them
&lt;/h2&gt;

&lt;p&gt;The naive pSEO instinct: more URLs is more surface area. If a long-tail searcher types "Tesla Model 3 Berlin to Munich charging stops," we wanted a page tuned to &lt;em&gt;that exact query&lt;/em&gt;. So we generated &lt;code&gt;/route/berlin-to-munich/tesla-model-3/&lt;/code&gt; and 49K of its siblings, then submitted them all.&lt;/p&gt;

&lt;p&gt;Google's reality: for a new EV-niche domain with low brand authority, indexing budget is allocated as a function of trust × content uniqueness × discovery cost. We were maximizing the third variable while having close to zero of the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data showed
&lt;/h2&gt;

&lt;p&gt;Two months in, GSC had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~327K URLs &lt;strong&gt;discovered&lt;/strong&gt; but flagged "Crawled – currently not indexed" or "Discovered – currently not indexed"&lt;/li&gt;
&lt;li&gt;A handful of routes indexed; most of the indexed pages were the small set of &lt;em&gt;unique&lt;/em&gt; hubs (countries, vehicles)&lt;/li&gt;
&lt;li&gt;Crawl rate stuck low because Google was sampling thousands of near-duplicate route-vehicle and tier slices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The route-vehicle pages were structurally near-duplicate: same route metadata, same map, same station list, with one paragraph of vehicle-specific stop math. The tier pages (long/mid/short-range) were even worse — they sliced the same route by an arbitrary battery-range threshold.&lt;/p&gt;

&lt;p&gt;We had built thousands of pages that, from Google's perspective, were dilutions of one good page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prune
&lt;/h2&gt;

&lt;p&gt;We kept what was structurally unique and dropped the rest from the sitemap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page type&lt;/th&gt;
&lt;th&gt;Kept in sitemap&lt;/th&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Routes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2,044&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;indexable, in sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cities&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;370&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;indexable, in sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Countries&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;indexable, in sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vehicles&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;indexable, in sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glamour pages&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;curated highlights, in sitemap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route × tier&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;noindex, follow&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route × vehicle&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;noindex, follow&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multilingual (non-EN)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;noindex&lt;/code&gt; (UI translation only, body EN)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The templates that emit &lt;code&gt;noindex, follow&lt;/code&gt; (&lt;code&gt;route-range.php&lt;/code&gt;, &lt;code&gt;route-vehicle-weather.php&lt;/code&gt;) keep crawl flow going — internal links from these pages still pass discovery to the canonical route page — but the pages themselves do not compete for index slots. New sitemap total: &lt;strong&gt;2,483 URLs&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The single-DB migration that made this possible
&lt;/h2&gt;

&lt;p&gt;A quick aside, because it's part of the same story. Until early May, the EV data tables (&lt;code&gt;wp_ev_*&lt;/code&gt;) lived on a separate Hostinger Business database, while the WP core tables lived on contabo. The plugin had a custom &lt;code&gt;ev_data_db()&lt;/code&gt; function that returned a second &lt;code&gt;wpdb&lt;/code&gt; connection.&lt;/p&gt;

&lt;p&gt;This worked for a year, then quietly broke after a Hostinger network change: cross-host queries started timing out under crawler load. The fix:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Whitelist contabo's IP on Hostinger Remote MySQL (&lt;code&gt;92.113.22.128&lt;/code&gt;, IPv4-forced — their host is IPv4-only).&lt;/li&gt;
&lt;li&gt;Dump the EV tables (88MB gzipped) and import into &lt;code&gt;wp_evrangeguide&lt;/code&gt; on contabo's local MariaDB.&lt;/li&gt;
&lt;li&gt;Patch &lt;code&gt;ev_data_db()&lt;/code&gt; to return &lt;code&gt;$wpdb&lt;/code&gt; directly. Single connection from then on.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;ev_data_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While we were in there we caught one more bug: a related-routes query that was casting distance into a &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; and underflowing on "distance less than threshold" comparisons. Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;distance_km&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;SIGNED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that cast, MariaDB was returning empty result sets for any negative comparison (which is half of all related-route queries).&lt;/p&gt;

&lt;h2&gt;
  
  
  IndexNow + GSC API
&lt;/h2&gt;

&lt;p&gt;We re-submit the pruned sitemap via the GSC Sitemaps API after each rebuild. The shared service account (&lt;code&gt;nererdenizlenir@dekorfiyat.iam.gserviceaccount.com&lt;/code&gt;) is &lt;code&gt;siteOwner&lt;/code&gt; on every property in our network, so a single key lets us PUT to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.googleapis.com/webmasters/v3/sites/{site}/sitemaps/{feedpath}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns 204; Google re-fetches the sitemap within 24h. We push fresh URL changes via IndexNow (key at the webroot, batched to &lt;code&gt;api.indexnow.org&lt;/code&gt;). Google's old &lt;code&gt;/ping?sitemap=&lt;/code&gt; endpoint is fully deprecated since 2023, so the GSC API path is the only programmatic re-submit channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd tell our March-self
&lt;/h2&gt;

&lt;p&gt;For a new pSEO domain, the right shape of the sitemap is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hubs first.&lt;/strong&gt; Cities, countries, vehicles, categories — these are the pages with strongest internal-link equity and the easiest to make uniquely useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One canonical layer per long-tail query.&lt;/strong&gt; Routes go in; route-vehicle and route-tier slices stay live but &lt;code&gt;noindex, follow&lt;/code&gt;. Internal navigation is the discovery path; the sitemap is the indexing intent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap multilingual to noindex&lt;/strong&gt; until you have the translation budget for genuine localization. Body-language mismatch (English body under a &lt;code&gt;de/&lt;/code&gt; URL) is worse than no localization at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resubmit via the GSC API after every prune.&lt;/strong&gt; It's a 204 response. Don't wait for Googlebot to discover the change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch "Discovered – currently not indexed" as a primary metric, not a footnote.&lt;/strong&gt; If it's growing faster than "Indexed," you're polluting the surface area.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you're sitting on 327K URLs and Google is indexing a few hundred, the fastest path to better indexing isn't more content. It's less surface area, sharper hubs, and patience.&lt;/p&gt;

&lt;p&gt;Live site: &lt;a href="https://evrangeguide.com" rel="noopener noreferrer"&gt;evrangeguide.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>seo</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Why Every Internal Link On Our Site Was a 404 (And the Smart-301 That Fixed It)</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Thu, 07 May 2026 19:46:04 +0000</pubDate>
      <link>https://dev.to/kavelaltd/why-every-internal-link-on-our-site-was-a-404-and-the-smart-301-that-fixed-it-3iko</link>
      <guid>https://dev.to/kavelaltd/why-every-internal-link-on-our-site-was-a-404-and-the-smart-301-that-fixed-it-3iko</guid>
      <description>&lt;p&gt;&lt;a href="https://beverageindex.com" rel="noopener noreferrer"&gt;BeverageIndex.com&lt;/a&gt; is a programmatic SEO site indexing drink prices across 98 cities and 30 drinks. 3,728 indexable URLs. We launched it in March, submitted the sitemap to Google Search Console, and watched indexing flatline at zero for two full months.&lt;/p&gt;

&lt;p&gt;When we finally crawled our own site with a friendly bot, the cause was embarrassingly simple: &lt;strong&gt;every drink and city link in our internal navigation pointed to a URL that returned 404.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;bi-engine is a virtual-routing WordPress plugin (no &lt;code&gt;wp_posts&lt;/code&gt;) backed by these tables:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_bi_drinks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;id=slug; category enum: coffee/beer/wine/spirits/tea/juice/specialty/soft_drinks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_bi_cities&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;98&lt;/td&gt;
&lt;td&gt;id=slug, country, currency, lat/lng&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_bi_prices&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,940&lt;/td&gt;
&lt;td&gt;drink × city, USD-normalized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_bi_routes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,727&lt;/td&gt;
&lt;td&gt;pre-computed &lt;code&gt;/{drink}/{city}/&lt;/code&gt; paths&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The theme is unusual for a pSEO site: &lt;strong&gt;a single SPA terminal template&lt;/strong&gt; (&lt;code&gt;template-terminal.php&lt;/code&gt;) hydrates from a JSON payload + &lt;code&gt;/wp-json/bi/v1/enriched&lt;/code&gt;. Country hubs, city hubs, drink hubs, category hubs, and city × drink price grids all render through the same client-side template, distinguished by URL pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL patterns
&lt;/h2&gt;

&lt;p&gt;The canonical paths are namespaced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/category/{slug}/&lt;/code&gt; — coffee, beer, wine, etc.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/city/{slug}/&lt;/code&gt; — amsterdam, tokyo, istanbul…&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/drink/{slug}/&lt;/code&gt; — americano, cappuccino, coca-cola…&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/country/{slug}/&lt;/code&gt; — by name slug or ISO2&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/{drink}/{city}/&lt;/code&gt; — 3,727 price grids (e.g. &lt;code&gt;/americano/amsterdam/&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last pattern is the workhorse. It's also where the sitemap gets most of its URLs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Look at any internal hub page on the site. The HTML is full of links like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/americano/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Americano&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/amsterdam/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Amsterdam&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/coffee/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Coffee&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not &lt;code&gt;/drink/americano/&lt;/code&gt;. Not &lt;code&gt;/city/amsterdam/&lt;/code&gt;. &lt;strong&gt;Slug-only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And slug-only didn't have a route. Googlebot crawled the homepage, followed every drink link, and got 404. Crawled the city hub, followed every drink × city link → those worked because &lt;code&gt;/{drink}/{city}/&lt;/code&gt; was registered. But the hub anchors themselves were dead.&lt;/p&gt;

&lt;p&gt;We had two reasonable choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite all the internal links&lt;/strong&gt; to use the canonical &lt;code&gt;/category/&lt;/code&gt;, &lt;code&gt;/drink/&lt;/code&gt;, &lt;code&gt;/city/&lt;/code&gt;, &lt;code&gt;/country/&lt;/code&gt; prefixes. Hundreds of edits across the SPA template and JSON payload generators.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make slug-only URLs work&lt;/strong&gt;, by 301-redirecting them to the canonical prefixed form.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We picked (2) because it's also good externally — third parties linking to &lt;code&gt;beverageindex.com/cappuccino/&lt;/code&gt; will resolve correctly forever, even if our internal nav drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smart-301
&lt;/h2&gt;

&lt;p&gt;We added a single rewrite rule, &lt;code&gt;bi_slug_only&lt;/code&gt;, plus a handler that does a 4-way disambiguation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;maybe_slug_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Drink?&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;drink_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/drink/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. City?&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;city_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/city/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// 3. Category? (URL hyphen ↔ DB underscore)&lt;/span&gt;
    &lt;span class="nv"&gt;$cat_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;str_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cat_db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/category/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// 4. Country? (ISO2 or full-name slug)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;country_lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$slug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;wp_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"/country/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$country&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;301&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Unknown — explicit 404 with theme template&lt;/span&gt;
    &lt;span class="nf"&gt;status_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;include&lt;/span&gt; &lt;span class="nf"&gt;get_404_template&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters: drinks shadow categories shadow countries. We had two collisions to handle — &lt;code&gt;coffee&lt;/code&gt; and &lt;code&gt;soft-drinks&lt;/code&gt; are both potential drink slugs and category slugs. They're categories, so we resolved by reordering the lookups (drink table first, but neither &lt;code&gt;coffee&lt;/code&gt; nor &lt;code&gt;soft_drinks&lt;/code&gt; is in &lt;code&gt;wp_bi_drinks&lt;/code&gt;, so they fall through to the category check correctly).&lt;/p&gt;

&lt;h2&gt;
  
  
  The other 301 we didn't notice
&lt;/h2&gt;

&lt;p&gt;While fixing this, we caught a second crawl-budget leak. The sitemap index &lt;code&gt;bi-sitemap.xml&lt;/code&gt; and its five children (&lt;code&gt;bi-sitemap-cities.xml&lt;/code&gt;, &lt;code&gt;bi-sitemap-drinks.xml&lt;/code&gt;, &lt;code&gt;bi-sitemap-categories.xml&lt;/code&gt;, &lt;code&gt;bi-sitemap-countries.xml&lt;/code&gt;, &lt;code&gt;bi-sitemap-prices-1.xml&lt;/code&gt;) were all 301-redirecting to a trailing-slash variant before serving. Every Googlebot fetch was burning two requests instead of one.&lt;/p&gt;

&lt;p&gt;WordPress's &lt;code&gt;redirect_canonical&lt;/code&gt; filter was the culprit. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'redirect_canonical'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'skip_canonical_for_sitemap'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;skip_canonical_for_sitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$redirect_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$requested_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$requested_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bi-sitemap'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$redirect_url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now all six sitemap files return 200 directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  IndexNow: not all engines treat all domains equally
&lt;/h2&gt;

&lt;p&gt;We push fresh slugs to IndexNow on publish. beverageindex's IndexNow key (&lt;code&gt;0047e09268ff…ccb&lt;/code&gt;) is happily accepted by both Yandex (HTTP 202) and &lt;code&gt;api.indexnow.org&lt;/code&gt; / Bing (HTTP 200).&lt;/p&gt;

&lt;p&gt;Meanwhile, &lt;a href="https://assetvs.com" rel="noopener noreferrer"&gt;our sister site assetvs.com&lt;/a&gt; gets &lt;code&gt;403 UserForbiddedToAccessSite&lt;/code&gt; from &lt;code&gt;api.indexnow.org&lt;/code&gt;, while Yandex still accepts. Same key format, same payload, different domain reputation — assetvs probably tripped a Bing trust signal during its 2-month zero-indexing window. The lesson: if you operate a network, don't assume IndexNow status from one domain transfers to another. Test each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where indexing actually stands
&lt;/h2&gt;

&lt;p&gt;Fix shipped. Sitemap re-submitted via the GSC Sitemaps API (PUT returns 204). 3,728 URLs are now linkable from anywhere on the site. Whether that converts into indexing in the next two weeks is the real test.&lt;/p&gt;

&lt;p&gt;The broader lesson is one we've now repeated across multiple network properties: &lt;strong&gt;for a new pSEO domain, the technical indexing tax shows up as silent 4xx noise, not as a single dramatic failure.&lt;/strong&gt; A trailing-slash 301 here, a 404'd internal link there, a missing FX table on another property. None of it is loud. All of it compounds.&lt;/p&gt;

&lt;p&gt;Live site: &lt;a href="https://beverageindex.com" rel="noopener noreferrer"&gt;beverageindex.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>seo</category>
      <category>webdev</category>
      <category>php</category>
    </item>
    <item>
      <title>Lighting Up 2,232 Dead URLs With One World Bank API Call</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Thu, 07 May 2026 19:46:03 +0000</pubDate>
      <link>https://dev.to/kavelaltd/lighting-up-2232-dead-urls-with-one-world-bank-api-call-1djf</link>
      <guid>https://dev.to/kavelaltd/lighting-up-2232-dead-urls-with-one-world-bank-api-call-1djf</guid>
      <description>&lt;p&gt;On &lt;a href="https://assetvs.com" rel="noopener noreferrer"&gt;assetvs.com&lt;/a&gt; we run a programmatic SEO site comparing investment returns across 16 countries and 5 asset classes (stocks, bonds, real estate, bills, gold). The site has 3,655 URLs in the sitemap. Last week we discovered that &lt;strong&gt;2,232 of them — every currency-pair page — were silently 404ing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the story of how one missing table turned a third of our site dark, and how a single World Bank endpoint brought it all back online in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture (quick recap)
&lt;/h2&gt;

&lt;p&gt;assetvs uses the same virtual-routing pattern we've written about &lt;a href="https://dev.to/kavelaltd/how-we-built-a-programmatic-seo-engine-serving-80k-pages-on-wordpress-without-using-wpposts-2kgn"&gt;before on the network&lt;/a&gt;: no &lt;code&gt;wp_posts&lt;/code&gt;, just custom tables and rewrite rules. The relevant tables are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Table&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ir_countries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;G7 + EU + ANZ + JP, ISO codes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ir_assets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;stocks/bonds/real-estate/bills/gold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ir_annual_returns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,955&lt;/td&gt;
&lt;td&gt;nominal returns, country × asset × year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ir_inflation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;976&lt;/td&gt;
&lt;td&gt;for real-return calculation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ir_comparisons&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;full-period asset-vs-asset summaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wp_ir_exchange_rates&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 → 976&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;currency pair history&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the punchline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL patterns
&lt;/h2&gt;

&lt;p&gt;The router (&lt;code&gt;IR_Router&lt;/code&gt;) maps slugs onto eight page types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/{country}/&lt;/code&gt; and &lt;code&gt;/{asset}/&lt;/code&gt; — 21 hubs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/{country}/{asset}/&lt;/code&gt; — 80 country-asset combos&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/{country}/{asset_a}-vs-{asset_b}/&lt;/code&gt; — 100 comparisons (alphabetical canonical)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/{country}/{asset_a}-vs-{asset_b}/{decade}/&lt;/code&gt; — sliced by 1960s–2010s&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/{asset}/{country_a}-vs-{country_b}/&lt;/code&gt; — 600 cross-country comparisons&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/currencies/{a}-{b}/&lt;/code&gt; and &lt;code&gt;/currencies/{a}-{b}/{year}/&lt;/code&gt; — &lt;strong&gt;2,232 currency pages&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/rankings/{asset}/&lt;/code&gt;, &lt;code&gt;/about/&lt;/code&gt;, &lt;code&gt;/data/&lt;/code&gt;, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything renders server-side from the custom tables. No posts, no taxonomies, no theme fight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, hiding in plain sight
&lt;/h2&gt;

&lt;p&gt;The currency builder (&lt;code&gt;build_currency_pair&lt;/code&gt;) had this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get_fx_pair_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$history&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...render the page&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;null&lt;/code&gt; from the page builder cascades up to a 404. Reasonable defense — but &lt;code&gt;wp_ir_exchange_rates&lt;/code&gt; had &lt;strong&gt;never been populated&lt;/strong&gt;. We'd built the schema, written the renderer, generated 2,232 rewrite rules, listed the URLs in the sitemap, and submitted them to Google Search Console for two months. All of them 404.&lt;/p&gt;

&lt;p&gt;GSC's coverage report had been flagging "Not found (404)" for weeks, but with 3,655 URLs and zero indexed pages overall (new domain trust issues), nobody had drilled into &lt;em&gt;which&lt;/em&gt; URLs were 404 vs which were just unindexed-but-live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one API
&lt;/h2&gt;

&lt;p&gt;The World Bank publishes annual official exchange rates as indicator &lt;code&gt;PA.NUS.FCRF&lt;/code&gt;, free, no auth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.worldbank.org/v2/country/{ISO}/indicator/PA.NUS.FCRF?date=1960:2020&amp;amp;format=json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each of our 16 countries, that's one HTTP call returning 61 annual rates against USD. Sixteen calls total. We wrote a one-shot fill script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$countries&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wb_fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;iso2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$year&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'wp_ir_exchange_rates'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'country_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'year'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'rate_per_usd'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'yoy_change'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$yoy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: 976 rows. The currency pair builder derives any &lt;code&gt;(A, B)&lt;/code&gt; pair by triangulating through USD — so 16 base currencies give us C(16,2) × 2 directions × 61 years × a few view modes = the 2,232 URLs we'd been promising Google for two months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons we keep relearning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. "Sitemap submitted" ≠ "page renders."&lt;/strong&gt; GSC will happily accept a sitemap of 404s and quietly file them under "Discovered – currently not indexed" or "Not found." There is no alarm. Build a synthetic monitor that hits one URL per page-type weekly and asserts HTTP 200. We'd have caught this in March.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Returning &lt;code&gt;null&lt;/code&gt; from a builder is a foot-gun.&lt;/strong&gt; The render path collapses three failure modes into one 404: missing data, missing slug, and genuinely-unknown URL. Now we log a distinct warning when a &lt;em&gt;known-routed&lt;/em&gt; slug returns null, so missing tables surface in error logs instead of just the GSC dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. New-domain GSC trust dwarfs technical issues.&lt;/strong&gt; Even with the FX table filled, indexing didn't snap back overnight. We're at zero indexed pages two months in, and lighting up the currency tier is a prerequisite for indexing — but not sufficient. Content depth is next: stock index names per country, country-level macro from World Bank, narrative blocks generated through our LLM pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Cross-site editorial linking is cheap and free crawl signal.&lt;/strong&gt; We added a &lt;code&gt;historysaid_for_country($slug)&lt;/code&gt; linker that pulls the top 5 inquiries citing the country slug from &lt;code&gt;historysaid.com/wp-json/hs/v1/inquiries/by-country/{slug}&lt;/code&gt; (12-hour transient cache) and renders them as a "Further Reading" block. United States gets 8 inquiries, Germany 10, UK 10. Two sites, one network, free internal-link equity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fill &lt;code&gt;stock_index_name&lt;/code&gt; per country (S&amp;amp;P 500, FTSE 100, DAX, Nikkei…) so each &lt;code&gt;/{country}/stocks/&lt;/code&gt; page has a named anchor instead of generic "stocks."&lt;/li&gt;
&lt;li&gt;Pull World Bank macro (GDP per capita, inflation regime, currency volatility) into country hubs.&lt;/li&gt;
&lt;li&gt;Generate narrative paragraphs (~150 words) per country-asset page through our LLM pool — same enrichment pattern we use on &lt;a href="https://historysaid.com/" rel="noopener noreferrer"&gt;HistorySaid's 18,304 indicator pages&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The larger lesson: when 2,232 URLs go dark, it usually isn't an SEO problem. It's a missing INSERT.&lt;/p&gt;

&lt;p&gt;Live site: &lt;a href="https://assetvs.com" rel="noopener noreferrer"&gt;assetvs.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>seo</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>Turning World Bank Data Into 50K+ Searchable Pages with WordPress</title>
      <dc:creator>kavela</dc:creator>
      <pubDate>Tue, 24 Mar 2026 11:28:45 +0000</pubDate>
      <link>https://dev.to/kavelaltd/turning-world-bank-data-into-50k-searchable-pages-with-wordpress-2ojp</link>
      <guid>https://dev.to/kavelaltd/turning-world-bank-data-into-50k-searchable-pages-with-wordpress-2ojp</guid>
      <description>&lt;p&gt;What if you could make decades of World Bank and IMF economic data actually accessible and browsable - not buried in spreadsheets and PDF reports that nobody reads?&lt;/p&gt;

&lt;p&gt;That's what we built with &lt;a href="https://historysaid.com" rel="noopener noreferrer"&gt;historysaid.com&lt;/a&gt;: a programmatic SEO site that transforms raw international development data into &lt;strong&gt;50,000+ structured, searchable pages&lt;/strong&gt;. Every country, every indicator, every year - all queryable, all browsable, all indexed by Google.&lt;/p&gt;

&lt;p&gt;This post covers the architectural thinking behind it and what we learned building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Problem
&lt;/h2&gt;

&lt;p&gt;The World Bank and IMF publish some of the richest economic datasets on the planet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GDP, inflation, trade balances, debt levels for 200+ countries&lt;/li&gt;
&lt;li&gt;Time series spanning 60+ years (some indicators go back to the 1960s)&lt;/li&gt;
&lt;li&gt;Hundreds of unique economic indicators covering everything from agricultural output to internet penetration rates&lt;/li&gt;
&lt;li&gt;Regular updates as new data gets published quarterly or annually&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the official portals are designed for researchers and economists who already know what they're looking for. You need to know specific indicator codes and use clunky query builders to extract data into spreadsheets.&lt;/p&gt;

&lt;p&gt;There's no way to just... explore. To browse. To stumble upon interesting economic stories by clicking around.&lt;/p&gt;

&lt;p&gt;We wanted to change that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Build a Dashboard?
&lt;/h2&gt;

&lt;p&gt;We considered building a single-page dashboard app with interactive charts and filters. But dashboards have a fundamental SEO problem: they're one URL. Google can't index the state of your filters. If someone searches "Turkey GDP growth history", a dashboard app won't rank because that specific view doesn't have its own URL.&lt;/p&gt;

&lt;p&gt;Programmatic SEO solves this. Each unique combination of country + indicator gets its own page, its own URL, its own title, and its own meta description. Google can index all 50K of them.&lt;/p&gt;

&lt;p&gt;We chose WordPress for the same reasons we used it for startup-cost.com (see our &lt;a href="https://dev.to/kavelaltd/how-we-built-a-programmatic-seo-engine-serving-80k-pages-on-wordpress-without-using-wpposts-2kgn"&gt;previous post&lt;/a&gt;): cheap hosting, familiar ecosystem, and a powerful rewrite engine that nobody uses to its full potential.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture - Overview
&lt;/h2&gt;

&lt;p&gt;We built a custom WordPress plugin that handles everything from data ingestion to page rendering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Pipeline
&lt;/h3&gt;

&lt;p&gt;The data flows through several stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;World Bank API --&amp;gt; Fetch &amp;amp; Parse --&amp;gt; Validate --&amp;gt; Normalize --&amp;gt; MySQL
IMF Data Portal --&amp;gt; Fetch &amp;amp; Parse --&amp;gt; Validate --&amp;gt; Normalize --&amp;gt; MySQL
                                                                  |
MySQL --&amp;gt; Virtual URL Routing --&amp;gt; Template Engine --&amp;gt; HTML Page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each data source has its own ingestion logic because the API formats differ significantly. The World Bank provides a well-documented REST API with JSON responses, while IMF data comes in a different structure. We wrote adapters that normalize both into a common internal format.&lt;/p&gt;

&lt;p&gt;The pipeline runs on a scheduled basis. When new data is published by either source, our next run picks it up automatically and updates the relevant records.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data Quality Challenges
&lt;/h3&gt;

&lt;p&gt;Working with international economic data is messier than you'd expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Missing values everywhere&lt;/strong&gt; - Some countries don't report certain indicators for certain years. We handle nulls gracefully rather than showing zeros.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delayed reporting&lt;/strong&gt; - Some nations publish data 2-3 years late. Our pages show the most recent available data and clearly indicate the time period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit inconsistency&lt;/strong&gt; - Some values are in current USD, some in constant USD, some in percentages. Each indicator carries its unit metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Country code mismatches&lt;/strong&gt; - The World Bank uses ISO 3166-1 alpha-3 codes, the IMF sometimes uses its own codes. Our normalization layer handles the mapping.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Database Design Principles
&lt;/h3&gt;

&lt;p&gt;We use custom MySQL tables (not &lt;code&gt;wp_posts&lt;/code&gt;) following the same pattern from our startup-cost.com engine. The key design decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proper normalization&lt;/strong&gt; - Countries, indicators, and data points are separate tables with foreign key relationships&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appropriate data types&lt;/strong&gt; - We use high-precision decimal types for economic values because the data ranges from tiny percentages to trillion-dollar GDP figures. Floating point would introduce precision errors that data-savvy users would notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strategic indexing&lt;/strong&gt; - Our most common query patterns (all data for a country+indicator, all countries for an indicator+year) each have compound indexes that resolve in single-digit milliseconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roughly 4 million data points&lt;/strong&gt; in the main table, all queryable in under 10ms thanks to proper indexing&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Page Types
&lt;/h3&gt;

&lt;p&gt;Our routing creates four types of pages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Country pages&lt;/strong&gt; - Overview of all available indicators for a country. Shows key stats, latest values, and links to explore each indicator in depth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Country + Indicator pages&lt;/strong&gt; - The core of the site. Detailed time series data with charts, data tables, summary statistics, and trend analysis. This is the bulk of our 50K pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indicator pages&lt;/strong&gt; - Global comparison view. Shows all countries ranked by a specific indicator, with the ability to see how they compare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparison pages&lt;/strong&gt; - Side-by-side country comparisons for a given indicator. Perfect for searches like "Japan vs South Korea GDP."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Zero rows in &lt;code&gt;wp_posts&lt;/code&gt;. Everything is computed from data tables on each request (with caching for popular pages).&lt;/p&gt;

&lt;h3&gt;
  
  
  Charts and Data Display
&lt;/h3&gt;

&lt;p&gt;Each data page includes an interactive chart (using a lightweight client-side charting library) and a full data table. The chart data is embedded as JSON in the page - fast, cacheable, and SEO-friendly since the actual values are also present in the HTML table.&lt;/p&gt;

&lt;p&gt;We also calculate and display summary statistics: latest value, historical min/max, average, and trend direction. These make each page genuinely informative rather than just a raw data dump.&lt;/p&gt;

&lt;h3&gt;
  
  
  SEO Strategy
&lt;/h3&gt;

&lt;p&gt;Every page gets unique, data-driven SEO elements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic titles&lt;/strong&gt; that include the country name and indicator name&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta descriptions&lt;/strong&gt; that include actual data values ("Turkey's GDP was $X in 2024. Explore the full trend from 1960 to 2024...")&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema.org Dataset markup&lt;/strong&gt; so Google understands these are data pages with temporal and spatial coverage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breadcrumb navigation&lt;/strong&gt; for clear site hierarchy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each meta description contains real numbers from the data, making every page genuinely unique in Google's eyes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching
&lt;/h3&gt;

&lt;p&gt;With 50K+ pages, not everything can be pre-cached. We use a tiered approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Popular combinations&lt;/strong&gt; (major countries + major indicators) get pre-cached with longer TTLs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium-traffic pages&lt;/strong&gt; are cached on demand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-tail pages&lt;/strong&gt; have shorter TTLs and are generated fresh when needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also cache aggregated data (like country rankings and regional averages) at the query level since multiple pages reference the same aggregations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Internal Linking
&lt;/h3&gt;

&lt;p&gt;Strong internal linking is essential for a site this large. Without it, search engines would never discover most of the pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each country page links to all available indicators for that country&lt;/li&gt;
&lt;li&gt;Each indicator page links to top countries for that indicator&lt;/li&gt;
&lt;li&gt;Breadcrumbs on every page create clear hierarchy&lt;/li&gt;
&lt;li&gt;Related content suggestions based on geographic and thematic proximity&lt;/li&gt;
&lt;li&gt;Comparison links suggest relevant country pairs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The internal link graph ensures that any page on the site is reachable within 3-4 clicks from the homepage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results After 12 Months
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;50,000+ pages indexed&lt;/strong&gt; in Google Search Console&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Average TTFB: ~150ms&lt;/strong&gt; on shared hosting&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database queries consistently under 10ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero editorial work&lt;/strong&gt; - the site runs itself, updated automatically from source data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Growing organic traffic&lt;/strong&gt; from long-tail searches like "Nigeria inflation rate 2015" or "Vietnam GDP per capita history"&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Reusable Pattern
&lt;/h2&gt;

&lt;p&gt;This is the same architectural pattern we use across multiple sites at Kavela:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find an interesting, structured dataset&lt;/li&gt;
&lt;li&gt;Design custom tables optimized for the specific data model&lt;/li&gt;
&lt;li&gt;Build a data pipeline that keeps the database fresh&lt;/li&gt;
&lt;li&gt;Use WordPress virtual routing to create SEO-friendly URLs&lt;/li&gt;
&lt;li&gt;Render pages dynamically from the data&lt;/li&gt;
&lt;li&gt;Generate chunked sitemaps and build strong internal links&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key is making sure every page offers genuine value - real data, real calculations, real insights. Template spam with swapped-out city names won't work. Search engines are smart enough to detect that. But if every page genuinely answers a different question with different data, you've built something valuable.&lt;/p&gt;

&lt;p&gt;Explore it yourself: &lt;a href="https://historysaid.com" rel="noopener noreferrer"&gt;historysaid.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by Kavela Ltd - turning data into discoverable web experiences.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>data</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
