<?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: Sedat Ali Zevit</title>
    <description>The latest articles on DEV Community by Sedat Ali Zevit (@seqat).</description>
    <link>https://dev.to/seqat</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1953121%2F7623d4eb-0a56-4c60-8b48-34c77867a25e.jpg</url>
      <title>DEV Community: Sedat Ali Zevit</title>
      <link>https://dev.to/seqat</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seqat"/>
    <language>en</language>
    <item>
      <title>Zero backend, zero cost — and three silent bugs I didn't see coming</title>
      <dc:creator>Sedat Ali Zevit</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:12:58 +0000</pubDate>
      <link>https://dev.to/seqat/zero-backend-zero-cost-and-three-silent-bugs-i-didnt-see-coming-pa1</link>
      <guid>https://dev.to/seqat/zero-backend-zero-cost-and-three-silent-bugs-i-didnt-see-coming-pa1</guid>
      <description>&lt;p&gt;Every Thursday, Epic gives away a game. Steam runs weekend freebies. Amazon Prime drops a batch that quietly expires two weeks later. Three tabs, three UIs, three different ways of saying "this offer ends soon."&lt;/p&gt;

&lt;p&gt;I kept missing claims, so I built &lt;strong&gt;OpenClaim&lt;/strong&gt; — an open-source tracker that aggregates all three, refreshes itself every four hours, and costs exactly nothing to run. No server, no database, no runtime API calls.&lt;/p&gt;

&lt;p&gt;The architecture is boring in a good way, and I'll cover it below. But the part worth your time is what happened &lt;em&gt;after&lt;/em&gt; it worked: three bugs that produced no errors, no failed builds, and no stack traces. The site kept serving pages. The data was just quietly wrong.&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://seqat.github.io/OpenClaim/" rel="noopener noreferrer"&gt;seqat.github.io/OpenClaim&lt;/a&gt;&lt;br&gt;
💻 &lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/Seqat/OpenClaim" rel="noopener noreferrer"&gt;github.com/Seqat/OpenClaim&lt;/a&gt; (MIT)&lt;/p&gt;


&lt;h2&gt;
  
  
  The architecture, in one paragraph
&lt;/h2&gt;

&lt;p&gt;There is no backend. A GitHub Actions cron job runs a Python pipeline every four hours, which writes a &lt;code&gt;games.json&lt;/code&gt; file and commits it to the repo. GitHub Pages serves that file alongside a vanilla ES6 frontend. The browser fetches a same-origin static file — no CORS, no rate limits, no API keys, no cold starts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Actions (cron, every 4h)
  ├─ GamerPower REST API  ──┐
  └─ Playwright → Amazon ───┤
                            ▼
                   normalize + dedupe
                            ▼
                      games.json ──▶ committed to repo
                                          ▼
                              GitHub Pages ──▶ browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first prototype called the GamerPower API directly from the browser. It worked on localhost and died immediately in production: no &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header, and every visitor burning a request against a shared rate limit. Amazon was worse — there's no public API at all.&lt;/p&gt;

&lt;p&gt;The fix is a pattern I'd now reach for by default on static hosting: &lt;strong&gt;push everything dynamic into the build step, ship the artifact.&lt;/strong&gt; One scrape serves every visitor. As a bonus, the data ends up version-controlled — &lt;code&gt;git log games.json&lt;/code&gt; tells me what was free last month.&lt;/p&gt;

&lt;p&gt;Steam and Epic come from &lt;a href="https://www.gamerpower.com/api-read" rel="noopener noreferrer"&gt;GamerPower's REST API&lt;/a&gt;, which already aggregates both. I didn't reinvent that. The part I actually had to build is Amazon.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Amazon scraper, and one trick worth stealing
&lt;/h2&gt;

&lt;p&gt;Amazon Luna / Prime Gaming renders its claims page client-side, so &lt;code&gt;requests&lt;/code&gt; returns an empty shell. Playwright handles that. The interesting problem wasn't rendering, it was &lt;em&gt;time&lt;/em&gt;: the listing page gives you titles and links, but expiration dates only exist on each game's detail page. Sixteen games meant sixteen extra page loads inside a CI runner.&lt;/p&gt;

&lt;p&gt;Three things brought that down to a few seconds. First, five concurrent tabs behind a semaphore. Second, &lt;code&gt;wait_until="domcontentloaded"&lt;/code&gt; instead of the default &lt;code&gt;load&lt;/code&gt;. Third, the one I hadn't seen elsewhere — blocking assets, but &lt;em&gt;fulfilling&lt;/em&gt; CSS instead of aborting it:&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.{png,jpg,jpeg,webp,svg,css,woff,woff2,gif}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fulfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/css&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.css&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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;Images, fonts and media get aborted outright. CSS gets served an empty 200 response. The distinction matters: aborting a stylesheet request makes some SPA bundles throw during module loading, and the page never hydrates. An empty stylesheet satisfies the loader while transferring nothing.&lt;/p&gt;

&lt;p&gt;Because the page hydrates asynchronously, waiting a fixed duration is either wasteful or flaky, so the scraper polls instead — reading &lt;code&gt;body&lt;/code&gt; text every 250ms for up to 3.5 seconds and stopping as soon as a parseable date appears.&lt;/p&gt;

&lt;p&gt;For selectors I leaned on attribute &lt;em&gt;substrings&lt;/em&gt; (&lt;code&gt;div[class*="Card"]&lt;/code&gt;, &lt;code&gt;[class*="title"]&lt;/code&gt;) rather than exact class names, since the build hashes them. It's more resilient than it looks, and less resilient than I'd like — more on that in a moment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 1: A ternary with two identical branches
&lt;/h2&gt;

&lt;p&gt;Here's the line that shipped:&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="n"&gt;is_permanent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;giveaway_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;game&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;full game&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it twice. Both branches return &lt;code&gt;True&lt;/code&gt;. Every Steam and Epic entry — 31 of them — was flagged as "permanently free," rendering a &lt;code&gt;♾️ Permanent&lt;/code&gt; badge on the card even when the API had handed me a concrete expiration date sitting right there in the same object.&lt;/p&gt;

&lt;p&gt;Nothing failed. No linter complained; it's syntactically fine and the variable is used. The tests didn't catch it because there were no tests. And it was invisible from the outside: a permanent badge on a game that expires next Tuesday looks exactly like a permanent badge on a game that doesn't.&lt;/p&gt;

&lt;p&gt;The fix is the whole point of how boring it is:&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="n"&gt;end_date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_iso_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;is_permanent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;end_date&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real lesson isn't "read your ternaries." It's that this field was &lt;strong&gt;derived state stored as a flag&lt;/strong&gt;. The moment &lt;code&gt;is_permanent&lt;/code&gt; became something you could set independently of &lt;code&gt;end_date&lt;/code&gt;, it became something that could disagree with &lt;code&gt;end_date&lt;/code&gt; — and nothing in the system was checking that they agreed. The one-line check I now run against generated output would have caught it on day one:&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="n"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;games&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;bool&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_permanent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Bug 2: A broken scraper publishes an empty site
&lt;/h2&gt;

&lt;p&gt;This one is baked into the architecture I just recommended to you, so it's worth dwelling on.&lt;/p&gt;

&lt;p&gt;Both scrapers wrapped their work in a try/except that logged the error and returned an empty list. Reasonable in isolation — one source failing shouldn't take down the other. But &lt;code&gt;main()&lt;/code&gt; then did this:&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="n"&gt;final_games&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_and_deduplicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_games&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;write_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;final_games&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# unconditionally
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Amazon changed a class name, or the runner hit a network hiccup, or GamerPower had a bad minute, the pipeline would cheerfully write an empty &lt;code&gt;games.json&lt;/code&gt;, commit it, and push. &lt;strong&gt;The job goes green.&lt;/strong&gt; The site shows zero games. Nothing alerts you, because from CI's perspective everything succeeded — the code ran to completion and produced a file.&lt;/p&gt;

&lt;p&gt;This is the failure mode nobody mentions when they tell you to move dynamism into the build step. A server that crashes is loud. A build that succeeds while producing garbage is silent, and it overwrites the last known-good artifact on its way out.&lt;/p&gt;

&lt;p&gt;The guard is crude and works:&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;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;final_games&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&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 free games fetched. Aborting write to prevent saving empty file.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;if&lt;/span&gt; &lt;span class="n"&gt;previous_games&lt;/span&gt; &lt;span class="ow"&gt;and&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;final_games&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;previous_games&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&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;Sudden drop in free games count (fetched &lt;/span&gt;&lt;span class="si"&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;final_games&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&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;previously &lt;/span&gt;&lt;span class="si"&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;previous_games&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). Scrapers might be broken. Aborting write.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties I'd argue are non-negotiable for any commit-the-artifact pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Failing loudly beats writing quietly.&lt;/strong&gt; &lt;code&gt;sys.exit(1)&lt;/code&gt; turns an invisible data loss into a red X in the Actions tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The previous artifact is your baseline.&lt;/strong&gt; You're committing the output to git, so last run's data is right there. Comparing against it costs nothing and catches partial breakage that an emptiness check misses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 50% threshold is arbitrary and I'll probably regret it the first time a legitimately quiet week trips it. That's a better failure than the alternative.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug 3: A regex that was too helpful
&lt;/h2&gt;

&lt;p&gt;Amazon writes expiration dates in whatever format it feels like — &lt;code&gt;Available through Sep 23&lt;/code&gt;, a localized Turkish string, sometimes a relative &lt;code&gt;in 12 days&lt;/code&gt;. So the parser tries patterns in sequence, and the last one was a catch-all:&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="n"&gt;re&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="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(\d+)\s*(?:gün|days?)&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied to &lt;code&gt;page.inner_text("body")&lt;/code&gt;. The entire page.&lt;/p&gt;

&lt;p&gt;Think about what else lives on an Amazon game page. Review timestamps. Description copy. And, memorably, game titles — &lt;em&gt;7 Days to Die&lt;/em&gt; would have handed that regex a very confident &lt;code&gt;7&lt;/code&gt;, which becomes "expires in 7 days," which becomes a countdown timer on a card, which a user believes.&lt;/p&gt;

&lt;p&gt;I deleted the fallback. &lt;code&gt;None&lt;/code&gt; is a correct answer; a fabricated date is not. The card renders without a countdown and nobody is misled.&lt;/p&gt;

&lt;p&gt;There was a subtler issue in the same code path. Relative dates were computed as &lt;code&gt;now + timedelta(days=n)&lt;/code&gt; at scrape time — so a game "expiring in 12 days" got a slightly later timestamp on every four-hour run, drifting forward forever. Pinning to &lt;code&gt;.replace(hour=23, minute=59, second=59)&lt;/code&gt; makes the value stable across runs.&lt;/p&gt;

&lt;p&gt;The pattern behind all three: &lt;strong&gt;in scraping, flexibility is indistinguishable from fabrication.&lt;/strong&gt; Every fallback you add to "handle more cases" is also a fallback that will confidently produce a wrong answer instead of admitting it doesn't know.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the deduplication taught me about keys
&lt;/h2&gt;

&lt;p&gt;Minor compared to the above, but it bit in a way I found instructive.&lt;/p&gt;

&lt;p&gt;The original dedupe key was a normalized title. Which quietly meant that when a game was free on Steam &lt;em&gt;and&lt;/em&gt; Epic in the same week — which happens more than you'd think — one of them silently vanished from the site. The dedupe was working perfectly. The key was just wrong: two entries with the same title on different stores aren't duplicates, they're two different deals a user might want.&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="n"&gt;norm_title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^\w\s]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;casefold&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;norm_title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\s+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;norm_title&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;norm_key&lt;/span&gt; &lt;span class="o"&gt;=&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;norm_title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there earned their place. &lt;code&gt;re.sub(r"\s+", " ", ...)&lt;/code&gt; because without it &lt;code&gt;"Game  Name"&lt;/code&gt; and &lt;code&gt;"Game Name"&lt;/code&gt; hash apart. And &lt;code&gt;casefold()&lt;/code&gt; rather than &lt;code&gt;lower()&lt;/code&gt; — the site is bilingual, and Turkish has a dotless &lt;code&gt;ı&lt;/code&gt;, so &lt;code&gt;lower()&lt;/code&gt; gives you a different string than you expect for titles containing &lt;code&gt;I&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;The pipeline runs every four hours, plus two extra attempts on Thursday afternoon to catch Epic's weekly rotation — GitHub's cron is delayed under load often enough that a single scheduled run isn't reliable if you care about a specific moment.&lt;/p&gt;

&lt;p&gt;There's a small pytest suite now. Nine tests, which is not many, but they cover the three functions that actually produce data: date parsing, title cleaning, deduplication. One of them exists purely to fail if anyone reintroduces that catch-all regex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Known rough edges, since you'll find them anyway:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GamerPower returns DLC, playtest access and in-game bundles alongside actual games. They're all technically free offers; they're also noise if you came looking for games. A type filter is the next patch.&lt;/li&gt;
&lt;li&gt;The Amazon scraper depends on class-name substrings. It will break. The guard above means it breaks loudly instead of publishing an empty page, which is the best I've got short of a proper contract test.&lt;/li&gt;
&lt;li&gt;Tests don't run in CI on pull requests yet.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;generated_at&lt;/code&gt; changes on every run, so the pipeline commits even when the game list is identical. Six commits a day of pure noise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've run a scraper aggregator for any length of time, I'd genuinely like to know what you did about breakage detection. Health-check endpoints, schema contracts, canary records, or just staring at the Actions tab like I do?&lt;/p&gt;

&lt;p&gt;Adding a platform is four steps and documented in &lt;a href="https://github.com/Seqat/OpenClaim/blob/main/CONTRIBUTING.md" rel="noopener noreferrer"&gt;CONTRIBUTING.md&lt;/a&gt; — drop a file in &lt;code&gt;backend/scrapers/&lt;/code&gt;, return a list of dicts matching the schema, register it in &lt;code&gt;main.py&lt;/code&gt;. GOG and itch.io are the obvious next ones.&lt;/p&gt;

&lt;p&gt;⭐ &lt;a href="https://github.com/Seqat/OpenClaim" rel="noopener noreferrer"&gt;github.com/Seqat/OpenClaim&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>python</category>
      <category>github</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
