<?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>My Rust TUI Let Any Local User Write to My Terminal</title>
      <dc:creator>Sedat Ali Zevit</dc:creator>
      <pubDate>Fri, 02 Oct 2026 15:39:10 +0000</pubDate>
      <link>https://dev.to/seqat/my-rust-tui-let-any-local-user-write-to-my-terminal-57fj</link>
      <guid>https://dev.to/seqat/my-rust-tui-let-any-local-user-write-to-my-terminal-57fj</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/Seqat/tuxctl" rel="noopener noreferrer"&gt;tuxctl&lt;/a&gt; is a lightweight, keyboard-first Linux TUI for monitoring and managing processes, systemd services, journal logs, hardware and network activity. It is written in Rust with Ratatui and Crossterm.&lt;/p&gt;

&lt;p&gt;While preparing v0.3.0, I found a bug that had been in every earlier release: any local user could make tuxctl write arbitrary bytes to my terminal. No memory corruption, no exploit chain. Just a name in a process list.&lt;/p&gt;

&lt;p&gt;This post is about how that happened, how I found it, and why I think it is an easy mistake to make when you build a terminal UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption I made
&lt;/h2&gt;

&lt;p&gt;A system monitor reads text from the system and prints it. Process names, command lines and log messages all come from &lt;code&gt;/proc&lt;/code&gt; or &lt;code&gt;journalctl&lt;/code&gt;, so I treated them as data about the machine: something to measure, sort and lay out in columns.&lt;/p&gt;

&lt;p&gt;But much of that text is not written by the system. It is written by whoever can run a process on it. Any local user can set a process name with &lt;code&gt;prctl&lt;/code&gt;, choose their own command line, and write a journal message with &lt;code&gt;logger&lt;/code&gt;. I was rendering input from the least trusted people on the box, and I never thought of it as input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happened
&lt;/h2&gt;

&lt;p&gt;When Ratatui renders a span, it removes newline characters and nothing else. The backend then prints each cell's symbol verbatim. So if a process name contains an ESC byte, that byte reaches the terminal as the start of an escape sequence, exactly as if a program had printed it.&lt;/p&gt;

&lt;p&gt;Two sequences worried me most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OSC 52&lt;/strong&gt;, which asks the terminal to write to the clipboard (in terminals that allow it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESC c&lt;/strong&gt;, which resets the terminal.
Rust did not save me here. ESC is a perfectly valid UTF-8 character, so the strings were "safe" as far as the type system was concerned. The problem was never memory safety. It was trusting what the text would do once it was printed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A harmless demo
&lt;/h2&gt;

&lt;p&gt;You do not need anything dangerous to see the problem. This script gives its own process a name that is an escape sequence setting the terminal window title:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;libc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CDLL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PR_SET_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;

&lt;span class="c1"&gt;# ESC ] 0 ; hello BEL  -&amp;gt; "set the window title to hello"
&lt;/span&gt;&lt;span class="n"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prctl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PR_SET_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\x1b&lt;/span&gt;&lt;span class="s"&gt;]0;hello&lt;/span&gt;&lt;span class="se"&gt;\x07&lt;/span&gt;&lt;span class="sh"&gt;"&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;0&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="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it as any user and check &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/comm&lt;/code&gt;: the name is stored with the raw escape bytes in it. A monitor that prints that name without cleaning it hands those bytes to your terminal, and the title changes. A title is harmless. The same channel carries the clipboard and reset sequences above, and the person who controls the bytes is not the person looking at the screen.&lt;/p&gt;

&lt;p&gt;The same trick works with &lt;code&gt;logger&lt;/code&gt; for journal messages and with the command line of any process.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I found it
&lt;/h2&gt;

&lt;p&gt;I found it in the security review for v0.3.0, not by using the tool. By then the project had 278 automated tests, clean &lt;code&gt;clippy&lt;/code&gt; runs, resize sweeps and a performance baseline in CI. None of that caught it, because tests check what you thought to test, and I had never thought of a process name as hostile input.&lt;/p&gt;

&lt;p&gt;A note on process: I build tuxctl with Claude as a coding partner, and the pull requests carry &lt;code&gt;Co-Authored-By&lt;/code&gt; trailers that say so. The review was part of that workflow, and it was worth it. The question that finds this class of bug is short: which strings on my screen can someone other than me control?&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug had the same root cause
&lt;/h2&gt;

&lt;p&gt;The review turned up a second problem. A process whose name is not valid UTF-8 was dropped from the process list, because its &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/stat&lt;/code&gt; could not be read as a string. Any user could hide a process from tuxctl by choosing such a name.&lt;/p&gt;

&lt;p&gt;It is the same mistake from the other side. In the first bug I trusted the bytes too much when printing them. In the second I assumed the bytes would always be well-formed when reading them. In both cases the data came from someone I do not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix in v0.3.0
&lt;/h2&gt;

&lt;p&gt;v0.3.0 now removes control characters and bidirectional overrides from everything tuxctl draws. Affected characters are shown as &lt;code&gt;�&lt;/code&gt;, so you can still see that something odd was there. The filter covers all drawn text, not only the three sources I knew about when I started looking. Bidirectional overrides are in the list because they can reorder text on screen and make a name look like something it is not.&lt;/p&gt;

&lt;p&gt;For the second bug, &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/stat&lt;/code&gt; is now read lossily. Processes with non-UTF-8 names appear in the list and can be signaled like any other.&lt;/p&gt;

&lt;p&gt;The release added no new dependencies and no async runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else I checked
&lt;/h2&gt;

&lt;p&gt;Once I was asking "what can a hostile user influence?", I went through the other places where tuxctl touches the system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Spawned commands.&lt;/strong&gt; &lt;code&gt;systemctl&lt;/code&gt; and &lt;code&gt;journalctl&lt;/code&gt; run with fixed arguments. The one &lt;code&gt;sh -c&lt;/code&gt; in &lt;code&gt;service.rs&lt;/code&gt; is test-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signals.&lt;/strong&gt; Before sending a signal, tuxctl revalidates the process by &lt;code&gt;(pid, start_time)&lt;/code&gt; and sends it through a pidfd, so a recycled PID cannot receive it.
Both held up. It was still worth writing them down, because "I checked and it was fine" is a result too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checklist if you build a TUI or CLI
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;List every string you draw that came from outside your program: process names, file names, log lines, hostnames, command output.&lt;/li&gt;
&lt;li&gt;Strip control characters and bidirectional overrides before they reach the terminal, at the point where you draw, not in each data source.&lt;/li&gt;
&lt;li&gt;Read the rendering library's docs for what it does not sanitize. In my case it removed newlines and nothing more.&lt;/li&gt;
&lt;li&gt;Never drop an entry because its text is malformed. Decode lossily and show it, or an attacker can hide things from you.&lt;/li&gt;
&lt;li&gt;Test with hostile input on purpose. A passing test suite only covers the cases you imagined.
## If you use tuxctl&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upgrade to v0.3.0 or later, especially if you run it as root or on a shared machine. Prebuilt x86_64 and aarch64 binaries are on the &lt;a href="https://github.com/Seqat/tuxctl/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;, or build from source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--git&lt;/span&gt; https://github.com/Seqat/tuxctl &lt;span class="nt"&gt;--tag&lt;/span&gt; v0.3.3 &lt;span class="nt"&gt;--locked&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code is at &lt;a href="https://github.com/Seqat/tuxctl" rel="noopener noreferrer"&gt;github.com/Seqat/tuxctl&lt;/a&gt;. If you find another way to make it print something it should not, please open an issue. I would rather hear about it from you than find it in the next review.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>linux</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <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>
