<?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: Fatih İlhan</title>
    <description>The latest articles on DEV Community by Fatih İlhan (@fatihbuilds).</description>
    <link>https://dev.to/fatihbuilds</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%2F3861452%2F58861428-4878-4a48-a2a4-447bd51fe1ec.jpg</url>
      <title>DEV Community: Fatih İlhan</title>
      <link>https://dev.to/fatihbuilds</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fatihbuilds"/>
    <language>en</language>
    <item>
      <title>I Automated a TikTok Campaign for My App by Editing CapCut's Project Files Directly</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Mon, 19 Oct 2026 19:23:29 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/i-automated-a-tiktok-campaign-for-my-app-by-editing-capcuts-project-files-directly-2bog</link>
      <guid>https://dev.to/fatihbuilds/i-automated-a-tiktok-campaign-for-my-app-by-editing-capcuts-project-files-directly-2bog</guid>
      <description>&lt;p&gt;I shipped &lt;strong&gt;Judge Me&lt;/strong&gt;, a small Android app where people read "Am I the jerk?"-style dilemmas and vote: &lt;em&gt;you were right&lt;/em&gt;, &lt;em&gt;you were wrong&lt;/em&gt;, &lt;em&gt;both of you messed up&lt;/em&gt;, or &lt;em&gt;it's complicated&lt;/em&gt;. The app works. Nobody knows it exists.&lt;/p&gt;

&lt;p&gt;So I'm running a 14-day experiment: short vertical videos on TikTok and Instagram Reels, one per day, each telling one dilemma from the app and ending with "go vote." The goal is simple and measurable: &lt;strong&gt;500+ Play Store installs in 14 days, or one video over 50k views.&lt;/strong&gt; If neither happens, I stop and try something else.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkb3nzsji71j5fa4pkxf.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frkb3nzsji71j5fa4pkxf.jpeg" alt=" " width="800" height="1734"&gt;&lt;/a&gt;&lt;br&gt;
This post is about the boring-but-interesting part: how I made producing a video take ~5 minutes of my time instead of an hour, with no paid tools.&lt;/p&gt;
&lt;h2&gt;
  
  
  The constraints
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Solo dev with a full-time day job. Video editing time is near zero.&lt;/li&gt;
&lt;li&gt;No budget for AI video SaaS subscriptions.&lt;/li&gt;
&lt;li&gt;English content aimed at a US audience, posted from Turkey (so everything gets scheduled for 03:00 my time).&lt;/li&gt;
&lt;li&gt;Every video has the same structure, which is the key insight: &lt;strong&gt;if the structure is fixed, the editing is a data problem.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each video looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;What's on screen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–2 s&lt;/td&gt;
&lt;td&gt;Big hook text (first sentence of the story)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 s → end−4 s&lt;/td&gt;
&lt;td&gt;Background clip + AI voiceover + 2–3 word captions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last 4 s&lt;/td&gt;
&lt;td&gt;"Right or wrong? YOU decide." + screenshot of the app's vote screen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Step 1: Script → voice + word timestamps
&lt;/h2&gt;

&lt;p&gt;Scripts are 100–130 words, first person, short sentences, ending with "So… was I wrong?". I use &lt;a href="https://github.com/rany2/edge-tts" rel="noopener noreferrer"&gt;edge-tts&lt;/a&gt;, which is free and — crucially — streams &lt;strong&gt;word boundary events&lt;/strong&gt; with timestamps alongside the audio.&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;comm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge_tts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Communicate&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-US-GuyNeural&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+12%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;boundary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WordBoundary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;comm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&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;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WordBoundary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offset&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="mi"&gt;10_000&lt;/span&gt;  &lt;span class="c1"&gt;# 100ns ticks → ms
&lt;/span&gt;        &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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;start_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&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_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duration&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="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives me an MP3 plus a JSON list of every word and when it's spoken. Captions come for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha: word boundaries have no punctuation
&lt;/h3&gt;

&lt;p&gt;My first captions looked like &lt;code&gt;star She launched&lt;/code&gt; and &lt;code&gt;reviews So I&lt;/code&gt; — chunks bleeding across sentences. The boundary events return bare words, so my "break on punctuation" rule never fired.&lt;/p&gt;

&lt;p&gt;Fix: re-align the timed words against the original script tokens (which do have punctuation), then chunk in two passes — split on clause boundaries first, then divide each clause into balanced groups so you never get a lonely one-word chunk:&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;chunk_words&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_words&lt;/span&gt;&lt;span class="o"&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;max_chars&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;clauses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cur&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&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;w&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="ow"&gt;in&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;clauses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clauses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&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;cl&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clauses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&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;cl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;max_words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# ceil
&lt;/span&gt;        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="c1"&gt;# balance sizes, respect char limit
&lt;/span&gt;            &lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divmod&lt;/span&gt;&lt;span class="p"&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;cl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cl&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&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="nf"&gt;join&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&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;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_chars&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;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;cl&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
            &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before: &lt;code&gt;They showed up | late.&lt;/code&gt;&lt;br&gt;
After: &lt;code&gt;They showed | up late.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Small thing, but captions are most of what people actually read on mute.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: CapCut projects are just JSON
&lt;/h2&gt;

&lt;p&gt;This is the part that made the whole pipeline possible. CapCut desktop stores each project as a folder containing &lt;code&gt;draft_content.json&lt;/code&gt; — tracks, segments, materials, text styles, all in plain JSON. (Some versions encrypt it; mine, 9.6 on Windows, didn't. &lt;strong&gt;Check yours before building on this.&lt;/strong&gt;)&lt;/p&gt;

&lt;p&gt;The structure, simplified:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tracks[]            → video / audio / text tracks, each with segments[]
segments[]          → material_id + target_timerange {start, duration} in µs + clip transform
materials.texts[]   → text content (as an embedded JSON string!) + style
materials.videos[]  → file paths for clips and images
materials.audios[]  → file paths for audio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build &lt;strong&gt;one template project by hand&lt;/strong&gt; with placeholder text layers: &lt;code&gt;HOOK_TEXT&lt;/code&gt;, &lt;code&gt;SUB_1&lt;/code&gt;, &lt;code&gt;VOTE_TEXT&lt;/code&gt;, plus the background, audio, and vote-screen image.&lt;/li&gt;
&lt;li&gt;Let CapCut open and re-save it once, so the file is in its own canonical format.&lt;/li&gt;
&lt;li&gt;Keep that folder as a read-only master.&lt;/li&gt;
&lt;li&gt;For each video, a Python script copies the template's JSON and rewrites it:

&lt;ul&gt;
&lt;li&gt;swap the audio and background paths&lt;/li&gt;
&lt;li&gt;set every duration to the MP3 length (in microseconds)&lt;/li&gt;
&lt;li&gt;put the first sentence into &lt;code&gt;HOOK_TEXT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;clone the &lt;code&gt;SUB_1&lt;/code&gt; layer once per caption chunk&lt;/strong&gt;, each with a fresh UUID and the chunk's timing&lt;/li&gt;
&lt;li&gt;set the vote text and move it to the last 4 seconds&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one non-obvious detail: the text content lives in a JSON &lt;em&gt;string&lt;/em&gt; inside the JSON, with a character &lt;code&gt;range&lt;/code&gt; for styling. Change the text without updating the range and the styling breaks.&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;set_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;st&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;styles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&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="n"&gt;mat&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I open CapCut, glance at the timeline, and hit Export. That's my whole manual step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch mode
&lt;/h3&gt;

&lt;p&gt;Once one video worked, I generated 10 separate CapCut projects in one go — copy the template folder, give it a new &lt;code&gt;draft_id&lt;/code&gt;, register it in CapCut's &lt;code&gt;root_meta_info.json&lt;/code&gt;, render the content. CapCut picked them all up in its project list. Ten videos, ten exports, done in one sitting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rules I learned the hard way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CapCut must be fully closed when you write.&lt;/strong&gt; If it's open, it autosaves over your changes. A &lt;code&gt;.locked&lt;/code&gt; file in the project folder tells you it's open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up before every write.&lt;/strong&gt; My script copies the target project to a backup folder first, every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text wrapping isn't guaranteed.&lt;/strong&gt; My first hook ran off both edges of the screen — CapCut ignored the max line width I set. I now wrap the hook myself into balanced lines of ≤20 characters before writing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the CapCut version and turn off auto-update.&lt;/strong&gt; This is an undocumented format. An update can break everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What didn't work: letting an AI agent click through CapCut
&lt;/h2&gt;

&lt;p&gt;My first attempt was to have an AI agent with desktop control build the template inside CapCut by clicking. It was slower and flakier than I expected: CapCut's media panel didn't respond to synthetic mouse events at all (couldn't drag a text layer onto the timeline), and the agent's own window kept stealing focus. After a few rounds I switched to "just edit the files," which took less time than the clicking attempts had.&lt;/p&gt;

&lt;p&gt;Lesson: if an app has a readable file format, editing the file beats automating the UI almost every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distribution gotcha: no link in bio
&lt;/h2&gt;

&lt;p&gt;Plan said "link in bio." TikTok said no — clickable bio links need 1,000 followers on a personal account, and the business account switch wasn't available to me without verification. Plain-text URLs in the bio aren't clickable.&lt;/p&gt;

&lt;p&gt;So the end card now says &lt;strong&gt;"Vote free: search 'Judge Me AITA' on Google Play"&lt;/strong&gt; — I checked that exact search ranks the app first. The downside: I lose UTM attribution on TikTok and have to infer it from Play Console's search-traffic numbers. Instagram still gets a proper tracked link.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, in numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cost: $0 (edge-tts, CapCut free tier, Pexels backgrounds)&lt;/li&gt;
&lt;li&gt;My time per video after setup: ~5 minutes (open project, check, export, schedule)&lt;/li&gt;
&lt;li&gt;Stock ready before day 1: 10 videos&lt;/li&gt;
&lt;li&gt;Posting: one per day at 03:00 Turkey time (evening in the US), scheduled&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The experiment starts this week. I'll post the results — views, installs, what hooks worked, and whether 03:00 scheduling actually mattered — when the 14 days are up, including if it flops.&lt;/p&gt;

&lt;p&gt;If you're an indie dev sitting on an app nobody knows about, the takeaway isn't "use CapCut." It's: &lt;strong&gt;find the fixed structure in your marketing content, and turn the repetitive part into data.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Judge Me is on &lt;a href="https://play.google.com/store/apps/details?id=com.noktastudio.judgeme&amp;amp;referrer=utm_source%3Ddevto" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt;. I build in public as &lt;a href="https://x.com/fatihbuilds" rel="noopener noreferrer"&gt;@fatihbuilds&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>indiedev</category>
      <category>python</category>
      <category>automation</category>
      <category>marketing</category>
    </item>
    <item>
      <title>UK public tenders now live in two systems. Here's what I learned merging them.</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Thu, 15 Oct 2026 18:56:56 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/uk-public-tenders-now-live-in-two-systems-heres-what-i-learned-merging-them-k0f</link>
      <guid>https://dev.to/fatihbuilds/uk-public-tenders-now-live-in-two-systems-heres-what-i-learned-merging-them-k0f</guid>
      <description>&lt;p&gt;If you track UK public sector contracts, for sales leads, market research or journalism, you probably noticed it got messier. Since the Procurement Act 2023 regime started in 2025, notices are spread across two government systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find a Tender Service (FTS):&lt;/strong&gt; the central platform, used for most notices under the new regime&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contracts Finder (CF):&lt;/strong&gt; still live and still receiving notices, including lower-value opportunities
If you only watch one, you miss part of the market. In a 3-day sample I pulled this week, FTS had &lt;strong&gt;1,247&lt;/strong&gt; notices and Contracts Finder had &lt;strong&gt;497&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The good news: both publish open data in the same standard (OCDS), with &lt;strong&gt;no API key&lt;/strong&gt;, under the &lt;strong&gt;Open Government Licence v3&lt;/strong&gt;, which allows commercial reuse with attribution.&lt;/p&gt;

&lt;p&gt;The bad news: "same standard" doesn't mean "same data". Here's what I ran into while merging them into one feed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I turned this into a paid Apify actor (linked at the end). Everything below also applies if you build it yourself, and I've tried to write it so you can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. The rate limit is stricter than the docs suggest
&lt;/h2&gt;

&lt;p&gt;Both hosts started returning &lt;strong&gt;HTTP 429 with &lt;code&gt;Retry-After: 120&lt;/code&gt;&lt;/strong&gt; after roughly 20 requests in my tests. Contracts Finder's docs mention a 403 instead, but I got 429s. After the first burst I could get about &lt;strong&gt;5 pages a minute&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This was consistent across three test runs, but it's my observation, not a documented number. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Respect &lt;code&gt;Retry-After&lt;/code&gt;. Don't retry blindly.&lt;/li&gt;
&lt;li&gt;Keep date windows small. A 1-day pull finishes in seconds; a 7-day pull of everything takes minutes.&lt;/li&gt;
&lt;li&gt;For monitoring, run daily and only fetch what's new since the last run.
## 2. The &lt;code&gt;stages&lt;/code&gt; filter silently drops notices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both APIs accept a stage filter (&lt;code&gt;tender&lt;/code&gt;, &lt;code&gt;award&lt;/code&gt;, ...). It looks like the obvious way to get only open tenders.&lt;/p&gt;

&lt;p&gt;Don't use it. In one test, FTS with &lt;code&gt;stages=tender&lt;/code&gt; returned &lt;strong&gt;11 of 101&lt;/strong&gt; tender releases that were in the unfiltered response. No error, just missing rows.&lt;/p&gt;

&lt;p&gt;Fetch everything for the date window and filter on your side using the release's &lt;code&gt;tag&lt;/code&gt; and &lt;code&gt;tender.status&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The same tender can appear in both systems with different IDs
&lt;/h2&gt;

&lt;p&gt;OCDS gives every procurement an &lt;code&gt;ocid&lt;/code&gt;. You'd expect to deduplicate on it. But the two systems use &lt;strong&gt;different ocid prefixes&lt;/strong&gt;, so the same tender published in both places looks like two different procurements.&lt;/p&gt;

&lt;p&gt;Real example from this week: a council RFQ for a "Dublin Belfast Economic Corridor Investment Land Guide" appeared in both feeds with different ocids.&lt;/p&gt;

&lt;p&gt;What worked for me was a composite key:&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;re&lt;/span&gt;

&lt;span class="n"&gt;PREFIX&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;compile&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;^(GB-[A-Za-z]+:|UK\d+ Tender Notice:|[A-Z]{2,4}_?\d+\s*-)\s*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PREFIX&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="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&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;[^a-z0-9 ]&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;s&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="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dedup_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;buyer_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&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;deadline_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;or&lt;/span&gt; &lt;span class="sh"&gt;""&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="c1"&gt;# same day
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Titles often carry system prefixes ("GB-Glasgow:", "UK4 Tender Notice:", "PRJ_31142 -"), so strip those first.&lt;/p&gt;

&lt;p&gt;There's a second, smaller case: the &lt;strong&gt;same system&lt;/strong&gt; sometimes publishes a notice twice a few minutes apart, with identical title and text but different IDs. I merge those too and keep the other ID in a &lt;code&gt;related_notice_ids&lt;/code&gt; field, so nothing is lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Fields that look the same but aren't
&lt;/h2&gt;

&lt;p&gt;A few things that will bite you if you just map field names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Region formats differ.&lt;/strong&gt; FTS uses NUTS codes (&lt;code&gt;UKK14&lt;/code&gt;); Contracts Finder often uses names ("East of England"). And about half of CF notices have no region at all. I split it into &lt;code&gt;region_code&lt;/code&gt; + &lt;code&gt;region_name&lt;/code&gt; and map CF names to NUTS1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Award notices carry old deadlines.&lt;/strong&gt; A CF award notice can have a &lt;code&gt;deadline_date&lt;/code&gt; from 2024, which is the original tender deadline. If you show it as "deadline", users will think it expired. For awards, move it to a separate field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CF award value ≠ awarded amount.&lt;/strong&gt; It's often the tender's estimated value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some FTS notices have no CPV code&lt;/strong&gt;, so a CPV filter won't see them. Add a keyword fallback if you need full coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CF descriptions sometimes repeat themselves.&lt;/strong&gt; The "Additional information" block can be a full copy of the description.
## Doing it yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a minimal Contracts Finder pull for the last 24 hours:&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;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.contractsfinder.service.gov.uk/Published/Notices/OCDS/Search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&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;publishedFrom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%dT%H:%M:%SZ&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;publishedTo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%Y-%m-%dT%H:%M:%SZ&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;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;releases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&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="nf"&gt;int&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;headers&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;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&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="n"&gt;releases&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;data&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;releases&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;links&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&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;releases&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;releases&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find a Tender has its own OCDS endpoint (&lt;code&gt;/api/1.0/ocdsReleasePackages&lt;/code&gt;) with similar pagination. Check the &lt;a href="https://www.find-tender.service.gov.uk/Developer/Documentation" rel="noopener noreferrer"&gt;FTS API docs&lt;/a&gt; for the current parameters. Then normalise both into one schema and apply the dedup above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or use the hosted version
&lt;/h2&gt;

&lt;p&gt;If you'd rather not maintain this, I published it as an Apify actor: &lt;strong&gt;&lt;a href="https://apify.com/seralifatih/uk-public-tenders" rel="noopener noreferrer"&gt;UK Public Tenders: Find a Tender + Contracts Finder&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One schema for both systems, with cross-source and same-source dedup&lt;/li&gt;
&lt;li&gt;Filters: keywords, CPV prefix (e.g. &lt;code&gt;72&lt;/code&gt; for IT), buyer, value range, region, notice type&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onlyNew&lt;/code&gt; mode for daily monitoring: schedule it and get only notices you haven't seen&lt;/li&gt;
&lt;li&gt;Handles the rate limit and keeps going if one source is down (logged in a run summary)&lt;/li&gt;
&lt;li&gt;Open tenders and planning notices by default; awards are opt-in&lt;/li&gt;
&lt;li&gt;Pricing: $3 per 1,000 results (less on higher Apify plans)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seralifatih/uk-public-tenders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&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;cpvCodes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;72&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;      &lt;span class="c1"&gt;# IT services
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minValue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;onlyNew&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&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;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;published_date&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;buyer_name&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;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title_clean&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;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deadline_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data: Contains public sector information licensed under the Open Government Licence v3.0.&lt;/p&gt;

&lt;p&gt;If you track UK procurement another way, or hit a quirk I didn't list, tell me in the comments.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>opendata</category>
      <category>datascience</category>
    </item>
    <item>
      <title>House Stock Watcher and Senate Stock Watcher are down: what to use instead (2026)</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Tue, 06 Oct 2026 10:47:33 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/house-stock-watcher-and-senate-stock-watcher-are-down-what-to-use-instead-2026-2h0n</link>
      <guid>https://dev.to/fatihbuilds/house-stock-watcher-and-senate-stock-watcher-are-down-what-to-use-instead-2026-2h0n</guid>
      <description>&lt;p&gt;If your congress-trades feature stopped working, you're not alone. House Stock Watcher and Senate Stock Watcher were the default free source for STOCK Act trade data for years, and a lot of projects still point at them. They are no longer usable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Status (checked Oct 2026)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;house-stock-watcher-data.s3-us-west-2.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403 Forbidden&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;senate-stock-watcher-data.s3-us-west-2.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;403 Forbidden&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;housestockwatcher.com&lt;/code&gt;, &lt;code&gt;senatestockwatcher.com&lt;/code&gt; (incl. &lt;code&gt;/api&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Domain doesn't resolve&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;raw.githubusercontent.com/timothycarambat/senate-stock-watcher-data/...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Still responds, but &lt;strong&gt;last commit was March 2021&lt;/strong&gt;. Using it gives you 5-year-old data with no error message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last one is the dangerous case. I found several projects that use the GitHub mirror as a "fallback". That fallback never fails, so nobody notices the data stopped in 2021.&lt;/p&gt;

&lt;p&gt;You can check the others yourself:&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="nt"&gt;-I&lt;/span&gt; https://house-stock-watcher-data.s3-us-west-2.amazonaws.com/data/all_transactions.json
&lt;span class="c"&gt;# HTTP/1.1 403 Forbidden&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below are your options, from free-but-work to paid-but-easy.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I build one of the options below (option 3). I've tried to list the others fairly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Option 1: Parse the official sources yourself (free)
&lt;/h2&gt;

&lt;p&gt;All of this data comes from two government sites:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;House:&lt;/strong&gt; the &lt;a href="https://disclosures-clerk.house.gov/FinancialDisclosure" rel="noopener noreferrer"&gt;House Clerk Financial Disclosure&lt;/a&gt; site publishes a yearly ZIP with an XML index of every filing (&lt;code&gt;{year}FD.zip&lt;/code&gt;). Periodic Transaction Reports (PTRs) are PDFs at &lt;code&gt;/public_disc/ptr-pdfs/{year}/{doc_id}.pdf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Senate:&lt;/strong&gt; &lt;a href="https://efdsearch.senate.gov" rel="noopener noreferrer"&gt;Senate eFD&lt;/a&gt; has a search UI. You must accept its terms, and it uses a session and CSRF token. Electronic PTRs are HTML tables. Paper filings are scanned images.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch is that the House side is PDF parsing. Most PTRs have a text layer, but the layout varies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;amounts can be split across lines&lt;/li&gt;
&lt;li&gt;some filings list exchanges&lt;/li&gt;
&lt;li&gt;around 14% of recent House PTRs are scanned images with no text at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naive regex parser silently drops a lot of rows. &lt;a href="https://dev.to/fatihbuilds/my-congressional-trading-scraper-was-quietly-losing-half-its-data-4hog"&gt;Mine dropped almost half of them before I caught it&lt;/a&gt;. If you go this route, count filings in vs. rows out, and log the filings that produce zero rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt; you want full control and don't mind maintaining a parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Commercial APIs and sites
&lt;/h2&gt;

&lt;p&gt;Several services sell this data, including Quiver Quantitative, Unusual Whales, Capitol Trades (web UI) and some general market-data APIs that have congress endpoints. They're polished, but check two things before you integrate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pricing and rate limits&lt;/strong&gt; for the volume you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commercial-use terms.&lt;/strong&gt; Federal law (5 U.S.C. §13107(c)) restricts some commercial uses of these disclosure reports, and vendors handle that differently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt; you need a dashboard or many extra datasets, and the price works for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: Hosted feeds on Apify (pay per row)
&lt;/h2&gt;

&lt;p&gt;I maintain two Apify actors that parse the official House and Senate sources into one clean JSON schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/seralifatih/congress-trading-pipeline-1" rel="noopener noreferrer"&gt;U.S. House Congress Trade Tracker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/seralifatih/congress-trading-pipeline" rel="noopener noreferrer"&gt;U.S. Senate Congress Trade Tracker&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One schema for both chambers&lt;/strong&gt;, with &lt;code&gt;member_bioguide_id&lt;/code&gt; on each row so you can join to other congress data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filters:&lt;/strong&gt; member (nickname-aware), ticker, filing-date window, transaction-date range&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No silent drops:&lt;/strong&gt; filings that can't be parsed (scanned or failed) come back as placeholder rows with a &lt;code&gt;parse_status&lt;/code&gt;, instead of disappearing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amendment handling:&lt;/strong&gt; &lt;code&gt;filing_type&lt;/code&gt;, &lt;code&gt;supersedes_filing_id&lt;/code&gt;, &lt;code&gt;is_superseded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; $2 per 1,000 rows (House) and $3 per 1,000 rows (Senate). Apify's free plan includes monthly credit, which covers a typical weekly pull.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Python example:&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;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seralifatih/congress-trading-pipeline-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&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;fetchDaysBack&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tickers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;NVDA&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;MSFT&lt;/span&gt;&lt;span class="sh"&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;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transaction_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;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;politician&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount_max&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Migrating from Stock Watcher fields
&lt;/h3&gt;

&lt;p&gt;If your code expects the old Stock Watcher JSON, the mapping is mostly direct:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stock Watcher&lt;/th&gt;
&lt;th&gt;This schema&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;representative&lt;/code&gt; / &lt;code&gt;senator&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;politician&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;normalized name; raw name in &lt;code&gt;politician_raw&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transaction_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;transaction_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ISO &lt;code&gt;YYYY-MM-DD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;disclosure_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;filing_date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ticker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ticker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;empty when the filing has none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;asset_description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;asset_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;type&lt;/code&gt; (&lt;code&gt;purchase&lt;/code&gt;, &lt;code&gt;sale_full&lt;/code&gt;, …)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;buy&lt;/code&gt; / &lt;code&gt;sell&lt;/code&gt; / &lt;code&gt;exchange&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;amount&lt;/code&gt; (&lt;code&gt;"$1,001 - $15,000"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;amount_min&lt;/code&gt;, &lt;code&gt;amount_max&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;already numeric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;owner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;owner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;self&lt;/code&gt; / &lt;code&gt;spouse&lt;/code&gt; / &lt;code&gt;joint&lt;/code&gt; / &lt;code&gt;child&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ptr_link&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pdf_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;House&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Good fit:&lt;/strong&gt; you want the old "just give me JSON" experience without maintaining a parser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: Just need history? Free 2025 snapshot
&lt;/h2&gt;

&lt;p&gt;If you only need a fixed dataset for backtesting or analysis, I published every 2025 House and Senate trade as a free CSV/Parquet file. It has 8,462 rows, 127 members, and the same schema as option 3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.kaggle.com/datasets/fatihlhan/us-congress-stock-trades-2025-house-and-senate" rel="noopener noreferrer"&gt;Kaggle: US Congress Stock Trades 2025&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/datasets/seralifatih/us-congress-stock-trades-2025" rel="noopener noreferrer"&gt;Hugging Face: seralifatih/us-congress-stock-trades-2025&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&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;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hf://datasets/seralifatih/us-congress-stock-trades-2025/us_congress_trades_2025.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One finding from that data: about &lt;strong&gt;12% of 2025 trades were disclosed more than 45 days after the trade&lt;/strong&gt;, which is past the STOCK Act deadline. If your strategy assumes disclosures arrive on time, check that assumption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist if your feature broke
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Search your code for &lt;code&gt;stock-watcher&lt;/code&gt;, &lt;code&gt;stockwatcher.com&lt;/code&gt; and &lt;code&gt;timothycarambat&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Remove any "fallback" to the GitHub mirror. It hides the outage.&lt;/li&gt;
&lt;li&gt;Pick an option above and update the field mapping.&lt;/li&gt;
&lt;li&gt;Add a freshness check: alert if the newest &lt;code&gt;filing_date&lt;/code&gt; is more than ~7 days old.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you know another working source, leave a comment and I'll add it to the list.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>datascience</category>
      <category>finance</category>
    </item>
    <item>
      <title>My congressional trading scraper was quietly losing half its data</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Tue, 29 Sep 2026 18:20:31 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/my-congressional-trading-scraper-was-quietly-losing-half-its-data-4hog</link>
      <guid>https://dev.to/fatihbuilds/my-congressional-trading-scraper-was-quietly-losing-half-its-data-4hog</guid>
      <description>&lt;p&gt;I run two small Apify actors that pull U.S. congressional stock trading disclosures (the STOCK Act "PTR" filings) for the House and the Senate and turn them into clean JSON. A handful of people pay for them. Revenue had plateaued, so I did what I should have done months ago: I stopped looking at the marketing and started auditing my own output.&lt;/p&gt;

&lt;p&gt;It was worse than I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that ruined my evening
&lt;/h2&gt;

&lt;p&gt;I took the 50 most recent House filings and checked how many rows each one produced.&lt;/p&gt;

&lt;p&gt;Almost half of them produced &lt;strong&gt;zero rows&lt;/strong&gt;. Not errors. Not warnings anyone would see. Just nothing. The run finished green, the dataset looked healthy, and a big chunk of Congress's trades simply weren't there.&lt;/p&gt;

&lt;p&gt;After fixing it, the same 50 filings went from &lt;strong&gt;238 rows to 416&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you're a paying user, that's the kind of thing you'd never notice. You'd just assume your data was complete. That's what bothered me most.&lt;/p&gt;

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

&lt;p&gt;There wasn't one bug. There were several small ones that all failed the same way: quietly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. An amount format I never planned for.&lt;/strong&gt; House filings report amounts as ranges like &lt;code&gt;$1,001 - $15,000&lt;/code&gt;. My regex required a range. Then one filing showed up with an exact amount, &lt;code&gt;$2,722.50&lt;/code&gt;, with cents. The regex didn't match, the parser returned an empty array, and the filing vanished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Exchange transactions.&lt;/strong&gt; Some rows are exchanges (swap one holding for another), not buys or sells. I didn't have a type for them, so they got dropped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Scanned paper filings.&lt;/strong&gt; About 14% of House filings in the last 90 days aren't digital at all. Someone printed the form, filled it in, and hand-delivered it. The PDF is just an image. My parser saw no text, returned nothing, moved on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The Senate had the same problem, and I had a comment saying it didn't.&lt;/strong&gt; There was literally a code comment in my Senate actor saying "Senate has no scanned case, source is HTML." Wrong. The Senate also accepts paper filings, they just live at a different URL. In the last 30 days: 3 out of 49.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Network failures.&lt;/strong&gt; When a PDF download failed after retries, I incremented an error counter and... that's it. The filing was known, it was in the index, and it disappeared from the output.&lt;/p&gt;

&lt;p&gt;The pattern is obvious in hindsight: every one of these paths ended in &lt;code&gt;return []&lt;/code&gt;. And an empty array is the most polite way a program can lie to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: every filing leaves a trace
&lt;/h2&gt;

&lt;p&gt;The rule I landed on is simple. &lt;strong&gt;Every filing that appears in the official index must show up in the output.&lt;/strong&gt; Either as real transaction rows, or as a clearly marked placeholder that tells you why there's no data.&lt;/p&gt;

&lt;p&gt;So now every row has a &lt;code&gt;parse_status&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parse_status&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ok&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parsed normally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scanned_unparsed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Paper filing, image only, no text layer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;parse_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text exists, but my parser couldn't extract rows (my bug, not yours)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch_failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The source couldn't be downloaded after retries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Placeholders keep the politician, filing date, and a link to the original document, so you can go look yourself. One catch: the platform bills every row in the dataset, placeholders included, so if you're counting or paying per result, filter on &lt;code&gt;parse_status = "ok"&lt;/code&gt; for the rows that carry real trade data.&lt;/p&gt;

&lt;p&gt;The run output now also includes counters (paper filings, parse failures, fetch failures), so a silent drop can't hide in a green run anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing things broke other things
&lt;/h2&gt;

&lt;p&gt;A few smaller lessons from the same week:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My ticker fix broke Mastercard.&lt;/strong&gt; Some rows had the ticker only inside the asset name, like &lt;code&gt;Electronic Arts Inc. (EA)&lt;/code&gt;. I added a fallback to extract it, with a stoplist to avoid false positives like &lt;code&gt;(LLC)&lt;/code&gt; or &lt;code&gt;(NY)&lt;/code&gt;. I put all U.S. state codes on that stoplist. Which meant &lt;code&gt;MA&lt;/code&gt; (Mastercard), &lt;code&gt;MS&lt;/code&gt; (Morgan Stanley), &lt;code&gt;DE&lt;/code&gt; (Deere), and &lt;code&gt;MO&lt;/code&gt; (Altria) all quietly became &lt;code&gt;null&lt;/code&gt;. Removed the state list, added tests for exactly those tickers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every "bug" was a bug.&lt;/strong&gt; I was sure duplicate &lt;code&gt;content_hash&lt;/code&gt; values meant I was losing data. They didn't. The hash fingerprints the transaction content on purpose, so identical trades in the same filing share it, and the unique &lt;code&gt;id&lt;/code&gt; handles the rest. It was already documented. I was the one who hadn't read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  I tried OCR. I stopped.
&lt;/h2&gt;

&lt;p&gt;Since 9 out of 10 scanned forms I checked were printed (not handwritten), OCR seemed doable. It mostly was.&lt;/p&gt;

&lt;p&gt;The forms turned out to be checkbox grids: the amount isn't written as text, it's an X in one of eleven lettered columns. Full-page OCR turned that into garbage. So I built a hybrid: OCR for text cells, pixel density for checkboxes.&lt;/p&gt;

&lt;p&gt;It got close. On one 6-page filing, date errors went from 29 of 111 rows down to 4. The remaining 4 were Tesseract reading a printed &lt;code&gt;2&lt;/code&gt; as &lt;code&gt;7&lt;/code&gt;, consistently, on digits a human reads instantly. &lt;code&gt;06/02/2026&lt;/code&gt; became &lt;code&gt;06/02/7026&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I could have "fixed" that with rules like "the year must be 2026, so 7 is probably 2." But that same guess on a month or day turns &lt;code&gt;02&lt;/code&gt; into &lt;code&gt;07&lt;/code&gt;, and now you have a wrong trade date that looks perfectly real. So the policy is all or nothing: if any row in a filing fails validation, the whole filing stays a &lt;code&gt;scanned_unparsed&lt;/code&gt; placeholder.&lt;/p&gt;

&lt;p&gt;The OCR code is sitting behind a disabled flag. Maybe a different engine later. For now, an honest placeholder beats a confident wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone building a data product
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Count what goes &lt;strong&gt;in&lt;/strong&gt;, not just what comes out. Index size vs output size, per run.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;return []&lt;/code&gt; in a parser is a smell. Make failure produce something visible.&lt;/li&gt;
&lt;li&gt;Treat "the run succeeded" and "the data is complete" as two different claims.&lt;/li&gt;
&lt;li&gt;When in doubt, emit less data with a clear flag rather than more data you can't vouch for.
The actors are here if you want to poke at them: &lt;a href="https://apify.com/seralifatih/congress-trading-pipeline-1" rel="noopener noreferrer"&gt;House&lt;/a&gt; and &lt;a href="https://apify.com/seralifatih/congress-trading-pipeline" rel="noopener noreferrer"&gt;Senate&lt;/a&gt;. If you find a filing that isn't accounted for, I genuinely want to know.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webscraping</category>
      <category>data</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Congressional trading data as an API — comparing pay-per-use vs subscription</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:00:58 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/congressional-trading-data-as-an-api-comparing-pay-per-use-vs-subscription-3cdk</link>
      <guid>https://dev.to/fatihbuilds/congressional-trading-data-as-an-api-comparing-pay-per-use-vs-subscription-3cdk</guid>
      <description>&lt;p&gt;Under the STOCK Act, members of the US Congress have to publicly disclose their stock trades. That data is public — but it's locked in PDF filings, and most people never see it because nobody's turned it into something queryable.&lt;/p&gt;

&lt;p&gt;A few tools do serve this data as structured output. Here's an honest look at the main options and where a pay-per-use API fits versus a subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  The subscription players
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Quiver Quantitative&lt;/strong&gt; has offered congressional trading data since 2020 and is probably the most established name here. Their API access starts around $25–30/month on the Hobbyist tier (billed annually), with higher tiers ($75/month+) unlocking insider trading, lobbying, and other datasets. It's a broad platform — congress trades are one dataset among many (lobbying, government contracts, corporate donors, sentiment, etc.), plus a web dashboard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unusual Whales&lt;/strong&gt; covers congressional trading as part of a much larger options-flow and dark-pool platform. Their dashboard starts at $50/month, and standalone API access has historically started around $150/month for the basic tier. It's built for active options traders — congress trading is a small slice of a much bigger toolset (100+ endpoints).&lt;/p&gt;

&lt;p&gt;Both are solid if you want the full platform: dashboards, alerts, backtesting, a maintained brand you can point a client to. You're paying for breadth and polish, and you're paying it every month whether you pull data once or every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a pay-per-use API fits
&lt;/h2&gt;

&lt;p&gt;If congressional trading is &lt;em&gt;all&lt;/em&gt; you need — no dashboard, no options flow, no lobbying data — a subscription is a lot of unused surface area. That's the gap I built into: two Apify actors, one for House filings and one for Senate filings, priced per 1,000 results instead of per month.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;House Congress Trade Tracker&lt;/strong&gt; — $2.00 / 1,000 results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Senate Congress Trade Tracker&lt;/strong&gt; — $3.00 / 1,000 results
No monthly floor. Pull data once for a one-off analysis and pay a few dollars, or schedule it daily and pay for what you actually consume. It runs on Apify, so you get their standard tooling for free: REST API, scheduled runs, webhooks, and client SDKs in whatever language you're already using.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Quiver Quantitative&lt;/th&gt;
&lt;th&gt;Unusual Whales&lt;/th&gt;
&lt;th&gt;Congress Trade Tracker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pricing model&lt;/td&gt;
&lt;td&gt;Subscription&lt;/td&gt;
&lt;td&gt;Subscription&lt;/td&gt;
&lt;td&gt;Pay-per-result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entry price&lt;/td&gt;
&lt;td&gt;~$25–30/mo&lt;/td&gt;
&lt;td&gt;~$50/mo (dashboard), ~$150/mo (API)&lt;/td&gt;
&lt;td&gt;$2–3 per 1k results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Congress trades + many other datasets&lt;/td&gt;
&lt;td&gt;Congress trades + options flow, dark pool, etc.&lt;/td&gt;
&lt;td&gt;Congress trades only (House + Senate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dashboard/alerts&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — API/data only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Ongoing research across multiple datasets&lt;/td&gt;
&lt;td&gt;Active options traders wanting one platform&lt;/td&gt;
&lt;td&gt;One-off pulls, small scripts, cost-sensitive automation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;This isn't a "cheaper and better" pitch — it's a different shape. Quiver and Unusual Whales give you a dashboard, alerts, and a dozen adjacent datasets you might grow into. If you want a UI to browse in your browser, they're the right pick. If you're a developer who wants one thing — clean, structured House/Senate trading data — without paying for a platform you won't use, a pay-per-result actor is the cheaper path in practically every scenario short of pulling data continuously at high volume.&lt;/p&gt;

&lt;p&gt;Links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;House: &lt;code&gt;apify.com/seralifatih/congress-trading-pipeline-1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Senate: &lt;code&gt;apify.com/seralifatih/congress-trading-pipeline&lt;/code&gt;
Happy to answer questions on the data format (JSON, STOCK Act PTR fields) in the comments.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>buildinpublic</category>
      <category>opendata</category>
      <category>finance</category>
    </item>
    <item>
      <title>I Built a Free World Cup 2026 Live Scores API on Apify — Here's How</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Thu, 18 Jun 2026 13:19:36 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/i-built-a-free-world-cup-2026-live-scores-api-on-apify-heres-how-576h</link>
      <guid>https://dev.to/fatihbuilds/i-built-a-free-world-cup-2026-live-scores-api-on-apify-heres-how-576h</guid>
      <description>&lt;p&gt;The World Cup is here and getting clean, structured match data is surprisingly painful. The official FIFA site has no public API. Third-party services either cost money, require OAuth, or return inconsistently shaped JSON that changes mid-tournament.&lt;/p&gt;

&lt;p&gt;I spent a few hours building a free Apify actor that wraps the &lt;a href="https://football-data.org" rel="noopener noreferrer"&gt;football-data.org&lt;/a&gt; v4 API and outputs clean, normalized JSON — ready to drop into a dashboard, a Discord bot, or an automated betting tool. Here's how it works and how to use it.&lt;/p&gt;

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

&lt;p&gt;Every run pushes records to an Apify dataset. A match record looks like this:&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;"fetchedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-18T12:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"match"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"matches"&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;"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;415082&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"utcDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-11T20:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Group Stage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"matchday"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"homeTeam"&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;"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;772&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mexico"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MEX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crest"&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://..."&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;"awayTeam"&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;"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;773&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"United States"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crest"&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://..."&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;"homeScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"awayScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"halfTimeHomeScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"halfTimeAwayScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"goals"&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;"minute"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;23&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;"REGULAR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"team"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Raúl Jiménez"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"assist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;"bookings"&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;"minute"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"team"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"away"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"player"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tyler Adams"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"card"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YELLOW"&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;"venue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Estadio Azteca"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"stats"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;Four modes are available:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;live&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All in-progress matches right now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;standings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full group stage table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;match&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Single match with goals + bookings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;full&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All completed matches (filterable by team)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;stats&lt;/code&gt; is always &lt;code&gt;null&lt;/code&gt;. Possession, shots, corners — these aren't available on football-data.org's free tier. I set the field to null explicitly rather than defaulting to 0, so consumers can handle the absence cleanly.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The interesting parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Rate limiting done right
&lt;/h3&gt;

&lt;p&gt;football-data.org's free tier caps you at 10 requests per minute. Naive polling will get you 429s. The client enforces a 6-second gap between every request using a timestamp-based throttle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;throttle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastRequestAt&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastRequestAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;THROTTLE_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;THROTTLE_MS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastRequestAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;If a 429 slips through anyway (burst from a previous run), the retry loop adds an extra 6s on top before trying again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;THROTTLE_MS&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;4xx responses that aren't 429 bail out immediately — no point retrying a bad request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Normalizing the raw API shape
&lt;/h3&gt;

&lt;p&gt;football-data.org returns statuses like &lt;code&gt;FINISHED&lt;/code&gt;, &lt;code&gt;IN_PLAY&lt;/code&gt;, &lt;code&gt;TIMED&lt;/code&gt;, &lt;code&gt;PAUSED&lt;/code&gt;. I normalize these to four clean consumer-friendly values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STATUS_MAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;FINISHED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;IN_PLAY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in_progress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PAUSED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in_progress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;SCHEDULED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scheduled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;TIMED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scheduled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;AWARDED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Groups come back as &lt;code&gt;GROUP_A&lt;/code&gt;, &lt;code&gt;GROUP_B&lt;/code&gt;, etc. A quick regex strips the prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mapGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;raw&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;undefined&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^GROUP_&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&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;match&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;match&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="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;Goals and bookings reference the scoring team by &lt;code&gt;team.id&lt;/code&gt;. To label them as &lt;code&gt;home&lt;/code&gt; or &lt;code&gt;away&lt;/code&gt;, I compare against &lt;code&gt;homeTeam.id&lt;/code&gt; — no string matching, no locale issues:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;team&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;homeId&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home&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="s1"&gt;away&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Get a free API key
&lt;/h3&gt;

&lt;p&gt;Sign up at &lt;a href="https://football-data.org" rel="noopener noreferrer"&gt;football-data.org&lt;/a&gt; — takes 30 seconds, no credit card.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Run the actor
&lt;/h3&gt;

&lt;p&gt;Go to &lt;strong&gt;&lt;a href="https://apify.com/seralifatih/wc2026-stats" rel="noopener noreferrer"&gt;apify.com/seralifatih/wc2026-stats&lt;/a&gt;&lt;/strong&gt; and hit &lt;strong&gt;Try for free&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Input:&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;"footballDataApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_key_here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"live"&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;Or via the Apify API:&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;POST https://api.apify.com/v2/acts/seralifatih~wc2026-stats/runs?token=YOUR_APIFY_TOKEN
Content-Type: application/json

{ "footballDataApiKey": "your_key_here", "mode": "live" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results land in the run's default dataset as individual JSON items.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Schedule it for live match days
&lt;/h3&gt;

&lt;p&gt;Set up an &lt;a href="https://docs.apify.com/platform/schedules" rel="noopener noreferrer"&gt;Apify Scheduler&lt;/a&gt; with cron &lt;code&gt;*/2 * * * *&lt;/code&gt; in &lt;code&gt;live&lt;/code&gt; mode. Each run pushes only currently in-progress matches. Your dataset stays fresh without polling from your own server.&lt;/p&gt;

&lt;p&gt;The World Cup runs &lt;strong&gt;June 11 – July 19, 2026&lt;/strong&gt;. Only enable the scheduler on match days to avoid burning free compute.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Filter by team
&lt;/h3&gt;

&lt;p&gt;Pass a &lt;code&gt;teamId&lt;/code&gt; with &lt;code&gt;mode: full&lt;/code&gt; to get only a specific country's completed matches:&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;"footballDataApiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_key_here"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"teamId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;773&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;Useful for single-country dashboards or per-team analytics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 20 + TypeScript&lt;/strong&gt; — async pipeline, typed throughout&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apify SDK v3&lt;/strong&gt; — dataset push, input/output schema, proxy support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;axios&lt;/strong&gt; — HTTP client with timeout and retry logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;football-data.org v4&lt;/strong&gt; — the underlying data source (free tier)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source: &lt;a href="https://github.com/seralifatih/wc2026-stats" rel="noopener noreferrer"&gt;github.com/seralifatih/wc2026-stats&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;What are you building with World Cup data? Drop it in the comments.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>worldcup</category>
      <category>typescript</category>
      <category>api</category>
    </item>
    <item>
      <title>I built a personal job alert pipeline. Here's the stack and what I learned.</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Mon, 11 May 2026 13:54:50 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/i-built-a-personal-job-alert-pipeline-for-the-un-heres-the-stack-and-what-i-learned-428h</link>
      <guid>https://dev.to/fatihbuilds/i-built-a-personal-job-alert-pipeline-for-the-un-heres-the-stack-and-what-i-learned-428h</guid>
      <description>&lt;p&gt;I work at UN. UN job hunting is a particular kind of suffering: openings are scattered across &lt;a href="https://careers.un.org" rel="noopener noreferrer"&gt;careers.un.org&lt;/a&gt;, &lt;a href="https://reliefweb.int" rel="noopener noreferrer"&gt;reliefweb.int&lt;/a&gt;, agency-specific portals, and aggregators of varying quality. None of them know my profile. None let me say "only show me P-2 and P-3 protection roles in family duty stations outside Türkiye." So I built one.&lt;/p&gt;

&lt;p&gt;This is the story of how I built that — and what the build itself taught me about respecting the tools you scrape from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bad idea I started with
&lt;/h2&gt;

&lt;p&gt;My first instinct was simple. Open &lt;a href="https://unjobs.org" rel="noopener noreferrer"&gt;unjobs.org&lt;/a&gt;, it lists everything in one place, write a &lt;code&gt;requests + BeautifulSoup&lt;/code&gt; scraper, done. Maybe a Cloudflare Worker, maybe a small Next.js dashboard, ship by Sunday.&lt;/p&gt;

&lt;p&gt;I asked Claude to help me scope it. The first thing it did was fetch the site to see the structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ROBOTS_DISALLOWED: URL is disallowed by robots.txt rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right. &lt;code&gt;unjobs.org&lt;/code&gt; explicitly forbids scraping. And the more I read into it, the worse the idea got:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The site is itself an &lt;strong&gt;aggregator&lt;/strong&gt; — it scrapes from official sources. So I'd be scraping a scraper, two layers downstream from truth.&lt;/li&gt;
&lt;li&gt;Their Terms of Service prohibit automated access. Ignoring that is not just rude, it's legally risky in some jurisdictions (&lt;a href="https://en.wikipedia.org/wiki/HiQ_Labs_v._LinkedIn" rel="noopener noreferrer"&gt;HiQ vs LinkedIn&lt;/a&gt; territory). Scraping the ecosystem I work in, against ToS, is not a career-positive move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I asked the obvious follow-up: where does unjobs.org get its data?&lt;/p&gt;

&lt;p&gt;The answer: from the same sources I could go to directly. &lt;strong&gt;ReliefWeb has a free, documented public API&lt;/strong&gt;. &lt;strong&gt;careers.un.org has an RSS feed.&lt;/strong&gt; The "hard problem" of aggregating UN jobs was already solved upstream — I just had to be willing to put in more wiring than &lt;code&gt;pip install beautifulsoup4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the first lesson and I want to flag it loudly: &lt;strong&gt;before you scrape, check whether the data has a front door.&lt;/strong&gt; Aggregators exist because real APIs are obscure or undocumented, not because they don't exist. Five minutes of looking saved me a brittle pipeline, legal exposure, and a worse data quality outcome.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ReliefWeb API (JSON)           careers.un.org (RSS + JSON)
        │                                │
        ▼                                ▼
  sources/reliefweb.py           sources/careers_un.py
        │                                │
        └──────────► Job (normalized) ◄──┘
                       │
                       ▼
                  scraper.py
                  - dedupe (title + source)
                  - score 0–100 (keyword + grade + location)
                  - filter by MIN_SCORE
                       │
                       ▼
                   store.py        ← SQLite, "have I already emailed this?"
                       │
                       ▼
                  notifier.py      ← Resend, HTML email
                       │
                       ▼
               systemd timer       ← Mon + Thu, 09:00 Istanbul time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adapter pattern: each source normalizes to one &lt;code&gt;Job&lt;/code&gt; dataclass. Adding a new source (untalent.org, UNDP, ICRC) is a 50-line file in &lt;code&gt;sources/&lt;/code&gt;. Everything downstream — scoring, dedup, store, notifier — never changes.&lt;/p&gt;

&lt;p&gt;Stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.12&lt;/strong&gt;, &lt;code&gt;requests&lt;/code&gt;, &lt;code&gt;xml.etree&lt;/code&gt;, &lt;code&gt;sqlite3&lt;/code&gt;. No frameworks. The whole thing is ~600 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hetzner VPS&lt;/strong&gt; (€4/mo Ubuntu box I already had)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;systemd timer&lt;/strong&gt; instead of cron — better logs, easier debugging, &lt;code&gt;Persistent=true&lt;/code&gt; re-runs if the box was off at scheduled time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt; for transactional email (already had a domain via &lt;a href="https://noktastudio.dev" rel="noopener noreferrer"&gt;Nokta Studio&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite&lt;/strong&gt; as the "seen" store — a single file, no daemon, no migration framework&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No Docker. No Kubernetes. No cloud functions. A &lt;code&gt;venv&lt;/code&gt;, a &lt;code&gt;.env&lt;/code&gt;, two systemd units. The whole thing deploys with one shell script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovering the API that wasn't documented
&lt;/h2&gt;

&lt;p&gt;ReliefWeb has a &lt;a href="https://apidoc.reliefweb.int" rel="noopener noreferrer"&gt;great public API&lt;/a&gt;. The &lt;code&gt;careers.un.org&lt;/code&gt; side was less obvious.&lt;/p&gt;

&lt;p&gt;The RSS feed is fine for discovery — title, level, duty station, deadline — but the body of each posting isn't there. So a "Programme Management Officer" at UNCTAD reads identically to one at UN until you read the full description. With only the RSS metadata, scoring was guessing.&lt;/p&gt;

&lt;p&gt;I knew the site had to have an internal API somewhere because it's an Angular SPA — &lt;code&gt;view-source:&lt;/code&gt; returns an empty shell, then JavaScript fetches and renders content. So the data is being pulled from &lt;em&gt;something&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Browser DevTools, Network tab, filter to Fetch/XHR, reload the page. After clicking through a few requests, here it is:&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://careers.un.org/api/public/opening/joV2/&amp;lt;jobId&amp;gt;/en
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns a clean JSON with &lt;code&gt;jobDescription&lt;/code&gt;, &lt;code&gt;jobLevel&lt;/code&gt;, &lt;code&gt;dutyStation&lt;/code&gt;, &lt;code&gt;jobFamily&lt;/code&gt;, the whole structured record. No auth required. The aggregators I was about to scrape? They almost certainly use exactly this endpoint.&lt;/p&gt;

&lt;p&gt;This kept being the pattern. Every "we don't have an API" turned out to be "we don't have a &lt;em&gt;documented&lt;/em&gt; API, but our frontend reads JSON from somewhere and that somewhere is reachable." DevTools is, frankly, the most underrated tool in a builder's stack.&lt;/p&gt;

&lt;p&gt;A small caveat: when you find an endpoint this way, you should still ask whether you're respecting reasonable use. I added 400ms between requests, a custom User-Agent identifying the project, and capped the run at 200 details/run. The whole pipeline makes about 400 requests per week. That's negligible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoring engine, and why it matters more than the scraping
&lt;/h2&gt;

&lt;p&gt;The scraper is the boring part. The interesting part is: given 400 fetched jobs, &lt;strong&gt;which 5–10 should land in my inbox?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My answer was a weighted keyword scorer that knows my CV:&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;CORE_KEYWORDS&lt;/span&gt; &lt;span class="o"&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;refugee&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;asylum&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;migration&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;protection&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;resettlement&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;statelessness&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;psea&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Fraud/Integrity — my current role
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fraud&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;integrity&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;investigation&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;misconduct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Cash-Based Interventions — trained
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cbi&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;cash-based&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;cash assistance&lt;/span&gt;&lt;span class="sh"&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="n"&gt;SOFT_KEYWORDS&lt;/span&gt; &lt;span class="o"&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;humanitarian&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;human rights&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# weak signals
&lt;/span&gt;
&lt;span class="n"&gt;NEGATIVE_KEYWORDS&lt;/span&gt; &lt;span class="o"&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;intern&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;internship&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;volunteer&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;driver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# IT roles — wrong profile
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;information systems officer&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;cloud engineer&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;service desk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Senior leadership — out of grade band
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;director,&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;deputy director&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;chief of mission&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Wrong functions
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;legal officer&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;auditor&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;procurement officer&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;counter-terrorism&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;finance and budget&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;economic affairs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;FAMILY_DUTY_STATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;# whitelist
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;geneva&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;vienna&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;rome&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;copenhagen&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;new york&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;bangkok&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;amman&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;nairobi&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;panama city&lt;/span&gt;&lt;span class="sh"&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="n"&gt;NON_FAMILY_STATIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c1"&gt;# hardship — hard reject
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kabul&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;mogadishu&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;juba&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;kyiv&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;damascus&lt;/span&gt;&lt;span class="sh"&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;Then score in layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;+30&lt;/code&gt; Türkiye-based (or, in my case, &lt;code&gt;−reject&lt;/code&gt; because I want to relocate)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+20&lt;/code&gt; Family duty station&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+10&lt;/code&gt; per core keyword (capped at +30)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+5&lt;/code&gt; soft keyword (only if no core hit, to avoid double-counting "humanitarian")&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+5&lt;/code&gt; per domain keyword (capped)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+25&lt;/code&gt; if P-2 ("next-step fit"), &lt;code&gt;+15&lt;/code&gt; if P-3 ("stretch")&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+10&lt;/code&gt; priority agency (priority organizations)&lt;/li&gt;
&lt;li&gt;Hard reject if grade is outside P-2/P-3, or if any negative keyword hits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first version was too generous. "humanitarian" matched almost every UN posting and pumped them all to 30+. The fix: demote it to soft, only count if no core keyword fired.&lt;/p&gt;

&lt;p&gt;The second version was bug-ridden. A regex &lt;code&gt;\bintern\b&lt;/code&gt; on the title &lt;code&gt;INTERNATIONAL CONSULTANT&lt;/code&gt; triggered the intern filter because I wasn't using whole-word boundaries correctly. A few false-positive iterations later, scoring stabilized. Watching real output and iterating is the only way to get this right — you can't reason your way to good filters from a CV alone.&lt;/p&gt;

&lt;p&gt;The thing I'm most happy about: the system handles &lt;strong&gt;its own ignorance&lt;/strong&gt; gracefully. When body content is sparse (RSS-only fallback), scores are lower. When duty station isn't in either whitelist or blacklist, it gets a &lt;code&gt;📍 unverified&lt;/code&gt; tag with a slight penalty, so it surfaces but isn't claimed to be a perfect match. Calibrated uncertainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dry-run loop
&lt;/h2&gt;

&lt;p&gt;The single most useful thing I added was a dry-run mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DRY_RUN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;MIN_SCORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 python &lt;span class="nt"&gt;-m&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fetches everything, scores everything, prints two tables — top matches &lt;em&gt;and&lt;/em&gt; top near-misses — but doesn't write to the DB and doesn't send email. Tuning loop becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run dry-run&lt;/li&gt;
&lt;li&gt;See which obviously-wrong job got 60 points&lt;/li&gt;
&lt;li&gt;Read the reasons it gave that score&lt;/li&gt;
&lt;li&gt;Add/remove a keyword&lt;/li&gt;
&lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went through maybe 15 iterations of this in an afternoon. The "reasons" output, where each scored job lists the signals that built up its score, was crucial. Without it I'd have been blindly editing keyword sets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ 70] HUMAN RIGHTS OFFICER / OPEN-SOURCE INVESTIGATOR (TJO), P3
      📍 OHCHR / Nairobi
      🏡 Family duty station: Nairobi
      ✅ Profile match: integrity, investigation
      📊 Data/M&amp;amp;E/IM: reporting
      🎯 P3 (stretch but doable)
      🤝 Priority agency: OHCHR
      📰 via UN Careers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a score is interpretable, the calibration loop is fast. When it's a black box, you're guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring stuff that mattered
&lt;/h2&gt;

&lt;p&gt;A few choices that felt over-engineered at the time but I'm glad I made:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adapter pattern.&lt;/strong&gt; Each source is a module exposing &lt;code&gt;fetch() -&amp;gt; list[Job]&lt;/code&gt;. Two sources today, three or four next month. Adding a source doesn't touch scoring, store, notifier, or the main loop. The &lt;code&gt;Job&lt;/code&gt; dataclass is the contract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite for "seen" state.&lt;/strong&gt; I considered keeping last-run state in a JSON file. SQLite is one extra &lt;code&gt;import sqlite3&lt;/code&gt; and gets me indexed lookups, transactions, and longevity. Total overhead: maybe 30 lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate &lt;code&gt;jobalert&lt;/code&gt; system user.&lt;/strong&gt; The service doesn't run as root. The &lt;code&gt;.env&lt;/code&gt; (with API key) is chmod 600. The unit has &lt;code&gt;NoNewPrivileges&lt;/code&gt;, &lt;code&gt;ProtectSystem=strict&lt;/code&gt;, &lt;code&gt;PrivateTmp&lt;/code&gt;. If something gets compromised, the blast radius is tiny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boilerplate stripping.&lt;/strong&gt; UN job descriptions all end with the same ~2000 words of legal boilerplate ("No Fee", "United Nations Considerations", "Special Notice"). Without stripping this, scoring would pick up keywords from boilerplate that have nothing to do with the actual role. One regex cut, big quality improvement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotent install script.&lt;/strong&gt; A single &lt;code&gt;bash deploy/install.sh&lt;/code&gt; does everything: user creation, code sync, venv setup, dependency install, .env templating, systemd registration. Safe to re-run. The first time I had to redeploy to debug something at midnight, this saved my sanity.&lt;/p&gt;

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

&lt;p&gt;The system is running. It mailed me 7 P-3 roles on the first production run, all genuinely worth a look. Twice a week from now on.&lt;/p&gt;

&lt;p&gt;But there's a clear progression:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ReliefWeb appname is pending approval.&lt;/strong&gt; When it lands, the agency mix changes drastically — UNHCR, IOM, NGOs flood in. Scoring will need recalibration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM-based scoring.&lt;/strong&gt; Right now keyword matching is the engine. A small Claude API call per job — "given this CV and this job description, output a 0-100 fit score and a one-line reasoning" — would be dramatically more accurate. About $1/month at my scale. The keyword scorer becomes a pre-filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A web dashboard.&lt;/strong&gt; Next.js + the SQLite database, so I can see what got filtered and why, mark jobs as "applied," track outcomes over time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More sources.&lt;/strong&gt; untalent.org has a JSON API. UNDP has its own. ICRC, MSF, and others have feeds I haven't touched yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're building a personal automation pipeline, three things to actually internalize:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Front doors first, side doors second.&lt;/strong&gt; Before you reach for a scraper, look for an API, an RSS feed, a JSON endpoint behind the Angular SPA. The 30 minutes you spend looking is repaid 100x in maintenance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boring stack, weird scoring.&lt;/strong&gt; The infrastructure should be unremarkable — Python, SQLite, systemd, one VPS. The interesting work is the domain modeling: "what does a good match look like &lt;em&gt;for me&lt;/em&gt;?" That's where the value compounds, not in your choice of framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dry-run mode is non-negotiable.&lt;/strong&gt; Any system that filters or scores or notifies needs a way to see what &lt;em&gt;would&lt;/em&gt; happen without committing. The tuning loop dies without it.&lt;/p&gt;

&lt;p&gt;The code is ~600 lines. The build took a weekend with &lt;a href="https://claude.ai" rel="noopener noreferrer"&gt;Claude&lt;/a&gt; as a pairing partner — I'd built the core in an evening, then spent a longer second session calibrating the scorer against real data. Going to open-source it once I've added LLM scoring and a dashboard; will link from &lt;a href="https://x.com/fatihbuilds" rel="noopener noreferrer"&gt;@fatihbuilds&lt;/a&gt; when it's up.&lt;/p&gt;

&lt;p&gt;If you're in the humanitarian sector and want this, message me. If you're not but want to adapt the pattern for your own job search — the keyword-based scoring layer ports to any domain. Same code, different &lt;code&gt;CORE_KEYWORDS&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>automation</category>
    </item>
    <item>
      <title>Building LoopSignal Part 3: Closing the Loop with GitHub Issues and Status Notifications</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Fri, 08 May 2026 16:16:55 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/building-loopsignal-part-3-closing-the-loop-with-github-issues-and-status-notifications-4e2d</link>
      <guid>https://dev.to/fatihbuilds/building-loopsignal-part-3-closing-the-loop-with-github-issues-and-status-notifications-4e2d</guid>
      <description>&lt;p&gt;In part one, I covered the anonymous submission flow. In part two, I covered the public board, voting, and how I kept prioritization deliberately simple.&lt;/p&gt;

&lt;p&gt;This part is about the piece I am most happy with: closing the loop.&lt;/p&gt;

&lt;p&gt;The whole reason LoopSignal is called LoopSignal is that feedback is supposed to come back to the user who left it. Someone submits a request, the team works on it, it ships, and the original submitter hears about it. That round trip is the entire product.&lt;/p&gt;

&lt;p&gt;For most teams, the place that work actually happens is GitHub. So if LoopSignal is going to close the loop, it has to live where the engineering work lives. That meant a real GitHub integration: linking posts to issues, automatically closing posts when issues close, and notifying everyone who cared.&lt;/p&gt;

&lt;p&gt;Here is how I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "closing the loop" actually requires
&lt;/h2&gt;

&lt;p&gt;Before writing any code, I listed what closing the loop really meant.&lt;/p&gt;

&lt;p&gt;It needed to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;let a team connect a GitHub repo to a project&lt;/li&gt;
&lt;li&gt;create a GitHub issue from an approved feedback post with one click&lt;/li&gt;
&lt;li&gt;listen for issue events from GitHub via webhooks&lt;/li&gt;
&lt;li&gt;automatically update the feedback post's status when the issue closes or reopens&lt;/li&gt;
&lt;li&gt;notify everyone subscribed to the post when its status changes&lt;/li&gt;
&lt;li&gt;handle anonymous subscribers and registered users equally&lt;/li&gt;
&lt;li&gt;not block the rest of the product if GitHub is slow or unreachable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is more moving parts than the submission flow or the public board, but each piece is small if you keep them separate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting a repo: OAuth, scope, and storage
&lt;/h2&gt;

&lt;p&gt;The connection flow is a standard GitHub OAuth dance, with one detail that matters.&lt;/p&gt;

&lt;p&gt;The initiate route encodes both &lt;code&gt;projectId&lt;/code&gt; and &lt;code&gt;userId&lt;/code&gt; into the OAuth &lt;code&gt;state&lt;/code&gt; parameter so the callback can verify access before binding a token to a project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&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;githubAuthUrl&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://github.com/login/oauth/authorize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;githubAuthUrl&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;client_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;githubAuthUrl&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;redirect_uri&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;githubAuthUrl&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;scope&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;repo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;githubAuthUrl&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;state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The callback decodes the state, verifies the user actually owns the project, exchanges the code for an access token, and stores the token on the &lt;code&gt;projects&lt;/code&gt; row in a &lt;code&gt;github_token&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;I chose &lt;code&gt;scope: "repo"&lt;/code&gt; deliberately. A more granular scope would have been nicer, but creating issues, registering webhooks, and reading the repo all need it, and trying to split that into multiple flows would add friction without any real security gain for the kind of teams using this product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating an issue from an approved post
&lt;/h2&gt;

&lt;p&gt;Once a project has a connected repo, every approval can optionally produce an issue.&lt;/p&gt;

&lt;p&gt;The logic lives in the moderation action. When a post moves from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;approved&lt;/code&gt;, and the project has both &lt;code&gt;github_repo&lt;/code&gt; and &lt;code&gt;github_token&lt;/code&gt; set, it fires off an issue creation in the background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_repo&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;createGitHubIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;project&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&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;That &lt;code&gt;.catch(console.error)&lt;/code&gt; matters. The product's source of truth is the LoopSignal database, not GitHub. If GitHub is rate-limiting us or down, the post should still move to approved. The issue creation is an enhancement, not a dependency.&lt;/p&gt;

&lt;p&gt;The actual creation just hits the GitHub REST API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&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="s2"&gt;`https://api.github.com/repos/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;owner&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;repo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/issues`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;github_token&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="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.github+json&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&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="na"&gt;body&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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n\n---\nCreated from LoopSignal feedback`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;After it succeeds, the post row gets a &lt;code&gt;github_issue_url&lt;/code&gt; column set to the issue's &lt;code&gt;html_url&lt;/code&gt;. That URL is the link between the two systems for the rest of the lifecycle.&lt;/p&gt;

&lt;p&gt;The footer line in the issue body — "Created from LoopSignal feedback" — exists for a real reason. It tells the engineer reading the issue why it exists, and where to look if they want context like vote counts or comments. Without that, GitHub issues created from external sources tend to feel orphaned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registering the webhook
&lt;/h2&gt;

&lt;p&gt;Connecting the repo also registers a webhook. This is the part that lets GitHub talk back to LoopSignal when something changes.&lt;/p&gt;

&lt;p&gt;I check for an existing webhook first, because re-registering on every connect would create duplicates and re-hook on every page reload during testing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hooksRes&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repoApi&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/hooks`&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hooksRes&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyHooked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;config&lt;/span&gt;&lt;span class="p"&gt;?:&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;webhookUrl&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;alreadyHooked&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;repoApi&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/hooks`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;web&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;active&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="na"&gt;events&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;issues&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;webhookUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&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="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;I only subscribe to the &lt;code&gt;issues&lt;/code&gt; event. Pull requests, pushes, and the rest are not part of the loop right now, and subscribing to events you do not use is a great way to fill your logs with noise.&lt;/p&gt;

&lt;p&gt;The webhook URL is built from the request host so this works the same way locally and in production without environment-specific config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reacting to issue events
&lt;/h2&gt;

&lt;p&gt;The webhook handler is small on purpose. It gets one job: translate GitHub issue actions into LoopSignal workflow statuses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;workflowStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkflowStatus&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;closed&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="nx"&gt;workflowStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&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="k"&gt;else&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;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reopened&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="nx"&gt;workflowStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open&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="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;workflowStatus&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="nx"&gt;NextResponse&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="na"&gt;ok&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;github_issue_url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&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;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&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="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;workflow_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;workflowStatus&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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 few things were intentional here.&lt;/p&gt;

&lt;p&gt;I match posts to issues by the full &lt;code&gt;html_url&lt;/code&gt;, not by issue number. Issue numbers are only unique within a repo, and a project could in theory swap which repo it is connected to. The HTML URL is globally unique and already stored when the issue was created.&lt;/p&gt;

&lt;p&gt;I ignore actions other than &lt;code&gt;closed&lt;/code&gt; and &lt;code&gt;reopened&lt;/code&gt;. &lt;code&gt;edited&lt;/code&gt;, &lt;code&gt;assigned&lt;/code&gt;, &lt;code&gt;labeled&lt;/code&gt;, and the rest can be useful eventually, but right now they would just create noise in the public board's history. Closing the loop only needs the two events that change visible state.&lt;/p&gt;

&lt;p&gt;I also do not currently verify the webhook signature. That is on my list. For an early version with low traffic, the cost of a malicious webhook is bounded — at worst, someone could mark posts as completed — and the engineering cost of HMAC verification is small enough that I would rather wait until I am sure the webhook surface is stable before locking it in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notifying the people who care
&lt;/h2&gt;

&lt;p&gt;A status change without notifications is just a database update. The whole reason for this system is that someone submitted feedback and wants to know what happened to it.&lt;/p&gt;

&lt;p&gt;Notifications run from a small helper that fires after status changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meaningfulStatuses&lt;/span&gt; &lt;span class="o"&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;planned&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;in_progress&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;completed&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;closed&lt;/span&gt;&lt;span class="dl"&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;meaningfulStatuses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newStatus&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notifications_enabled, name, slug&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&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;project&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;notifications_enabled&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;subscriptions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post_subscriptions&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id, user_id, email&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unsubscribed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in here matter more than they might look.&lt;/p&gt;

&lt;p&gt;First, only some statuses send email. &lt;code&gt;open&lt;/code&gt; does not — every post starts as open, so emailing about it would be spam. The notification list is the four states that actually represent progress.&lt;/p&gt;

&lt;p&gt;Second, the project itself has a &lt;code&gt;notifications_enabled&lt;/code&gt; flag. Some teams want quiet boards, especially during early product development, and forcing them into outbound email would push them off the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anonymous and registered subscribers in one table
&lt;/h2&gt;

&lt;p&gt;This part took a bit of thought.&lt;/p&gt;

&lt;p&gt;Subscribers can come from two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;registered users who voted while logged in&lt;/li&gt;
&lt;li&gt;anonymous users who left an email when submitting&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than create separate tables for each, I store both in a single &lt;code&gt;post_subscriptions&lt;/code&gt; table with two nullable columns: &lt;code&gt;user_id&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt;. At notification time, the code resolves whichever is set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;recipientEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&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;recipientEmail&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;recipientEmail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;??&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="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;recipientEmail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendStatusUpdateEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;recipientEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;postTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&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;newStatus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;unsubscribeUrl&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;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/unsubscribe?id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the subscription model simple and lets registered users update their email in one place — their account — rather than having to update every subscription. Anonymous emails are stored directly because there is nothing else to look them up against.&lt;/p&gt;

&lt;p&gt;Every email carries an unsubscribe link tied to the specific subscription row. One click marks &lt;code&gt;unsubscribed = true&lt;/code&gt; and the row stops receiving future updates. That is non-negotiable for any system sending unsolicited mail to people who left an email a long time ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the loop actually feels like now
&lt;/h2&gt;

&lt;p&gt;When all of these pieces line up, the experience is the part of the product I am most proud of.&lt;/p&gt;

&lt;p&gt;Someone submits feedback anonymously. A team member approves it. A GitHub issue gets created. The team works on the feature. They merge the PR and close the issue. GitHub fires the webhook. LoopSignal moves the post to &lt;code&gt;completed&lt;/code&gt;. Everyone subscribed gets an email saying the thing they asked for shipped, with a link back to the post and an option to unsubscribe.&lt;/p&gt;

&lt;p&gt;The user who submitted the original feedback never had to create an account, never had to log in to check status, never had to chase the team for an update. The system did the chasing for them.&lt;/p&gt;

&lt;p&gt;That is the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned building this piece
&lt;/h2&gt;

&lt;p&gt;A few things stood out while putting this together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External integrations should fail soft.&lt;/strong&gt; Issue creation, webhook delivery, and email sending all run async and non-blocking from the user-visible moderation action. If GitHub is unreachable, the post still gets approved. If email fails, the status still updates. The product database is the source of truth, and the integrations enhance it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subscribe to as little as possible.&lt;/strong&gt; The webhook only listens for &lt;code&gt;issues&lt;/code&gt; events, and the handler only acts on &lt;code&gt;closed&lt;/code&gt; and &lt;code&gt;reopened&lt;/code&gt;. Every event you do not handle is a future log line, a future bug, a future maintenance question. Smaller surface area is easier to reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity layers should converge late.&lt;/strong&gt; Anonymous emails and registered user IDs both live in the subscriptions table without being merged. They only resolve to a real recipient at the moment we send the email. That keeps the data model simple and avoids brittle account-linking logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The unsubscribe link is part of the product, not an afterthought.&lt;/strong&gt; It needs to be in every email, work without an account, and be a one-click flow. Treating it as an edge case is how products end up with angry users and ignored mail.&lt;/p&gt;

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

&lt;p&gt;In the next article, I will cover the changelog: how completed posts roll into a public, shareable list of what shipped, how it stays in sync with the board automatically, and why I decided the changelog is the changelog page rather than a separate authoring surface.&lt;/p&gt;

&lt;p&gt;If you are building something similar, my takeaway from this part is simple:&lt;/p&gt;

&lt;p&gt;The loop is the product.&lt;/p&gt;

&lt;p&gt;Submission, voting, and prioritization all matter, but they only become valuable when the user who left feedback hears that something happened. Build that round trip first, and the rest of the product gets clearer.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>sass</category>
      <category>supabase</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I built two Apify actors that scrape U.S. Congress trading data — directly from government sources, no QuiverQuant</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Thu, 30 Apr 2026 20:16:24 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/i-built-two-apify-actors-that-scrape-us-congress-trading-data-directly-from-government-sources-47m1</link>
      <guid>https://dev.to/fatihbuilds/i-built-two-apify-actors-that-scrape-us-congress-trading-data-directly-from-government-sources-47m1</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built two Apify actors that pull every U.S. Senate and House Periodic Transaction Report (PTR) directly from official government sources, parse them into a clean JSON dataset, and expose them through Apify's API. They replace &lt;a href="https://quiverquant.com" rel="noopener noreferrer"&gt;QuiverQuant&lt;/a&gt;'s congressional-trading endpoint at roughly 1/10th the cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Senate actor:&lt;/strong&gt; &lt;a href="https://apify.com/seralifatih/congress-trading-pipeline" rel="noopener noreferrer"&gt;apify.com/seralifatih/congress-trading-pipeline&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;House actor:&lt;/strong&gt; &lt;a href="https://apify.com/seralifatih/congress-trading-pipeline-1" rel="noopener noreferrer"&gt;apify.com/seralifatih/congress-trading-pipeline-1&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both actors emit the same schema. Run one, the other, or chain them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;I had a personal trading dashboard pulling congressional trade data from QuiverQuant. $30/month, fine. But the API kept returning slightly different shapes between endpoints, occasional 500s during market hours, and the per-transaction granularity I needed sat behind a higher tier.&lt;/p&gt;

&lt;p&gt;The data itself is &lt;strong&gt;public domain&lt;/strong&gt;. The STOCK Act (2012) requires every member of Congress to file every trade within 45 days. The Senate publishes them at &lt;a href="https://efdsearch.senate.gov" rel="noopener noreferrer"&gt;efdsearch.senate.gov&lt;/a&gt;. The House publishes them as a daily-updated ZIP file at &lt;a href="https://disclosures-clerk.house.gov" rel="noopener noreferrer"&gt;disclosures-clerk.house.gov&lt;/a&gt;. Both free, both public, both indexed by every aggregator that resells them.&lt;/p&gt;

&lt;p&gt;Two weekends of work later, I have my own pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Output schema
&lt;/h2&gt;

&lt;p&gt;Every transaction from both actors normalizes to this shape:&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4d6016b44239f646476ffac6798f21ae3e32c8ed75ea6c5b50a0bbdf9e5d3296"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"politician"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mark Alford"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-16"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filing_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ticker"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"AMZN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"asset_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Amazon.com, Inc. - Common Stock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"asset_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;"Stock"&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;"sell"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amount_min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"amount_max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"self"&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-256 of `politician\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;type&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;buy&lt;/code&gt; or &lt;code&gt;sell&lt;/code&gt; (House &lt;code&gt;Purchase&lt;/code&gt;/&lt;code&gt;Sale (Full)&lt;/code&gt;/&lt;code&gt;Sale (Partial)&lt;/code&gt; mapped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;amount_min&lt;/code&gt; / &lt;code&gt;amount_max&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Cents-precise bounds parsed from PTR brackets (&lt;code&gt;$1,001 - $15,000&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amount_max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; for unbounded "Over $X" disclosures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ticker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;null&lt;/code&gt; for bonds, municipals, structured notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;owner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;self&lt;/code&gt; / &lt;code&gt;joint&lt;/code&gt; / &lt;code&gt;spouse&lt;/code&gt; / &lt;code&gt;child&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;id&lt;/code&gt; lets you idempotently sync into your own DB without seeing duplicates across runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  How they work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Senate actor
&lt;/h3&gt;

&lt;p&gt;The Senate's disclosure system is a Django app at &lt;code&gt;efdsearch.senate.gov&lt;/code&gt;. Three things make it tricky:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Akamai bot protection.&lt;/strong&gt; Direct curl gets a 403. Even with a residential proxy, Apify's default datacenter pool gets blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terms-acceptance gate.&lt;/strong&gt; Every session must POST &lt;code&gt;prohibition_agreement=1&lt;/code&gt; to &lt;code&gt;/search/home/&lt;/code&gt; before any disclosure URL works. The session cookie expires fast and silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two-stage data flow.&lt;/strong&gt; The DataTables search endpoint at &lt;code&gt;/search/report/data/&lt;/code&gt; returns &lt;em&gt;filings&lt;/em&gt; (one row per PTR document). Each row links to a separate detail page that contains the actual transactions.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The actor handles all three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pin to a single residential exit IP for the whole run&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`senate_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proxyUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;proxyConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why pinned IP? Django keeps &lt;code&gt;prohibition_agreement&lt;/code&gt; state per-IP. If Apify rotates exit IPs between requests, the second request gets redirected back to the agreement page even though our cookie jar has the right session ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Walk the redirect chain manually so we capture Set-Cookie at each hop&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;maxRedirects&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&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="o"&gt;=&amp;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;setCookie&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;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;set-cookie&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;jar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setCookie&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;config&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="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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Axios's built-in redirect follower drops &lt;code&gt;Set-Cookie&lt;/code&gt; headers from intermediate responses. Django sets the session cookie on the 302 returned by the agreement POST, so we have to walk the chain ourselves.&lt;/p&gt;

&lt;p&gt;For each PTR detail page, the actor parses the HTML transaction table with cheerio. About 240 transactions per 30-day window across ~34 active filers. The whole run takes ~110 seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  House actor
&lt;/h3&gt;

&lt;p&gt;The House serves disclosures very differently — a single ZIP file with everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://disclosures-clerk.house.gov/public_disc/financial-pdfs/2026FD.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside: an XML index plus thousands of individual PTR PDFs. No login, no Akamai, no proxy needed.&lt;/p&gt;

&lt;p&gt;The flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ZIP download → XML index → filter to PTRs (FilingType='P') → fetch each PDF → pdf-parse → regex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The XML index gives you politician + filing date + a &lt;code&gt;DocID&lt;/code&gt;. The PDF lives at &lt;code&gt;https://disclosures-clerk.house.gov/public_disc/ptr-pdfs/2026/&amp;lt;DocID&amp;gt;.pdf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Parsing the PDFs is where it gets interesting.&lt;/p&gt;




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

&lt;p&gt;House PTR PDFs are machine-generated, but the text-extraction order is chaotic. After running them through &lt;code&gt;pdf-parse&lt;/code&gt;, here's what one transaction row looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Amazon.com, Inc. - Common Stock
(AMZN) [ST]
S (partial)03/16/202603/16/2026$1,001 - $15,000
F     S    : New
S         O : Putnam Investments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The asset name is on a separate line from the ticker&lt;/li&gt;
&lt;li&gt;The transaction type, two dates, and amount range all run together with &lt;strong&gt;no whitespace separators&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Header text contains null bytes (&lt;code&gt;F\x00\x00\x00 S\x00\x00\x00&lt;/code&gt;) — a PDF font-glyph hack&lt;/li&gt;
&lt;li&gt;Comment blocks (&lt;code&gt;Filing Status: New&lt;/code&gt;, &lt;code&gt;Subholding Of:&lt;/code&gt;, &lt;code&gt;Description:&lt;/code&gt;) bleed between rows in unpredictable ways&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ended up with a &lt;strong&gt;marker-anchored parser&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Find every (TICKER) [XX] line — these are the row anchors&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MARKER_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(?:\(([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;A-Z0-9.&lt;/span&gt;&lt;span class="se"&gt;\-]{0,5})\)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)?\[([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]{2})\]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Tx-row: type + date + date + amount, all glued together&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TX_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;S&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="sr"&gt;partial&lt;/span&gt;&lt;span class="se"&gt;\)&lt;/span&gt;&lt;span class="sr"&gt;|P|S|E&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(\d{1,2}\/\d{1,2}\/\d{4})\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(\d{1,2}\/\d{1,2}\/\d{4})\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\$([\d&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;*-&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\$([\d&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each marker, walk backward up to 5 lines to collect the asset name, hard-stopping at any:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Other marker (next row above)&lt;/li&gt;
&lt;li&gt;Transaction row (this would be the previous row's data)&lt;/li&gt;
&lt;li&gt;Comment-block keyword (&lt;code&gt;Filing Status:&lt;/code&gt;, &lt;code&gt;Description:&lt;/code&gt;, &lt;code&gt;shares sold @&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Owner-code prefix (&lt;code&gt;SP&lt;/code&gt;/&lt;code&gt;DC&lt;/code&gt;/&lt;code&gt;JT&lt;/code&gt; stuck to the start)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;SP&lt;/code&gt; prefix detection has a fun gotcha: SPDR ETFs would falsely match. Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only strip SP/DC/JT when followed by [A-Z][a-z] (real word boundary)&lt;/span&gt;
&lt;span class="c1"&gt;// "SPDR" → uppercase next, no strip. "SPApple" → strip → "Apple".&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;SP|DC|JT&lt;/span&gt;&lt;span class="se"&gt;)([&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/&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 small post-processor for known truncations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*Common$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assetName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;assetName&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; Stock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Inc&lt;/span&gt;&lt;span class="se"&gt;\.\s&lt;/span&gt;&lt;span class="sr"&gt;+New$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assetName&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;assetName&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; Common Stock&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;End result: ~95% of records parse cleanly. The remaining 5% are scanned PDFs (older filings — the parser logs them and moves on; OCR is Phase 2).&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Both actors share the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fetch → Parse → Normalize → Dedup → Push to Apify Dataset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Normalize:&lt;/strong&gt; maps each source's quirks (Senate's "Purchase"/"Sale (Full)" vs House's &lt;code&gt;P&lt;/code&gt;/&lt;code&gt;S (partial)&lt;/code&gt;) into the unified &lt;code&gt;buy&lt;/code&gt;/&lt;code&gt;sell&lt;/code&gt; enum, parses dollar ranges into integers, ISO-8601s the dates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup:&lt;/strong&gt; hashes &lt;code&gt;politician|transaction_date|asset_name|amount_min|amount_max&lt;/code&gt; to SHA-256. Same key in two runs = same &lt;code&gt;id&lt;/code&gt; = no duplicate dataset entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push:&lt;/strong&gt; &lt;code&gt;Actor.pushData()&lt;/code&gt; straight into the default dataset. Persisted by Apify, queryable via API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both actors are independent Node + TypeScript projects living in their own GitHub repos. Apify links each repo as a separate actor, builds it on push, runs on a schedule.&lt;/p&gt;

&lt;p&gt;Schedule both for every 6 hours. Each run takes 1-3 minutes. Combined daily cost: under $2.&lt;/p&gt;




&lt;h2&gt;
  
  
  Using the data
&lt;/h2&gt;

&lt;p&gt;Run either actor manually or hit the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Trigger Senate run&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/seralifatih~congress-trading-pipeline/runs?token=YOUR_TOKEN"&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;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"fetchDaysBack": 30}'&lt;/span&gt;

&lt;span class="c"&gt;# Pull dataset items&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.apify.com/v2/datasets/&amp;lt;dataset-id&amp;gt;/items?token=YOUR_TOKEN&amp;amp;format=json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or use Apify's Node SDK to consume the dataset directly in your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;ApifyClient&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="s1"&gt;apify-client&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;client&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;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&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;APIFY_TOKEN&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;items&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;senate-dataset-id&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="nf"&gt;listItems&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recentBuys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;buy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both datasets share the schema, so you can &lt;code&gt;.concat()&lt;/code&gt; them and treat the result as one Congress-wide feed.&lt;/p&gt;




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

&lt;p&gt;A few things I'd tell past-me before starting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Read the docs of every layer of HTTP libraries.&lt;/strong&gt; Axios's &lt;code&gt;validateStatus &amp;lt; 400&lt;/code&gt; looks reasonable. Combined with default &lt;code&gt;maxRedirects: 5&lt;/code&gt;, it silently swallows critical Set-Cookie headers from 302s. Took me three diagnostic runs to figure out.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Government sites have weird security postures.&lt;/strong&gt; The Senate's Akamai layer doesn't care about your User-Agent string but cares deeply about which IP block you're coming from. Apify's residential proxy pool sails through; their datacenter pool gets walled.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PDF text extraction is lossy in non-obvious ways.&lt;/strong&gt; &lt;code&gt;pdf-parse&lt;/code&gt; happily returns "text" that's actually a stream of glyph references with embedded null bytes. Always normalize the output with &lt;code&gt;.replace(/\x00/g, '')&lt;/code&gt; before any pattern matching.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dedup early, dedup deterministically.&lt;/strong&gt; A SHA-256 of the natural key is dumber and faster than UUIDs + a uniqueness index. Two runs of the same data produce the same set of records.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Treat the source as adversarial documentation.&lt;/strong&gt; The Senate's terms page wording isn't just legalese — it's the literal CSRF gate. The House's PDF "Filing Status: New" boilerplate isn't human-readable bureaucracy — it's the visual marker that ends one transaction row.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Costs
&lt;/h2&gt;

&lt;p&gt;For both actors combined, running every 6 hours:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Daily cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Senate compute (~110s/run × 4)&lt;/td&gt;
&lt;td&gt;~$0.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Senate residential proxy (~3MB/run × 4)&lt;/td&gt;
&lt;td&gt;~$0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;House compute (~120s/run × 4)&lt;/td&gt;
&lt;td&gt;~$0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;House proxy&lt;/td&gt;
&lt;td&gt;$0 (no proxy needed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.72/day&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;QuiverQuant's congressional plan is $10/month. This is cheaper, gives me raw transactional data instead of pre-aggregated summaries, and runs on infrastructure I control.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OCR fallback&lt;/strong&gt; for the ~5% of House PTRs that are scanned PDFs (older filings)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ticker enrichment&lt;/strong&gt; — resolving asset names to tickers for the bond/muni rows where the source has no ticker column&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge actor&lt;/strong&gt; that consumes both Senate + House datasets and emits a unified Congress-wide stream with cross-source dedup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to use the actors, both are public on Apify Store. Pricing is ~$0.40-0.50 per 1k results — a basic 30-day pull runs about $0.10. Pay-per-result, no subscription.&lt;/p&gt;

&lt;p&gt;Data is public domain. STOCK Act compliant. No third-party vendors. Use it however you want.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>apify</category>
      <category>typescript</category>
      <category>finance</category>
    </item>
    <item>
      <title>I Built a Tool That Finds Mathematical Arbitrage on Polymarket — Here's How It Works</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Wed, 29 Apr 2026 12:17:44 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/-i-built-a-tool-that-finds-mathematical-arbitrage-on-polymarket-heres-how-it-works-4127</link>
      <guid>https://dev.to/fatihbuilds/-i-built-a-tool-that-finds-mathematical-arbitrage-on-polymarket-heres-how-it-works-4127</guid>
      <description>&lt;p&gt;Prediction markets are supposed to be efficient. They're not.&lt;/p&gt;

&lt;p&gt;Here's a trade I found last week: the &lt;strong&gt;Lebanon Parliamentary Election&lt;/strong&gt; had 21 candidate markets on Polymarket. Each one was a binary YES/NO bet on whether that party wins the most seats. Exactly one party will win — so the probabilities must sum to 1.0.&lt;/p&gt;

&lt;p&gt;They summed to &lt;strong&gt;0.789&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means you could buy one share of YES on every single candidate for $0.789 total, and guaranteed receive $1.00 when the election resolves. No forecasting required. No opinion on Lebanese politics needed. Pure arithmetic.&lt;/p&gt;

&lt;p&gt;After fees, that's a &lt;strong&gt;22.7% locked return&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I built a tool that finds these automatically. Here's how.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Math
&lt;/h2&gt;

&lt;p&gt;For any event where exactly one outcome can resolve YES — an election winner, a championship, an award — the following must hold under perfect pricing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Σ P(outcome_i = YES) = 1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the sum falls below 1.0, you buy all YES positions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cost    = Σ YES prices       (e.g. 0.789)
Payout  = $1.00              (guaranteed — exactly one resolves YES)
Gross   = (1.0 - Σ YES) / Σ YES
        = (1.0 - 0.789) / 0.789
        = 26.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subtract fees (Polymarket charges ~2% per fill, so ~4% round-trip):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Net return = 26.7% - 4.0% = 22.7%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also works in reverse. When Σ YES &amp;gt; 1.0, you buy NO on every outcome:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cost    = N - Σ YES          (sum of NO prices)
Payout  = N - 1              (every NO resolves YES except one)
Gross   = (Σ YES - 1.0) / (N - Σ YES)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;Eurovision 2026 Top 5&lt;/strong&gt; is a live example of this: 35 countries, Σ YES = 9.58 when it should equal 5.0. Buying NO on every country pays out 30 countries × $1 = $30, costing $25.42 — a 14% net return.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why These Mispricings Exist
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Market fragmentation.&lt;/strong&gt; Each candidate is a separate binary market. Market makers price each one independently. Small errors compound across 20+ markets and the aggregated sum drifts from 1.0 without anyone noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Thin liquidity.&lt;/strong&gt; Many of these markets have $5K–$50K of liquidity per leg, not millions. Arbitrageurs with serious capital move on, and the mispricing persists for smaller traders.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Unlisted-candidate risk.&lt;/strong&gt; For winner-take-all events, part of the Σ YES shortfall is rational: traders implicitly price in "someone not listed here wins." That's real risk, not pure arb. The tool flags this in the output so you can judge it yourself.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Tool Does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/seralifatih/pm-arbitrage" rel="noopener noreferrer"&gt;Polymarket Multi-Outcome Arbitrage Scanner&lt;/a&gt;&lt;/strong&gt; is an Apify Actor (a cloud-hosted script) that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetches every open Polymarket event via the Gamma API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classifies each event's structure&lt;/strong&gt; — winner-take-all (Σ = 1.0), top-K (Σ = K), or neither&lt;/li&gt;
&lt;li&gt;Filters out events that look like arb but aren't: cumulative "by date" events, price ladders, independent prop bets&lt;/li&gt;
&lt;li&gt;Computes fee-adjusted basket returns for qualifying events&lt;/li&gt;
&lt;li&gt;Walks the CLOB order book for each leg and verifies fillability at your target position size&lt;/li&gt;
&lt;li&gt;Scores each opportunity 0–100 and returns structured JSON&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The classifier step is the most important. Without it, the tool surfaces thousands of false positives — events like "Will Solana reach $110 / $100 / dip to $70 this month?" look like massive arb (Σ YES ≈ 0.05 → 1900% return) but the outcomes are independent, not mutually exclusive. All three can resolve YES.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Classifier
&lt;/h2&gt;

&lt;p&gt;Three structures pass the filter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WINNER_TAKE_ALL&lt;/strong&gt; — exactly one outcome resolves YES. Detected by keywords: &lt;code&gt;nominee&lt;/code&gt;, &lt;code&gt;nomination&lt;/code&gt;, &lt;code&gt;winner&lt;/code&gt;, &lt;code&gt;wins the [year]&lt;/code&gt;, &lt;code&gt;championship&lt;/code&gt;, &lt;code&gt;next president/CEO/Pope&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TOP_K&lt;/strong&gt; — exactly K outcomes resolve YES. Detected by: &lt;code&gt;Top N&lt;/code&gt; in the title, or &lt;code&gt;reach the final&lt;/code&gt; / &lt;code&gt;advance to final&lt;/code&gt; (K=2 implied by a two-slot final).&lt;/p&gt;

&lt;p&gt;Everything else is rejected. The rejection list is explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;by [month]&lt;/code&gt; in labels → cumulative nested structure&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;↑&lt;/code&gt; / &lt;code&gt;↓&lt;/code&gt; arrows in labels → price ladder&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;O/U&lt;/code&gt; in questions → independent prop bets&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;What X will Y&lt;/code&gt; / &lt;code&gt;Which X will&lt;/code&gt; → multi-condition, can co-resolve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conservative by design. It's better to miss a few valid signals than to ship noise dressed as alpha.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sample Output
&lt;/h2&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alaska-governor-election-winner-buy_yes_basket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_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;"Alaska Governor Election Winner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_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://polymarket.com/event/alaska-governor-election-winner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resolution_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_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;"winner_take_all"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expected_sum_yes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arb_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;"buy_yes_basket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"leg_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sum_yes_price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9015&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"deviation_from_one"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-0.0985&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fees_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gross_return_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;10.93&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"net_return_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;6.93&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"legs"&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;"outcome_label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tom Begich"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"side"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YES"&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="mf"&gt;0.375&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"liquidity_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;24661&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fillable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"outcome_label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bernadette Wilson"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"side"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YES"&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="mf"&gt;0.235&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"liquidity_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14765&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fillable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;"liquidity"&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;"tested_usd_per_leg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"all_legs_fillable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"fillable_leg_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"total_leg_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&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;"signal_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;81&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"signal_label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Pure arbitrage"&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;9 candidates, all fillable at $100/leg, 6.9% net return locked in until election day November 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  Technical Stack
&lt;/h2&gt;

&lt;p&gt;The whole thing is ~600 lines of Python 3.11:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;pm-arbitrage/&lt;/span&gt;
&lt;span class="s"&gt;├── src/&lt;/span&gt;
&lt;span class="s"&gt;│   ├── core/&lt;/span&gt;
&lt;span class="s"&gt;│   │   ├── scanner.py&lt;/span&gt;          &lt;span class="c1"&gt;# orchestrates the pipeline&lt;/span&gt;
&lt;span class="s"&gt;│   │   ├── event_classifier.py&lt;/span&gt; &lt;span class="c1"&gt;# rejects non-exclusive structures&lt;/span&gt;
&lt;span class="s"&gt;│   │   ├── scorer.py&lt;/span&gt;           &lt;span class="c1"&gt;# 0-100 signal scoring&lt;/span&gt;
&lt;span class="s"&gt;│   │   └── models.py&lt;/span&gt;           &lt;span class="c1"&gt;# Pydantic v2 output schema&lt;/span&gt;
&lt;span class="s"&gt;│   └── venues/&lt;/span&gt;
&lt;span class="s"&gt;│       └── polymarket.py&lt;/span&gt;       &lt;span class="c1"&gt;# Gamma + CLOB API adapter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key technical decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Async-first, bounded concurrency.&lt;/strong&gt; The CLOB order book probe fans out to all legs simultaneously but through an &lt;code&gt;asyncio.Semaphore(20)&lt;/code&gt; to avoid OOMing the 512MB Apify container. Early version hit OOM at 352 eligible events × 10 legs each = 3500 concurrent HTTP connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conservative classifier, not a blocklist.&lt;/strong&gt; Rejecting bad structures is whitelisting-only — events must positively match a good pattern. Default is NOT_ARB. This means some edge cases get rejected, but the output is clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Szymkiewicz–Simpson overlap for entity matching.&lt;/strong&gt; (In an earlier cross-venue matching phase, since scrapped.) Jaccard penalizes asymmetric sets; S-S handles the case where one side has more "noise" tokens than the other. Relevant if you extend this to cross-venue matching later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Results From a Live Run
&lt;/h2&gt;

&lt;p&gt;From a scan on April 29, 2026 with default settings (1000 events, top-K and WTA only, min $5K event liquidity):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Net Return&lt;/th&gt;
&lt;th&gt;Legs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lebanon Parliamentary Election&lt;/td&gt;
&lt;td&gt;winner_take_all&lt;/td&gt;
&lt;td&gt;22.7%&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guinea-Bissau Assembly Election&lt;/td&gt;
&lt;td&gt;winner_take_all&lt;/td&gt;
&lt;td&gt;11.7%&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alaska Governor Election&lt;/td&gt;
&lt;td&gt;winner_take_all&lt;/td&gt;
&lt;td&gt;6.9%&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MLS Cup Winner 2026&lt;/td&gt;
&lt;td&gt;winner_take_all&lt;/td&gt;
&lt;td&gt;7.8%&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serie A Top 4 Finish&lt;/td&gt;
&lt;td&gt;top_k (K=4)&lt;/td&gt;
&lt;td&gt;81.1%&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EPL Top 4 Finish&lt;/td&gt;
&lt;td&gt;top_k (K=4)&lt;/td&gt;
&lt;td&gt;28.2%&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eurovision 2026 Top 5&lt;/td&gt;
&lt;td&gt;top_k (K=5)&lt;/td&gt;
&lt;td&gt;14.0%&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026 Fields Medal&lt;/td&gt;
&lt;td&gt;winner_take_all&lt;/td&gt;
&lt;td&gt;47.3%&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: the Serie A 81% return reflects the scanner computing against the expected Σ=4 baseline — it's real but assumes the 5 listed teams are the only realistic top-4 candidates. Judge accordingly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limitations and Honest Caveats
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Implicit "Other" probability.&lt;/strong&gt; For winner-take-all events with many candidates, Polymarket may not list every plausible winner. The probability shortfall partially reflects unlisted-candidate risk, not pure arb. Top-K events (EPL Top 4, Eurovision Top 5) are cleaner — the outcome space is rigorously closed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execution risk.&lt;/strong&gt; Prices move between scan and fill. Large baskets with 20+ legs require many simultaneous fills; by the time you've filled legs 1–15, legs 16–20 may have moved. The tool tests fillability at $100/leg which is conservative — if you're sizing at $1000/leg, retest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Liquidity depth.&lt;/strong&gt; The CLOB test is at &lt;code&gt;liquidity_test_amount_usd&lt;/code&gt; (default $100/leg). Higher position sizes will face more slippage. The &lt;code&gt;fillable&lt;/code&gt; flag is binary at the test size, not a continuous depth curve.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is not financial advice.&lt;/strong&gt; It's an arithmetic signal engine. Trade your own book.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The actor is live on Apify:&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://apify.com/seralifatih/pm-arbitrage" rel="noopener noreferrer"&gt;https://apify.com/seralifatih/pm-arbitrage&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run it with these settings to start:&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;"min_net_return_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"min_signal_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"min_event_liquidity_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"liquidity_test_amount_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"max_days_to_resolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"max_events_to_scan"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output_limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&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;&lt;code&gt;min_net_return_pct: 0&lt;/code&gt; filters to pure arbitrage only — positions where you profit even after fees regardless of outcome. &lt;code&gt;min_signal_score: 60&lt;/code&gt; cuts marginal low-liquidity results.&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/seralifatih/pm-arbitrage" rel="noopener noreferrer"&gt;github.com/seralifatih/pm-arbitrage&lt;/a&gt;&lt;/p&gt;




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

&lt;p&gt;The obvious extension is adding more venues. Kalshi has the same multi-outcome structure on US elections and sports. Cross-venue arb (same event, different prices on Polymarket vs Kalshi) is the gold standard but requires a much better title-matching system than raw string similarity — I built one using entity extraction + Szymkiewicz-Simpson and it still struggles on the short, diverse titles both venues use.&lt;/p&gt;

&lt;p&gt;The more tractable extension is &lt;strong&gt;real-time alerting&lt;/strong&gt; — running the scanner on a 5-minute schedule and pushing Telegram/Discord notifications only when new high-score opportunities appear. If you want to build on top of this via the Apify API, all the pieces are there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions? Leave a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>fintech</category>
      <category>apify</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building LoopSignal Part 2: The Public Board, Voting, and Prioritization Without the Overhead</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Thu, 16 Apr 2026 20:33:55 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/building-loopsignal-part-2-the-public-board-voting-and-prioritization-without-the-overhead-53m6</link>
      <guid>https://dev.to/fatihbuilds/building-loopsignal-part-2-the-public-board-voting-and-prioritization-without-the-overhead-53m6</guid>
      <description>&lt;p&gt;In part one, I talked about the very first thing a user does with LoopSignal: submit feedback anonymously, with as little friction as possible.&lt;/p&gt;

&lt;p&gt;A quick update since that post: LoopSignal is now listed on &lt;a href="https://github.com/marketplace/loopsignal" rel="noopener noreferrer"&gt;GitHub Marketplace&lt;/a&gt;. That felt like a good milestone to mention before going deeper into how the product is built.&lt;/p&gt;

&lt;p&gt;But a submission going into a void is not a product. It is a contact form.&lt;/p&gt;

&lt;p&gt;The next piece was building the public board — the place where approved feedback becomes visible, where users vote, where statuses are tracked, and where a team can see what their users actually want.&lt;/p&gt;

&lt;p&gt;This article is about how I built that, and the decisions that shaped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the public board needed to do
&lt;/h2&gt;

&lt;p&gt;Before I wrote any UI, I listed what the board actually had to accomplish.&lt;/p&gt;

&lt;p&gt;It needed to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;show only approved posts, not raw submissions&lt;/li&gt;
&lt;li&gt;let anonymous users vote without creating an account&lt;/li&gt;
&lt;li&gt;let logged-in users have their votes persisted properly&lt;/li&gt;
&lt;li&gt;support sorting by votes and by recency&lt;/li&gt;
&lt;li&gt;support filtering by status and category&lt;/li&gt;
&lt;li&gt;link to a changelog for completed items&lt;/li&gt;
&lt;li&gt;work as both a standalone page and eventually an embeddable widget&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a reasonable surface area for a core feature. Not small, but not bloated either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page is a server component
&lt;/h2&gt;

&lt;p&gt;The public board page at &lt;code&gt;/p/[slug]&lt;/code&gt; is a React Server Component. That was an easy call.&lt;/p&gt;

&lt;p&gt;The page needs data from four places before anything renders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the project itself (name, settings, categories)&lt;/li&gt;
&lt;li&gt;the approved posts, filtered and sorted&lt;/li&gt;
&lt;li&gt;the current user's existing votes (if they are logged in)&lt;/li&gt;
&lt;li&gt;the comments attached to visible posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Doing all of that on the server means zero loading states for the initial render. The page arrives fully hydrated. The only client-side work is the vote button itself.&lt;/p&gt;

&lt;p&gt;I fetch the project and the auth session in parallel, then the posts and existing votes in parallel after that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;userVotes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;postsQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;user&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;votes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;post_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&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;The result is that on a cold load, the page shows the right content — including whether the user has already voted on something — without any client round-trips.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moderation is what makes the board clean
&lt;/h2&gt;

&lt;p&gt;This goes back to a decision made in part one.&lt;/p&gt;

&lt;p&gt;Every submission starts as &lt;code&gt;pending&lt;/code&gt;. A team member reviews it and either approves or rejects it before it appears publicly.&lt;/p&gt;

&lt;p&gt;On the board page, the query is always filtered to approved posts only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;postsQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;typedProject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderation_status&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;approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small thing, but it has a big product effect.&lt;/p&gt;

&lt;p&gt;A public board without moderation becomes a mess within a week. Duplicate posts, nonsense, spam, test submissions from your own team. Moderation keeps the board worth reading, which keeps users worth engaging.&lt;/p&gt;

&lt;p&gt;The moderation queue lives in the dashboard, which I will cover in a later article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voting without an account
&lt;/h2&gt;

&lt;p&gt;This was the hardest design problem in the board.&lt;/p&gt;

&lt;p&gt;On one hand, I wanted voting to work for logged-in users with proper persistence. On the other hand, I did not want anonymous users to be blocked from participating. Voting is how the board surfaces signal. Blocking anonymous votes would severely reduce the data quality.&lt;/p&gt;

&lt;p&gt;The solution I landed on: a &lt;code&gt;localStorage&lt;/code&gt;-based voter key for anonymous users, combined with proper user ID tracking for authenticated ones.&lt;/p&gt;

&lt;p&gt;When the VoteButton mounts, it reads or generates a UUID from &lt;code&gt;localStorage&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getVoterKey&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ls_voter_key&lt;/span&gt;&lt;span class="dl"&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;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ls_voter_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;key&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="nx"&gt;key&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;That key gets sent with every vote action. On the server side, the action checks whether there is an authenticated user. If there is, the vote is recorded against the user ID. If not, the voter key is used instead.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anonymous users can vote, and their votes persist across page reloads on the same browser&lt;/li&gt;
&lt;li&gt;Logged-in users always have their votes properly tracked&lt;/li&gt;
&lt;li&gt;The same post cannot be double-voted from the same source&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not a perfect anti-fraud system. Someone who clears localStorage can vote again. But for the scale LoopSignal is targeting — small product teams, not large open-source projects — it is the right tradeoff. Friction-free participation is worth more than bulletproof deduplication at this stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimistic updates in the vote button
&lt;/h2&gt;

&lt;p&gt;The VoteButton is a client component. When a user clicks it, the count updates immediately before the server confirms anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleVote&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;newVoted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hasVoted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setHasVoted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newVoted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setVoteCount&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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="nx"&gt;newVoted&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

  &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;voteOnPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;voterKey&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setHasVoted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;newVoted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setVoteCount&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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="nx"&gt;newVoted&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&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="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;If the server action fails, the state reverts. This gives the button an instant feel while still being correct.&lt;/p&gt;

&lt;p&gt;I use &lt;code&gt;useTransition&lt;/code&gt; here rather than a loading spinner. The button stays interactive during the transition, which is better for something this small. A spinner on a vote button would feel like overkill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorting and filtering without full-page reloads
&lt;/h2&gt;

&lt;p&gt;The board supports two sort modes — most voted and newest — and two sets of filters: status and category.&lt;/p&gt;

&lt;p&gt;I deliberately chose URL-based filtering rather than client-side state.&lt;/p&gt;

&lt;p&gt;Every filter and sort option is a link, not a button that updates local state. Clicking "Planned" changes the URL to &lt;code&gt;/p/my-project?status=planned&lt;/code&gt;. The server component re-runs with the new params and returns a fresh set of posts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;statusFilter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sortParam&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;categoryFilter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach has a few advantages that felt worth the slightly more complex URL building:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Filtered views are bookmarkable and shareable&lt;/li&gt;
&lt;li&gt;Browser back/forward works naturally&lt;/li&gt;
&lt;li&gt;No hydration mismatches&lt;/li&gt;
&lt;li&gt;No client-side data re-fetching logic to maintain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is that filter changes do cause a full navigation. For this kind of read-heavy, low-interaction page, that is perfectly acceptable. Nobody is rapidly toggling filters on a feedback board.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prioritization without becoming roadmap software
&lt;/h2&gt;

&lt;p&gt;This was a product decision as much as a technical one.&lt;/p&gt;

&lt;p&gt;I deliberately kept prioritization simple: vote counts, visible on the board, sortable, filterable by status. That is it.&lt;/p&gt;

&lt;p&gt;No scoring formulas. No weighted votes. No segment breakdowns. No "effort vs. impact" matrix.&lt;/p&gt;

&lt;p&gt;There are products that do all of that, and they are right for certain teams. But for the kind of user LoopSignal is built for — an indie developer or a small team — that kind of overhead gets in the way rather than helping.&lt;/p&gt;

&lt;p&gt;The premise I kept coming back to: if something has 47 votes and the next item has 12, you do not need a scoring algorithm to tell you what users want.&lt;/p&gt;

&lt;p&gt;The most useful prioritization tool at this scale is just visibility. See what people asked for, see how many people asked for it, see what you have already committed to. That is enough.&lt;/p&gt;

&lt;p&gt;If a team grows to a size where they need weighted scoring and segment filtering, they will probably need a different product. That is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status labels bridge feedback and roadmap
&lt;/h2&gt;

&lt;p&gt;Each post has a &lt;code&gt;workflow_status&lt;/code&gt; that the team controls: open, planned, in progress, completed, closed.&lt;/p&gt;

&lt;p&gt;These are visible on the public board as badges on each card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statusLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;variant&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Open&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;secondary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;planned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Planned&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;in_progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;In Progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Closed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outline&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the minimum viable roadmap view.&lt;/p&gt;

&lt;p&gt;Users can see what is being worked on and what has shipped without the team having to maintain a separate roadmap page. The feedback board is the roadmap, with statuses as the signal.&lt;/p&gt;

&lt;p&gt;That status change is also what drives the changelog — but that is for a later article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Admin comments as public replies
&lt;/h2&gt;

&lt;p&gt;One subtle feature: team members can leave comments on posts, and those comments appear on the public board under the post they belong to.&lt;/p&gt;

&lt;p&gt;It is rendered simply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="nx"&gt;commentsByPost&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="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;comment&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;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex gap-2 text-sm bg-muted/50 rounded-md p-2 mt-1"&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="nc"&gt;MessageSquare&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-3.5 w-3.5 mt-0.5 text-primary shrink-0"&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-foreground"&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;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xs text-muted-foreground mt-0.5"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Admin reply · &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&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;p&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;&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a small thing that has an outsized effect.&lt;/p&gt;

&lt;p&gt;When users see a team member has replied to a request — even just to say "good idea, we will think about it" or "this is planned for next sprint" — it signals that the board is being read by real people.&lt;/p&gt;

&lt;p&gt;Empty feedback boards feel abandoned. Boards with admin replies feel alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned building this piece
&lt;/h2&gt;

&lt;p&gt;A few things became clear while working on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server components are the right default for data-heavy pages.&lt;/strong&gt; The board does a lot of reading — project, posts, votes, comments — and doing all of it on the server gives a much better first-render experience than a loading skeleton and three client fetches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URL-based filtering ages well.&lt;/strong&gt; Client-side filter state always needs special handling: back button, shareability, initial load from URL. URL-based filtering just works for all of those, without the extra logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anonymous voting is worth the complexity.&lt;/strong&gt; The voter key approach adds some nuance, but the alternative — blocking anonymous votes — would have killed participation on boards for products that do not require user accounts. The friction cost is too high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keeping the board simple keeps it useful.&lt;/strong&gt; Every time I thought about adding a feature to the board — trending scores, vote breakdowns by country, satisfaction ratings — I asked whether it would make the core use case better or just noisier. Most ideas got cut. The ones that stayed are the ones in this article.&lt;/p&gt;

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

&lt;p&gt;In the next article, I will go into the GitHub integration: how a team can link an approved feedback post to a GitHub issue with one click, and how issue events automatically close the feedback post and notify users when the issue is resolved.&lt;/p&gt;

&lt;p&gt;That is the "loop" in LoopSignal — and it is the part of the product I am most happy with.&lt;/p&gt;

&lt;p&gt;If you are building something similar, my takeaway from this part is simple:&lt;/p&gt;

&lt;p&gt;Start with the board that shows what users want.&lt;/p&gt;

&lt;p&gt;The prioritization and roadmap features only matter after you have real signal coming in.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>saas</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>How I Built Instagram Intelligence Suite for IG Growth</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Wed, 15 Apr 2026 19:02:01 +0000</pubDate>
      <link>https://dev.to/fatihbuilds/how-i-built-instagram-intelligence-suite-for-ig-growth-3348</link>
      <guid>https://dev.to/fatihbuilds/how-i-built-instagram-intelligence-suite-for-ig-growth-3348</guid>
      <description>&lt;p&gt;Instagram research usually breaks down in the same three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You find profiles, but you still do manual qualification.&lt;/li&gt;
&lt;li&gt;You know a brand or influencer is active, but you cannot track story behavior cleanly.&lt;/li&gt;
&lt;li&gt;You see high-intent comments under posts and reels, but nobody turns them into structured leads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is exactly why I split the workflow into three focused APIs instead of trying to force everything into one oversized scraper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;IGLead&lt;/code&gt; for profile qualification&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IG_story_snapshot&lt;/code&gt; for story activity monitoring&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IG_comment_lead&lt;/code&gt; for comment-to-lead extraction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are built around Apify actors, but the bigger idea is simple: treat Instagram intelligence like a pipeline, not a one-off scrape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I split this into three APIs
&lt;/h2&gt;

&lt;p&gt;A lot of Instagram tools try to do discovery, enrichment, monitoring, and lead scoring in one place. That sounds convenient until the inputs, auth requirements, and output formats start fighting each other.&lt;/p&gt;

&lt;p&gt;I wanted each API to answer one clean question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;IGLead&lt;/code&gt;: Is this profile worth contacting?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IG_story_snapshot&lt;/code&gt;: Is this profile active on stories right now?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IG_comment_lead&lt;/code&gt;: Which commenters look like real demand?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation makes the stack easier to maintain, easier to schedule, and easier to plug into downstream automations.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. IGLead: qualifying influencer and creator profiles before outreach
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IGLead&lt;/code&gt; starts with a list of Instagram usernames or profile URLs and turns them into scored outreach candidates.&lt;/p&gt;

&lt;p&gt;Instead of just scraping follower counts, it combines multiple signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;follower count&lt;/li&gt;
&lt;li&gt;recent post engagement&lt;/li&gt;
&lt;li&gt;engagement rate&lt;/li&gt;
&lt;li&gt;business email detection from public bio text&lt;/li&gt;
&lt;li&gt;niche keyword matching&lt;/li&gt;
&lt;li&gt;verification status&lt;/li&gt;
&lt;li&gt;a final lead score and recommendation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I especially like in this API is that the scoring is not flat. Engagement expectations change depending on account size. A micro creator should not be evaluated like a mega influencer, so the actor adjusts its thresholds by tier.&lt;/p&gt;

&lt;p&gt;It also uses multiple extraction paths for reliability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Instagram web profile API&lt;/li&gt;
&lt;li&gt;feed endpoints when timeline media is incomplete&lt;/li&gt;
&lt;li&gt;HTML parsing fallbacks&lt;/li&gt;
&lt;li&gt;meta tag parsing for follower and post counts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matters because Instagram is rarely stable enough for a single-method scraper.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example &lt;code&gt;IGLead&lt;/code&gt; input
&lt;/h3&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;"profiles"&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="s2"&gt;"therock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cristiano"&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://www.instagram.com/kyliejenner/"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sessionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_SESSION_ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minFollowers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minEngagementRate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requireBusinessEmail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nicheKeywords"&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="s2"&gt;"fitness"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wellness"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxProfilesPerRun"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"proxyConfiguration"&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;"useApifyProxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"apifyProxyGroups"&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="s2"&gt;"RESIDENTIAL"&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;h3&gt;
  
  
  What comes back
&lt;/h3&gt;

&lt;p&gt;For each profile, &lt;code&gt;IGLead&lt;/code&gt; can return:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;normalized profile data&lt;/li&gt;
&lt;li&gt;recent post stats&lt;/li&gt;
&lt;li&gt;average and median engagement metrics&lt;/li&gt;
&lt;li&gt;extracted business email if it is publicly visible in the bio&lt;/li&gt;
&lt;li&gt;niche match score&lt;/li&gt;
&lt;li&gt;&lt;code&gt;leadScore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;recommendation such as &lt;code&gt;contact&lt;/code&gt;, &lt;code&gt;review&lt;/code&gt;, or &lt;code&gt;skip&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For outreach teams, that means you can stop treating every Instagram profile as equal. You can prioritize the ones that actually fit your campaign and have the engagement to justify the spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. IG_story_snapshot: monitoring story activity without owning the account
&lt;/h2&gt;

&lt;p&gt;Stories are one of the hardest parts of Instagram to operationalize because they are temporary, fast-moving, and usually checked manually.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IG_story_snapshot&lt;/code&gt; is built to answer a very specific operational question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Does this public profile have an active story right now, and what does that story set look like?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It tracks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether a profile currently has an active story&lt;/li&gt;
&lt;li&gt;how many story frames are live&lt;/li&gt;
&lt;li&gt;image vs video composition&lt;/li&gt;
&lt;li&gt;oldest and newest story timestamps&lt;/li&gt;
&lt;li&gt;hours since the story sequence started&lt;/li&gt;
&lt;li&gt;hours left until expiry&lt;/li&gt;
&lt;li&gt;optional profile context such as follower count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;competitor monitoring&lt;/li&gt;
&lt;li&gt;campaign verification&lt;/li&gt;
&lt;li&gt;event coverage tracking&lt;/li&gt;
&lt;li&gt;brand activity benchmarking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I like most here is that it avoids trying to do too much. It does not pretend to give story view counts, and it does not download story content. It focuses on presence and metadata, which is the part most teams actually need for monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example &lt;code&gt;IG_story_snapshot&lt;/code&gt; input
&lt;/h3&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;"profiles"&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="s2"&gt;"nike"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"adidas"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@puma"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sessionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_SESSION_ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeProfileContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxProfilesPerRun"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxRequestsPerMinute"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"proxyConfiguration"&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;"useApifyProxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;h3&gt;
  
  
  What comes back
&lt;/h3&gt;

&lt;p&gt;The output is centered around a few operational fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;active_story&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;story_count&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;story_frames&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;story_metadata&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;story_age_hours&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hours_left_until_expiry&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes the API especially useful for scheduled runs. If you execute it every hour, you can build a clean timeline of who is posting stories, how often they post, and whether they lean more toward video or image content.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. IG_comment_lead: turning Instagram comments into lead intelligence
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;IG_comment_lead&lt;/code&gt; is the most directly sales-oriented API in the stack.&lt;/p&gt;

&lt;p&gt;The idea is straightforward: people reveal intent in comments all the time. They ask about price, shipping, details, availability, or how to order. Most teams read those comments manually, if they read them at all.&lt;/p&gt;

&lt;p&gt;This API takes Instagram post or reel URLs, fetches comments, and scores commenters based on lead relevance.&lt;/p&gt;

&lt;p&gt;The pipeline includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;input validation for post and reel URLs&lt;/li&gt;
&lt;li&gt;authenticated scraping with &lt;code&gt;sessionId&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;fallback comment extraction strategies&lt;/li&gt;
&lt;li&gt;keyword-based intent scoring&lt;/li&gt;
&lt;li&gt;lightweight sentiment scoring&lt;/li&gt;
&lt;li&gt;spam checks&lt;/li&gt;
&lt;li&gt;deduplication by username&lt;/li&gt;
&lt;li&gt;early stopping once the target lead count is reached&lt;/li&gt;
&lt;li&gt;a final analytics summary for the whole run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also like that this API is optimized for cost control. You can cap comments per post, define a minimum lead score, and stop the run as soon as enough leads are found.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example &lt;code&gt;IG_comment_lead&lt;/code&gt; input
&lt;/h3&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;"postUrls"&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="s2"&gt;"https://www.instagram.com/p/C3xYz1234Ab/"&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://www.instagram.com/reel/C3xYz5678Cd/"&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;"sessionId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR_SESSION_ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cookie"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sessionid=...; csrftoken=...; ds_user_id=...;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxCommentsPerPost"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targetLeads"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minLeadScore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"debugComments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;h3&gt;
  
  
  What makes this one interesting
&lt;/h3&gt;

&lt;p&gt;The comment fetch flow does not rely on a single endpoint. It tries multiple strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GraphQL queries&lt;/li&gt;
&lt;li&gt;shortcode-based REST endpoints&lt;/li&gt;
&lt;li&gt;mobile-style REST endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives it a better chance of surviving endpoint instability.&lt;/p&gt;

&lt;p&gt;On top of extraction, it enriches leads with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;buyer_intent_score&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;engagement_score&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;likely customer flag&lt;/li&gt;
&lt;li&gt;extracted keywords&lt;/li&gt;
&lt;li&gt;inferred niche&lt;/li&gt;
&lt;li&gt;inferred geography&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the end of a run, it also pushes an analytics summary with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;total comments processed&lt;/li&gt;
&lt;li&gt;total leads found&lt;/li&gt;
&lt;li&gt;lead rate&lt;/li&gt;
&lt;li&gt;top commenters&lt;/li&gt;
&lt;li&gt;intent distribution&lt;/li&gt;
&lt;li&gt;sentiment distribution&lt;/li&gt;
&lt;li&gt;top keywords&lt;/li&gt;
&lt;li&gt;per-post breakdown&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means the output is useful for both direct lead capture and campaign analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the three APIs work together
&lt;/h2&gt;

&lt;p&gt;The fun part is not each API in isolation. It is the workflow they create together.&lt;/p&gt;

&lt;p&gt;A practical sequence looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;IGLead&lt;/code&gt; to qualify creators, influencers, or niche accounts before outreach.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;IG_story_snapshot&lt;/code&gt; to monitor who is actively posting stories right now.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;IG_comment_lead&lt;/code&gt; on posts and reels in your niche to surface warm demand from commenters.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives you three different layers of Instagram intelligence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;profile quality&lt;/li&gt;
&lt;li&gt;current activity&lt;/li&gt;
&lt;li&gt;audience intent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, you can answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who should I contact?&lt;/li&gt;
&lt;li&gt;Who is active right now?&lt;/li&gt;
&lt;li&gt;Who is already asking buying questions?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Implementation notes
&lt;/h2&gt;

&lt;p&gt;All three projects are built around the same practical philosophy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use Apify actors for deployment and scheduling&lt;/li&gt;
&lt;li&gt;use Crawlee for request orchestration&lt;/li&gt;
&lt;li&gt;use Playwright when browser context is needed&lt;/li&gt;
&lt;li&gt;keep concurrency controlled to reduce blocks&lt;/li&gt;
&lt;li&gt;use fallback strategies instead of trusting one endpoint&lt;/li&gt;
&lt;li&gt;support session cookies when Instagram requires authentication&lt;/li&gt;
&lt;li&gt;preserve debug artifacts when extraction fails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;code&gt;IGLead&lt;/code&gt;, that means debug HTML and screenshots when profile parsing breaks.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;IG_story_snapshot&lt;/code&gt;, that means API-first story detection with a visual fallback for story presence.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;IG_comment_lead&lt;/code&gt;, that means endpoint fallback plus a summary record at the end so a run is not just raw data, but something closer to decision-ready output.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would improve next
&lt;/h2&gt;

&lt;p&gt;If I keep iterating on this stack, these are the next areas I would push:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cross-actor orchestration so leads can move automatically from one API to the next&lt;/li&gt;
&lt;li&gt;historical storage for story activity trends over weeks instead of single snapshots&lt;/li&gt;
&lt;li&gt;richer commenter enrichment for repeat engagement across multiple posts&lt;/li&gt;
&lt;li&gt;better dashboarding on top of the analytics summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core scraping part is useful, but the real leverage comes from building a repeatable operating system around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from building &lt;code&gt;IGLead&lt;/code&gt;, &lt;code&gt;IG_story_snapshot&lt;/code&gt;, and &lt;code&gt;IG_comment_lead&lt;/code&gt; is that Instagram automation becomes much more useful when you stop thinking in terms of "scrape a page" and start thinking in terms of "answer a business question."&lt;/p&gt;

&lt;p&gt;Each API here is narrow on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;IGLead&lt;/code&gt; answers qualification&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IG_story_snapshot&lt;/code&gt; answers activity&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IG_comment_lead&lt;/code&gt; answers intent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put together, they form a lightweight Instagram intelligence stack for outreach, competitor research, and lead generation.&lt;/p&gt;

&lt;p&gt;If you are building in this space, I would strongly recommend resisting the urge to turn everything into one monolith. Small, composable APIs are easier to trust, easier to debug, and much easier to turn into real workflows.&lt;/p&gt;

&lt;p&gt;You can find all of my APIs here: &lt;a href="https://apify.com/store/categories?search=seralifatih" rel="noopener noreferrer"&gt;https://apify.com/store/categories?search=seralifatih&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>apify</category>
      <category>automation</category>
      <category>socialmedia</category>
    </item>
  </channel>
</rss>
