<?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 (@seralifatih).</description>
    <link>https://dev.to/seralifatih</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3861452%2F58861428-4878-4a48-a2a4-447bd51fe1ec.jpg</url>
      <title>DEV Community: Fatih İlhan</title>
      <link>https://dev.to/seralifatih</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/seralifatih"/>
    <language>en</language>
    <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/seralifatih/building-loopsignal-part-2-the-public-board-voting-and-prioritization-without-the-overhead-53m6</link>
      <guid>https://dev.to/seralifatih/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/seralifatih/how-i-built-instagram-intelligence-suite-for-ig-growth-3348</link>
      <guid>https://dev.to/seralifatih/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>
    <item>
      <title>Building LoopSignal Part 1: Anonymous Feedback Submission in Next.js 15</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Sat, 11 Apr 2026 18:06:57 +0000</pubDate>
      <link>https://dev.to/seralifatih/building-loopsignal-part-1-anonymous-feedback-submission-in-nextjs-15-5g6f</link>
      <guid>https://dev.to/seralifatih/building-loopsignal-part-1-anonymous-feedback-submission-in-nextjs-15-5g6f</guid>
      <description>&lt;p&gt;When I started building LoopSignal, I did not begin with GitHub sync, changelogs, or billing.&lt;/p&gt;

&lt;p&gt;I started with the very first thing a user would do: submit feedback.&lt;/p&gt;

&lt;p&gt;That sounds small, but for a product like LoopSignal, it is the foundation for everything else. If submitting feedback feels annoying, too formal, or too slow, the rest of the product does not matter. No votes, no roadmap, no changelog, no loop to close.&lt;/p&gt;

&lt;p&gt;So this first article in the series is about the first core problem I wanted to solve:&lt;/p&gt;

&lt;p&gt;How do you let users submit useful product feedback with as little friction as possible, without opening the door to spam and chaos?&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with most feedback forms
&lt;/h2&gt;

&lt;p&gt;A lot of feedback tools make the same mistake early.&lt;/p&gt;

&lt;p&gt;They treat feedback submission like a mini onboarding flow.&lt;/p&gt;

&lt;p&gt;Create an account.&lt;br&gt;&lt;br&gt;
Verify your email.&lt;br&gt;&lt;br&gt;
Fill out a long form.&lt;br&gt;&lt;br&gt;
Pick a category.&lt;br&gt;&lt;br&gt;
Pick a board.&lt;br&gt;&lt;br&gt;
Confirm your submission.&lt;/p&gt;

&lt;p&gt;At that point, most users are already gone.&lt;/p&gt;

&lt;p&gt;That might work if your users are deeply invested power users, but for most SaaS products, feedback happens in small moments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;someone hits a rough edge&lt;/li&gt;
&lt;li&gt;someone wants one missing feature&lt;/li&gt;
&lt;li&gt;someone notices a bug&lt;/li&gt;
&lt;li&gt;someone has an idea while using the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you add too much friction in that moment, you lose the feedback entirely.&lt;/p&gt;

&lt;p&gt;For LoopSignal, I wanted the opposite approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no account required&lt;/li&gt;
&lt;li&gt;optional email for updates&lt;/li&gt;
&lt;li&gt;short, low-friction form&lt;/li&gt;
&lt;li&gt;moderation before public visibility&lt;/li&gt;
&lt;li&gt;enough checks to prevent obvious abuse&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That became the starting point.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the submission flow needed to do
&lt;/h2&gt;

&lt;p&gt;Before I wrote any UI, I made a short list of requirements.&lt;/p&gt;

&lt;p&gt;A submission should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;work from a public board&lt;/li&gt;
&lt;li&gt;work from an embedded widget&lt;/li&gt;
&lt;li&gt;allow anonymous users&lt;/li&gt;
&lt;li&gt;optionally collect an email for status updates&lt;/li&gt;
&lt;li&gt;validate title and description&lt;/li&gt;
&lt;li&gt;rate-limit abusive traffic&lt;/li&gt;
&lt;li&gt;detect obvious spam&lt;/li&gt;
&lt;li&gt;store the post in a pending state for moderation&lt;/li&gt;
&lt;li&gt;subscribe the submitter for future notifications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not a huge system, but it is enough to turn a simple form into a real product feature.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why anonymous submission mattered
&lt;/h2&gt;

&lt;p&gt;This was one of the earliest product decisions I felt strongly about.&lt;/p&gt;

&lt;p&gt;If LoopSignal is meant for indie developers and small teams, it cannot assume their end users want to create accounts just to suggest something.&lt;/p&gt;

&lt;p&gt;A user should be able to open a public board, type:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Would love dark mode scheduling"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and hit submit.&lt;/p&gt;

&lt;p&gt;That is it.&lt;/p&gt;

&lt;p&gt;If they want updates, they can leave an email. If they do not, the feedback should still count.&lt;/p&gt;

&lt;p&gt;That one decision shapes a lot of the implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you cannot rely on user auth for identity&lt;/li&gt;
&lt;li&gt;you need basic anti-spam protections&lt;/li&gt;
&lt;li&gt;you need moderation&lt;/li&gt;
&lt;li&gt;you need a clean public workflow for anonymous posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But I still think it is the right tradeoff. More friction might make the system cleaner internally, but it would also make the product worse at collecting signal.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;At the database level, the first important table was &lt;code&gt;posts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I needed each feedback item to track both moderation state and product workflow state, because those are different things.&lt;/p&gt;

&lt;p&gt;A post might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pending moderation&lt;/li&gt;
&lt;li&gt;approved and open&lt;/li&gt;
&lt;li&gt;planned&lt;/li&gt;
&lt;li&gt;in progress&lt;/li&gt;
&lt;li&gt;completed&lt;/li&gt;
&lt;li&gt;closed&lt;/li&gt;
&lt;li&gt;rejected before ever becoming public&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That led to fields like these:&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;type&lt;/span&gt; &lt;span class="nx"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;id&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;project_id&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;title&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;description&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;category&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;email&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ip&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="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;spam_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;moderation_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&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;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;workflow_status&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="o"&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="o"&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="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="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="nl"&gt;created_at&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is that moderation and workflow are separate.&lt;/p&gt;

&lt;p&gt;That keeps the public board clean and makes the later product flow much easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I used a server action for submission
&lt;/h2&gt;

&lt;p&gt;LoopSignal is built with Next.js 15, and for this kind of mutation, server actions felt like a good fit.&lt;/p&gt;

&lt;p&gt;I did not need a big REST surface just to support one form. I needed a secure write path with access to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;request metadata&lt;/li&gt;
&lt;li&gt;auth context if available&lt;/li&gt;
&lt;li&gt;headers and IP information&lt;/li&gt;
&lt;li&gt;database access&lt;/li&gt;
&lt;li&gt;cache revalidation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly the kind of job server actions are good at.&lt;/p&gt;

&lt;p&gt;The flow looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the incoming form data&lt;/li&gt;
&lt;li&gt;Read the client IP&lt;/li&gt;
&lt;li&gt;Check rate limits&lt;/li&gt;
&lt;li&gt;Run spam scoring&lt;/li&gt;
&lt;li&gt;Insert the post as &lt;code&gt;pending&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Subscribe the email if one exists&lt;/li&gt;
&lt;li&gt;Return a simple success or error response&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The core of it looked like this:&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;spam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkSpam&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;description&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="nx"&gt;ip&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;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSpam&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;logSpam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spam_detected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;description&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="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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your submission was flagged.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newPost&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;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;project_id&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;description&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="na"&gt;email&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="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="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;spam_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;moderation_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&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="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="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;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing especially fancy here, and that is part of the point.&lt;/p&gt;

&lt;p&gt;A lot of good product code is just careful code, not clever code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moderation was not optional
&lt;/h2&gt;

&lt;p&gt;The moment you allow anonymous submissions, moderation stops being a nice-to-have.&lt;/p&gt;

&lt;p&gt;Even if your product is small, public forms eventually attract junk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spam links&lt;/li&gt;
&lt;li&gt;repeated nonsense&lt;/li&gt;
&lt;li&gt;promotional messages&lt;/li&gt;
&lt;li&gt;low-effort duplicate posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did not want LoopSignal to become one of those "technically open, practically unusable" public boards.&lt;/p&gt;

&lt;p&gt;So every new submission starts as &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That gives teams a lightweight review layer before something appears publicly. It also lets the product stay open to anonymous participation without making the board feel messy.&lt;/p&gt;

&lt;p&gt;This ended up being one of the most important quality-of-life decisions in the whole product.&lt;/p&gt;

&lt;p&gt;Open submission works much better when moderation is built in from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-spam without overengineering it
&lt;/h2&gt;

&lt;p&gt;I did not want to begin with a complex third-party moderation stack.&lt;/p&gt;

&lt;p&gt;Instead, I started with a simple scoring approach.&lt;/p&gt;

&lt;p&gt;Things that add spam weight include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;all-caps titles&lt;/li&gt;
&lt;li&gt;too many links&lt;/li&gt;
&lt;li&gt;repeated characters&lt;/li&gt;
&lt;li&gt;suspicious email patterns&lt;/li&gt;
&lt;li&gt;known spam phrases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is enough to catch a surprising amount of bad input.&lt;/p&gt;

&lt;p&gt;The goal here is not perfect classification. The goal is to block obvious junk while keeping the system fast and lightweight.&lt;/p&gt;

&lt;p&gt;That is a pattern I keep coming back to in product work:&lt;/p&gt;

&lt;p&gt;Start with the simplest version that meaningfully reduces pain.&lt;/p&gt;

&lt;p&gt;If it turns out to be insufficient, then you have real usage data to justify something more advanced.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional email was better than required auth
&lt;/h2&gt;

&lt;p&gt;This was another small decision that had a big product effect.&lt;/p&gt;

&lt;p&gt;I still wanted users to be able to hear back when something changed. That is part of the whole "close the loop" idea behind LoopSignal.&lt;/p&gt;

&lt;p&gt;But I did not want updates to require registration.&lt;/p&gt;

&lt;p&gt;So the form allows an optional email field.&lt;/p&gt;

&lt;p&gt;If a user includes it, they can get notified when the request is approved, planned, completed, or replied to. If they leave it blank, the feedback still gets submitted.&lt;/p&gt;

&lt;p&gt;That gave me a much better balance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;low friction for submitting&lt;/li&gt;
&lt;li&gt;enough contact info for follow-up when users want it&lt;/li&gt;
&lt;li&gt;no forced signup just to leave one idea&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, that feels much more aligned with how users actually behave.&lt;/p&gt;

&lt;h2&gt;
  
  
  One subtle thing this unlocked later
&lt;/h2&gt;

&lt;p&gt;Building submission first clarified the rest of the product.&lt;/p&gt;

&lt;p&gt;Once you have a clean, low-friction way to collect feedback, the next pieces become much more obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;approved posts can appear on the public board&lt;/li&gt;
&lt;li&gt;users can vote on them&lt;/li&gt;
&lt;li&gt;teams can change statuses&lt;/li&gt;
&lt;li&gt;approved requests can create GitHub issues&lt;/li&gt;
&lt;li&gt;completed items can feed the changelog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why I think the submission flow was the real starting point of LoopSignal, even if it is not the flashiest feature.&lt;/p&gt;

&lt;p&gt;It defined the shape of the whole system.&lt;/p&gt;

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

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

&lt;p&gt;First, friction matters more than formality.&lt;/p&gt;

&lt;p&gt;A clean, lightweight submission experience will produce more useful feedback than a "proper" workflow with too many steps.&lt;/p&gt;

&lt;p&gt;Second, anonymous does not have to mean uncontrolled.&lt;/p&gt;

&lt;p&gt;If you combine rate limits, spam scoring, and moderation, you can keep the door open without letting the system turn into noise.&lt;/p&gt;

&lt;p&gt;Third, optional identity is often better than mandatory identity.&lt;/p&gt;

&lt;p&gt;Sometimes the best way to get signal is to make participation easy, then let users opt into deeper engagement.&lt;/p&gt;

&lt;p&gt;And finally, building the smallest meaningful loop is a better starting point than trying to design the entire platform up front.&lt;/p&gt;

&lt;p&gt;In LoopSignal's case, that loop started with one question:&lt;/p&gt;

&lt;p&gt;How do I make it as easy as possible for someone to tell a product team what they want?&lt;/p&gt;

&lt;p&gt;Everything else came after that.&lt;/p&gt;

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

&lt;p&gt;In the next article, I'll go deeper into the public board itself: how approved feedback becomes visible, how voting works, and how I approached prioritization without turning the product into heavy roadmap software.&lt;/p&gt;

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

&lt;p&gt;Do not start by designing the perfect feedback system.&lt;/p&gt;

&lt;p&gt;Start by removing everything that makes feedback harder to send.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>saas</category>
      <category>supabase</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why Every AI Developer Should Try the `caveman` Repo (Even If It Looks Like a Joke)</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Fri, 10 Apr 2026 11:23:25 +0000</pubDate>
      <link>https://dev.to/seralifatih/why-every-ai-developer-should-try-the-caveman-repo-even-if-it-looks-like-a-joke-cpf</link>
      <guid>https://dev.to/seralifatih/why-every-ai-developer-should-try-the-caveman-repo-even-if-it-looks-like-a-joke-cpf</guid>
      <description>&lt;p&gt;If you work with AI coding assistants long enough, you start noticing something annoying.&lt;/p&gt;

&lt;p&gt;Your AI tools are &lt;strong&gt;extremely smart… but extremely verbose&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You ask a simple question and get:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Sure! I’d be happy to help with that. The issue you’re experiencing is likely caused by…”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And suddenly you're paying for &lt;strong&gt;three paragraphs of politeness before the actual answer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That’s where a tiny open-source repo called &lt;strong&gt;&lt;code&gt;caveman&lt;/code&gt;&lt;/strong&gt; comes in.&lt;/p&gt;

&lt;p&gt;And surprisingly, it might be one of the &lt;strong&gt;most practical AI workflow hacks of 2026&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Caveman?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;caveman&lt;/code&gt; is a &lt;strong&gt;Claude Code / AI-agent skill&lt;/strong&gt; created by Julius Brussee that forces an AI to communicate in &lt;strong&gt;ultra-compressed language&lt;/strong&gt; while keeping the technical meaning intact.&lt;/p&gt;

&lt;p&gt;The idea is simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why use many token when few token do trick.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“The reason your React component is re-rendering is likely because you're creating a new object reference each render cycle…”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Caveman produces something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New object ref each render.&lt;br&gt;
Inline object prop = new ref = re-render.&lt;br&gt;
Wrap in useMemo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;75% fewer tokens.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem: AI Is Too Polite
&lt;/h2&gt;

&lt;p&gt;Most LLM responses contain a lot of unnecessary language:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Politeness (“Sure! Happy to help!”)&lt;/li&gt;
&lt;li&gt;Hedging (“It might be worth considering…”)&lt;/li&gt;
&lt;li&gt;Conversational glue&lt;/li&gt;
&lt;li&gt;Articles and filler words&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Individually they don't matter.&lt;/p&gt;

&lt;p&gt;But across &lt;strong&gt;thousands of agent calls&lt;/strong&gt;, they become expensive.&lt;/p&gt;

&lt;p&gt;Token consumption becomes the &lt;strong&gt;hidden tax of AI workflows&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Caveman attacks exactly that.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Articles (&lt;code&gt;a&lt;/code&gt;, &lt;code&gt;an&lt;/code&gt;, &lt;code&gt;the&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Pleasantries&lt;/li&gt;
&lt;li&gt;Filler words&lt;/li&gt;
&lt;li&gt;Hedging language&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And keeps only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Technical terms&lt;/li&gt;
&lt;li&gt;Code blocks&lt;/li&gt;
&lt;li&gt;Actual instructions&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Benefits Do You Actually Get?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Massive Token Savings
&lt;/h3&gt;

&lt;p&gt;Caveman claims &lt;strong&gt;~65–75% reduction in output tokens&lt;/strong&gt; across typical programming tasks.&lt;/p&gt;

&lt;p&gt;Example results from real prompts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Token Reduction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React debugging&lt;/td&gt;
&lt;td&gt;~87%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth middleware bug&lt;/td&gt;
&lt;td&gt;~83%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL race condition&lt;/td&gt;
&lt;td&gt;~81%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;cheaper AI usage&lt;/li&gt;
&lt;li&gt;longer agent sessions&lt;/li&gt;
&lt;li&gt;less context window pressure&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2. Faster Responses
&lt;/h3&gt;

&lt;p&gt;Less output means &lt;strong&gt;less generation time&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you use AI in tools like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code&lt;/li&gt;
&lt;li&gt;Cursor&lt;/li&gt;
&lt;li&gt;Codex&lt;/li&gt;
&lt;li&gt;agent pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;you’ll feel the difference immediately.&lt;/p&gt;

&lt;p&gt;Large outputs slow everything down.&lt;/p&gt;

&lt;p&gt;Short outputs keep the loop tight.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Higher Signal-to-Noise Ratio
&lt;/h3&gt;

&lt;p&gt;Ironically, brevity often improves clarity.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The issue you're experiencing is likely caused by…"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You get:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Bug in auth middleware.&lt;br&gt;
Token expiry check use &amp;lt; not &amp;lt;=.&lt;br&gt;
Fix:&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This style works incredibly well for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;debugging&lt;/li&gt;
&lt;li&gt;code review&lt;/li&gt;
&lt;li&gt;architecture hints&lt;/li&gt;
&lt;li&gt;agent communication&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  4. Surprisingly Easy to Install
&lt;/h3&gt;

&lt;p&gt;One command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add JuliusBrussee/caveman
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then trigger it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/caveman
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And your AI switches modes instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Repo Went Viral
&lt;/h2&gt;

&lt;p&gt;The reason &lt;code&gt;caveman&lt;/code&gt;exploded on Hacker News and GitHub is simple.&lt;/p&gt;

&lt;p&gt;It exposes a painful truth:&lt;/p&gt;

&lt;p&gt;Most AI responses waste tokens.&lt;/p&gt;

&lt;p&gt;Developers already felt it.&lt;br&gt;
Caveman just turned the feeling into a tool.&lt;/p&gt;

&lt;p&gt;The project also shows something interesting:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prompt engineering can sometimes beat architecture changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No new model.&lt;/p&gt;

&lt;p&gt;No compression algorithm.&lt;/p&gt;

&lt;p&gt;Just better constraints on output style.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Important Caveat
&lt;/h2&gt;

&lt;p&gt;Caveman does not reduce thinking tokens.&lt;/p&gt;

&lt;p&gt;It only compresses the visible output.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reasoning cost stays the same&lt;/li&gt;
&lt;li&gt;but response tokens shrink dramatically
For most agent workflows, that's still a big win.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Caveman Is Actually Useful
&lt;/h2&gt;

&lt;p&gt;Best use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI coding assistants&lt;/li&gt;
&lt;li&gt;agent pipelines&lt;/li&gt;
&lt;li&gt;automated code review&lt;/li&gt;
&lt;li&gt;debugging loops&lt;/li&gt;
&lt;li&gt;CI AI tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worst use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tutorials&lt;/li&gt;
&lt;li&gt;documentation&lt;/li&gt;
&lt;li&gt;learning explanations
Basically:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use caveman when you want answers, not essays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;caveman&lt;/code&gt;looks like a joke.&lt;/p&gt;

&lt;p&gt;But it's actually a great example of developer-driven AI tooling.&lt;/p&gt;

&lt;p&gt;Tiny repo.&lt;br&gt;
Simple idea.&lt;br&gt;
Huge practical impact.&lt;/p&gt;

&lt;p&gt;Sometimes the best optimization isn't a new model.&lt;/p&gt;

&lt;p&gt;It's just telling the model:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Speak less.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're experimenting with AI coding workflows, it's definitely worth trying.&lt;/p&gt;

&lt;p&gt;Your token budget might thank you.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I Built a Trading Signal Engine That Reads Congressional Insider Trades — Here's the Architecture</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Thu, 09 Apr 2026 06:59:29 +0000</pubDate>
      <link>https://dev.to/seralifatih/i-built-a-trading-signal-engine-that-reads-congressional-insider-trades-heres-the-architecture-3d2b</link>
      <guid>https://dev.to/seralifatih/i-built-a-trading-signal-engine-that-reads-congressional-insider-trades-heres-the-architecture-3d2b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Congressional members beat the market by 12% on average. I built a system to find out why — in real time.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;There's a dataset hiding in plain sight. Every time a U.S. senator or congressman buys stock, they're legally required to disclose it within 45 days. Every time a corporate CEO buys their own company's shares, that Form 4 hits the SEC within 2 business days.&lt;/p&gt;

&lt;p&gt;Most people scroll past these filings. I built a machine to read all of them, filter out the noise, and surface only the trades worth paying attention to.&lt;/p&gt;

&lt;p&gt;This is the architecture behind &lt;strong&gt;Insider Signal Engine&lt;/strong&gt; — a personal trading signal tool I built in a few weeks using Next.js, Supabase, and Cloudflare Workers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Raw Insider Data Is Mostly Noise
&lt;/h2&gt;

&lt;p&gt;The data is public. The problem is the signal-to-noise ratio.&lt;/p&gt;

&lt;p&gt;In any given week, you might see 400+ congressional trade disclosures. But most of them are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sales (informationless — could be divorce, taxes, anything)&lt;/li&gt;
&lt;li&gt;Filed 38 days after the trade (already priced in)&lt;/li&gt;
&lt;li&gt;Tiny amounts ($1K–$15K, basically rounding errors)&lt;/li&gt;
&lt;li&gt;From members with no relevant committee oversight&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running a filter stack on that data is the whole game.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Next.js 14 App Router&lt;/td&gt;
&lt;td&gt;Dashboard + API routes in one project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Supabase (Postgres)&lt;/td&gt;
&lt;td&gt;RLS-ready for multi-tenant SaaS later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;Cloudflare Pages&lt;/td&gt;
&lt;td&gt;Edge performance, generous free tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron&lt;/td&gt;
&lt;td&gt;Cloudflare Workers (scheduled)&lt;/td&gt;
&lt;td&gt;Runs the ingestion pipeline every 4 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary data&lt;/td&gt;
&lt;td&gt;Quiver Quant API ($10/mo)&lt;/td&gt;
&lt;td&gt;Congressional + insider trades, clean REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secondary data&lt;/td&gt;
&lt;td&gt;Financial Modeling Prep (free tier)&lt;/td&gt;
&lt;td&gt;Earnings calendar, corporate Form 4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ingestion pipeline runs as a Cloudflare Worker on a cron schedule, hits an internal protected API route, and writes to Supabase. The Next.js dashboard reads from Supabase and renders signals sorted by score.&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│         CLOUDFLARE WORKER (every 4h)         │
│                                              │
│  1. Fetch congress trades from Quiver Quant  │
│  2. Fetch corporate insider trades (FMP)     │
│  3. Normalize into unified RawTrade schema   │
│  4. Run 7-filter stack                       │
│  5. Score survivors (0-100)                  │
│  6. Upsert into Supabase signals table       │
└──────────────────────┬──────────────────────┘
                       │
                       ▼
             Supabase (Postgres)
             ├── raw_trades  (append-only log)
             ├── signals     (filtered + scored)
             └── politicians (track record)
                       │
                       ▼
          Next.js Dashboard (App Router)
          /              → Signal feed
          /politicians   → Leaderboard by hit rate
          /ticker/[sym]  → Per-stock activity
          /backtest      → Historical performance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The 7-Filter Stack
&lt;/h2&gt;

&lt;p&gt;This is the core of the engine. Every trade must pass &lt;strong&gt;all 7 filters&lt;/strong&gt; to become a signal. Filters are pure functions — simple to test, easy to tune.&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;type&lt;/span&gt; &lt;span class="nx"&gt;TradeFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RawTrade&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;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Filter 1: Purchases Only
&lt;/h3&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;filterPurchaseOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TradeFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&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;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trade_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;purchase&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;Sales have too many non-informative motivations — taxes, diversification, estate planning. Buys are different. Nobody buys their own stock for the wrong reasons.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Exception: unusually large sales &amp;gt;$500K go to a separate "bearish watchlist" — not implemented yet.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter 2: Filing Delay ≤ 7 Days
&lt;/h3&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;filterFilingDelay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TradeFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;differenceInDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;parseISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filing_date&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;parseISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trade_date&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;delay&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;7&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;Congress has &lt;strong&gt;45 days&lt;/strong&gt; to disclose. Most of them use every day of it. This filter rejects ~80% of congressional trades by design.&lt;/p&gt;

&lt;p&gt;The fast-filers are a self-selecting group. When a senator buys $500K of defense stock and files the next day, that's a different animal from someone who files at day 44.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter 3: Minimum Size ≥ $50K
&lt;/h3&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;filterMinSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TradeFilter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&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;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_high&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_low&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&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;Congress reports in ranges — &lt;code&gt;$1K–$15K&lt;/code&gt;, &lt;code&gt;$15K–$50K&lt;/code&gt;, &lt;code&gt;$100K–$250K&lt;/code&gt;. We use the upper bound. The $50K floor eliminates noise buys and auto-purchase plans.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter 4: Relevance — Committee Match or C-Suite
&lt;/h3&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;COMMITTEE_SECTOR_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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Armed Services&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defense&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;aerospace&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;Energy and Commerce&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;energy&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;utilities&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;healthcare&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;Finance&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;banks&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;fintech&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;crypto&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;Intelligence&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defense&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;cybersecurity&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;tech&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;C_SUITE_TITLES&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="s1"&gt;CEO&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;CFO&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;COO&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;CTO&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;President&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;Chairman&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;A senator on the Senate Intelligence Committee buying a cybersecurity stock is a different signal than a backbencher doing the same. A CEO buying their own stock means something. A Director buying theirs means less.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter 5: Cluster Detection
&lt;/h3&gt;

&lt;p&gt;Not a per-trade filter — a post-filter enrichment. After filters 1-4, we group surviving trades by ticker in a 30-day sliding window:&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;detectClusters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;filteredTrades&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RawTrade&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;windowDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="nx"&gt;ClusterResult&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;byTicker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filteredTrades&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &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;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;byTicker&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="nx"&gt;trades&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// sliding window: find groups of 2+ trades within windowDays&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;When 3 different insiders buy the same ticker in the same month, that's a cluster. Clusters get heavily rewarded in the scoring model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter 6: No Earnings Gamble (Async)
&lt;/h3&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;filterNoEarningsGamble&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RawTrade&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="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;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;earningsDate&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;getNextEarningsDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// FMP API&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;earningsDate&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;true&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;diffDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;differenceInDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;parseISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;earningsDate&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;parseISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trade_date&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;diffDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;diffDays&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&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;Insider buys 3 days before an earnings beat look brilliant in hindsight. They're also binary event gambling. This filter runs only on the survivors from 1-4, minimizing API calls to FMP's free tier (250 req/day limit).&lt;/p&gt;

&lt;h3&gt;
  
  
  Filter 7: Technical Check (Stub — Phase 2)
&lt;/h3&gt;

&lt;p&gt;Placeholder for a 200-day SMA guardrail: reject trades where the stock is more than 20% below its long-term average. Falling knives are falling knives, even with insider buying.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scoring Model (0–100)
&lt;/h2&gt;

&lt;p&gt;Every trade that survives the filters gets a score. The score is a sum of 6 components:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ScoreBreakdown&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;size_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// 0-20&lt;/span&gt;
  &lt;span class="nl"&gt;delay_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 0-15&lt;/span&gt;
  &lt;span class="nl"&gt;cluster_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 0-25  ← most impactful&lt;/span&gt;
  &lt;span class="nl"&gt;filer_track_record&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 0-20&lt;/span&gt;
  &lt;span class="nl"&gt;relevance_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// 0-10&lt;/span&gt;
  &lt;span class="nl"&gt;recency_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 0-10&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thresholds:&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;// SIZE (0-20)&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;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="nx"&gt;size_score&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="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;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;size_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&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;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;size_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&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;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="nx"&gt;size_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// FILING DELAY (0-15)&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;delayDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="nx"&gt;delay_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&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;delayDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;delay_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&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;delayDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;delay_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&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;delayDays&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;delay_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// CLUSTER (0-25) — the big one&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;cluster_strength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="nx"&gt;cluster_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&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;cluster_strength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cluster_score&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="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;cluster_strength&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;cluster_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// TRACK RECORD (0-20) — needs 10+ historical trades to activate&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;hit_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;filer_track_record&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="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;hit_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;filer_track_record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&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;hit_rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;filer_track_record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The score is stored alongside the full breakdown as a JSONB column in Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ticker&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;filer_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;filer_type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;-- 'congress' | 'corporate_insider'&lt;/span&gt;
  &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;score_breakdown&lt;/span&gt; &lt;span class="n"&gt;JSONB&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- { size_score, delay_score, ... }&lt;/span&gt;
  &lt;span class="n"&gt;filters_passed&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cluster_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;filing_delay_days&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="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;This means I can always explain &lt;em&gt;why&lt;/em&gt; a trade scored the way it did — not just the final number.&lt;/p&gt;




&lt;h2&gt;
  
  
  Data Model: Three Core Tables
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;raw_trades&lt;/code&gt;&lt;/strong&gt; — append-only ingest log. Every trade from every source lands here first, before filtering. Has a &lt;code&gt;UNIQUE(source, source_id)&lt;/code&gt; constraint — upsert only, never blind insert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;signals&lt;/code&gt;&lt;/strong&gt; — filtered and scored trades only. References &lt;code&gt;raw_trades&lt;/code&gt; via FK. This is what the dashboard reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;politicians&lt;/code&gt;&lt;/strong&gt; — filer lookup with a computed &lt;code&gt;hit_rate&lt;/code&gt; column (winning trades / total trades × 100, calculated in Postgres):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;hit_rate&lt;/span&gt; &lt;span class="nb"&gt;NUMERIC&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;total_trades&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="k"&gt;THEN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;winning_trades&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;NUMERIC&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_trades&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Ingestion Pipeline
&lt;/h2&gt;

&lt;p&gt;The whole flow lives in a single orchestrator function:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runIngestionPipeline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Fetch&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;congressTrades&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;insiderTrades&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fmpTrades&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;quiverClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchCongressTrades&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;quiverClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchInsiderTrades&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;fmpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchInsiderTrades&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&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;rawTrades&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;congressTrades&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;insiderTrades&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;fmpTrades&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Dedup + store&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertRawTrades&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawTrades&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// UNIQUE constraint handles dedup&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Filter (sync first, async only on survivors)&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;passed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rejected&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="nf"&gt;runFilterStack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawTrades&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Score&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signals&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;passed&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;trade&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;scoreWithContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Store&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsertSignals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 6. Expire old signals&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markStaleSignals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// is_active = false after 30 days&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Pipeline: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rawTrades&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ingested → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; signals`&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 cron endpoint is protected by a secret header — no auth library needed:&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;// /src/app/api/cron/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-cron-secret&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;secret&lt;/span&gt; &lt;span class="o"&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;CRON_SECRET&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;runIngestionPipeline&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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;h2&gt;
  
  
  What Makes This Different From Just Using Quiver
&lt;/h2&gt;

&lt;p&gt;Quiver shows you raw data. Capitol Trades shows you raw data. Unusual Whales shows you raw data — with prettier charts.&lt;/p&gt;

&lt;p&gt;Nobody gives you a &lt;strong&gt;confidence score&lt;/strong&gt;. Nobody tells you "this specific combination of factors — a cluster of 3 insiders, fast filing, large size, from a senator on the Finance Committee — has historically been worth paying attention to."&lt;/p&gt;

&lt;p&gt;The filter stack + scoring model is the IP. The data is commodity.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Phase 1 (done):&lt;/strong&gt; Personal tool. Use it for 4 weeks. Track accuracy manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2:&lt;/strong&gt; Backtest engine — calculate 7/30/90-day returns for every historical signal. This turns the hit rate columns from placeholders into real data. Add Telegram alerts for score ≥ 70.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3:&lt;/strong&gt; Multi-tenant SaaS via Supabase Auth + RLS. Pricing: free tier (3 signals/day, delayed) → Pro at $15/mo (real-time feed, full history, alerts, backtest). Break-even is literally 1 paying user — infrastructure cost at this scale is basically $10/mo for the Quiver API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 4:&lt;/strong&gt; AI-generated trade thesis per signal. Cross-reference with FDA calendar, earnings, legislation schedule. The data is already there — it just needs context.&lt;/p&gt;




&lt;h2&gt;
  
  
  Competitive Moat
&lt;/h2&gt;

&lt;p&gt;The barrier here isn't data access — it's the model. Quiver, Unusual Whales, and Capitol Trades all show you the same filings. The moat is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The scoring model accumulates historical calibration over time (the &lt;code&gt;hit_rate&lt;/code&gt; column)&lt;/li&gt;
&lt;li&gt;Cluster detection catches coordinated buying that raw feeds miss&lt;/li&gt;
&lt;li&gt;The filter stack eliminates noise that makes other tools feel overwhelming&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Congressional trading platforms all have the same problem: too much signal, not enough filtering. Most users end up ignoring them after a few weeks because they don't know which trades to act on. A score from 0-100 solves that UX problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Technical Decisions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why Quiver Quant over scraping?&lt;/strong&gt; Capitol Trades has no API. You could scrape it with Apify, but Quiver gives you normalized data plus corporate insider trades, lobbying data, and WSB sentiment in one REST API. $10/mo vs. scraper maintenance is an easy call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why 7-day filing delay and not 45?&lt;/strong&gt; This deliberately rejects ~80% of congressional trades. The fast-filers are a statistically distinct group. If I'm wrong about this hypothesis, the backtest data will tell me — and I can loosen the filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Cloudflare Workers for cron?&lt;/strong&gt; Free tier covers 100K requests/day and unlimited scheduled workers. No Lambda cold starts. The entire infrastructure cost at personal-use scale is $10/mo (Quiver API only).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why Supabase over plain Postgres?&lt;/strong&gt; Row-Level Security means multi-tenant is a schema migration away, not an architectural rewrite. The free tier covers ~50K signals, which is years of personal use.&lt;/p&gt;




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

&lt;p&gt;Right now this is a personal tool — I use it for my own trading and I'm not ready to open it up yet. I want to run it for a few months, validate the scoring model against real returns, and see if the signals actually hold up before putting it in front of anyone else.&lt;/p&gt;

&lt;p&gt;If the backtest data looks good, this becomes a product. The infrastructure is already designed for it — Supabase RLS for multi-tenancy, Cloudflare Pages for edge delivery, Paddle for payments. The jump from personal tool to SaaS is mostly a pricing page and an auth flow.&lt;/p&gt;

&lt;p&gt;If you're building something similar or have thoughts on the filter logic, I'd love to hear it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Next.js 14, TypeScript strict mode, Supabase, Cloudflare Pages + Workers, and Quiver Quant API. Stack is fully open — the IP is in the filtering logic, not the framework choices.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not financial advice. Congressional disclosure data is public record.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>programming</category>
      <category>automation</category>
    </item>
    <item>
      <title>Building OneRule: A Technical Deep Dive into an Offline Password Manager with Flutter, SQLCipher, and AES-GCM</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Wed, 08 Apr 2026 07:19:31 +0000</pubDate>
      <link>https://dev.to/seralifatih/building-onerule-a-technical-deep-dive-into-an-offline-password-manager-with-flutter-sqlcipher-nkd</link>
      <guid>https://dev.to/seralifatih/building-onerule-a-technical-deep-dive-into-an-offline-password-manager-with-flutter-sqlcipher-nkd</guid>
      <description>&lt;p&gt;Password managers often get discussed at product level: sync, UX, pricing, recovery, browser extensions.&lt;/p&gt;

&lt;p&gt;What interested me more in OneRule was lower in stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what "offline-first" actually means in implementation&lt;/li&gt;
&lt;li&gt;how local key derivation is handled&lt;/li&gt;
&lt;li&gt;where encryption boundaries sit&lt;/li&gt;
&lt;li&gt;how Flutter and native Android divide responsibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OneRule is an open-source Android password manager built with Flutter. It does not require an account or backend for core vault operations. The interesting part is not only that it works offline, but that the architecture is intentionally shaped around that constraint.&lt;/p&gt;

&lt;p&gt;This post is a technical walkthrough of current implementation: storage model, key flow, backup format, panic mode behavior, and Android Autofill boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architectural Shape
&lt;/h2&gt;

&lt;p&gt;At code level, OneRule follows a fairly direct layered structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flutter UI for screens, forms, and interaction flows&lt;/li&gt;
&lt;li&gt;Provider-based state for app-level reactive data&lt;/li&gt;
&lt;li&gt;service and facade classes for storage, crypto, backup, auth, clipboard, and platform integration&lt;/li&gt;
&lt;li&gt;native Android code where platform APIs are necessary, especially Autofill&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practical terms, the flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UI/screens
  -&amp;gt; Provider state
    -&amp;gt; service/facade layer
      -&amp;gt; SQLCipher / secure storage / crypto / platform channels
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not an especially exotic architecture, but it fits security-sensitive mobile software well. UI code stays mostly unaware of encryption details. Providers coordinate visible app state. Services own the mechanics of vault initialization, field encryption, backup creation, and unlock behavior.&lt;/p&gt;

&lt;p&gt;For a Flutter app, that separation matters because it reduces chances that sensitive logic leaks into widget code and becomes harder to reason about.&lt;/p&gt;

&lt;p&gt;The startup path is also explicit about local protections. On launch, the app initializes error handling and reminder services, then enables screen-capture protections before rendering UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;WidgetsFlutterBinding&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ensureInitialized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;LocalLogService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initializeGlobalErrorHandlers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;BackupReminderService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&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="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAndroid&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isIOS&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="n"&gt;ScreenProtector&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;preventScreenshotOn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ScreenProtector&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;protectDataLeakageOn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ScreenProtector&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;protectDataLeakageWithBlur&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;runApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* providers + app */&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 is small, but it shows something important about OneRule: operational security controls are part of app bootstrap, not optional polish added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data-at-Rest Model
&lt;/h2&gt;

&lt;p&gt;The vault is not stored as plaintext records in local SQLite.&lt;/p&gt;

&lt;p&gt;OneRule uses &lt;strong&gt;SQLCipher-backed SQLite&lt;/strong&gt; as the primary vault store. The database file is encrypted at rest, and the SQLCipher password is derived from the active in-memory session key.&lt;/p&gt;

&lt;p&gt;But the design does not stop there.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;password&lt;/code&gt; field is also encrypted again at application level using &lt;strong&gt;AES-256-GCM&lt;/strong&gt;. So the current storage model is effectively:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;encrypted database file via SQLCipher&lt;/li&gt;
&lt;li&gt;encrypted sensitive field payloads inside that database&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second layer is important. It means the most sensitive credential field is stored and serialized as an authenticated encrypted envelope, not only as a row protected by database-level encryption. When the Flutter app needs to render or analyze entries, it decrypts that field at read time.&lt;/p&gt;

&lt;p&gt;The current field payload format is versioned and structured like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;or1:v2:gcm:&amp;lt;nonce_b64url&amp;gt;:&amp;lt;cipherText_b64url&amp;gt;:&amp;lt;tag_b64url&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using a versioned envelope instead of opaque bytes makes future migrations much easier. It also gives the app a clean way to distinguish between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current AES-GCM records&lt;/li&gt;
&lt;li&gt;legacy AES-CBC records&lt;/li&gt;
&lt;li&gt;pre-envelope data from older versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matters because OneRule includes migration logic and does not assume the vault will always start in latest format.&lt;/p&gt;

&lt;p&gt;For backups, the code takes the same approach: explicit envelope, explicit metadata, explicit version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Derivation and Session Model
&lt;/h2&gt;

&lt;p&gt;The master PIN is not used directly as an encryption key.&lt;/p&gt;

&lt;p&gt;Instead, OneRule derives key material with &lt;strong&gt;PBKDF2-HMAC-SHA256&lt;/strong&gt;. Current implementation uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100,000 iterations for interactive PIN unlock/session derivation&lt;/li&gt;
&lt;li&gt;256-bit output&lt;/li&gt;
&lt;li&gt;16-byte random salts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Backup encryption uses a separate derivation profile:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PBKDF2-HMAC-SHA256&lt;/li&gt;
&lt;li&gt;200,000 iterations&lt;/li&gt;
&lt;li&gt;256-bit output&lt;/li&gt;
&lt;li&gt;16-byte random salt embedded in backup payload&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation is a good design choice. Unlock flows need acceptable latency on-device. Backup export and import can afford a higher work factor because they are less frequent and less latency-sensitive.&lt;/p&gt;

&lt;p&gt;The session model is also worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the active vault session key is kept in memory&lt;/li&gt;
&lt;li&gt;SQLCipher password is derived from that key&lt;/li&gt;
&lt;li&gt;secure storage holds salts, verifiers, and biometric-restorable material&lt;/li&gt;
&lt;li&gt;session key is cleared on logout and lock paths&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the app has a fairly explicit distinction between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;persistent verification or recovery metadata&lt;/li&gt;
&lt;li&gt;active decrypted session state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is small but important. A lot of local-first security design is really state-lifecycle design.&lt;/p&gt;

&lt;p&gt;Backup derivation path is straightforward and readable in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SecretKey&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_deriveKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;passphrase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;pbkdf2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Pbkdf2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;macAlgorithm:&lt;/span&gt; &lt;span class="n"&gt;Hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="nl"&gt;iterations:&lt;/span&gt; &lt;span class="n"&gt;_pbkdf2Iterations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;bits:&lt;/span&gt; &lt;span class="mi"&gt;256&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="n"&gt;pbkdf2&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;deriveKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nl"&gt;secretKey:&lt;/span&gt; &lt;span class="n"&gt;SecretKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passphrase&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="nl"&gt;nonce:&lt;/span&gt; &lt;span class="n"&gt;salt&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;Then the payload is encrypted with AES-GCM using fresh salt and nonce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_deriveKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passphrase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;secretBox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_gcm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;utf8&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plaintext&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;secretKey:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;nonce:&lt;/span&gt; &lt;span class="n"&gt;nonce&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;h2&gt;
  
  
  Why SQLCipher Plus Field-Level AES-GCM?
&lt;/h2&gt;

&lt;p&gt;A reasonable question is: if SQLCipher already encrypts database file at rest, why encrypt the password field again?&lt;/p&gt;

&lt;p&gt;The answer is separation of concerns.&lt;/p&gt;

&lt;p&gt;SQLCipher protects the database as a container. Field-level AES-GCM protects the most sensitive payloads as application-managed cryptographic objects with explicit integrity tags and versioned envelopes.&lt;/p&gt;

&lt;p&gt;That gives OneRule a few advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;clean migration path from old formats to new ones&lt;/li&gt;
&lt;li&gt;explicit tamper detection at field level&lt;/li&gt;
&lt;li&gt;easier reuse of encrypted credential payloads outside normal Flutter list rendering&lt;/li&gt;
&lt;li&gt;tighter control over what can be serialized or handed to native layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Android Autofill integration benefits directly from this. More on that later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vault Migration Strategy
&lt;/h2&gt;

&lt;p&gt;OneRule still contains migration support for older storage models.&lt;/p&gt;

&lt;p&gt;There are two migration stories in current codebase:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Legacy vault backend migration
&lt;/h3&gt;

&lt;p&gt;Older vault data could exist in Hive-based storage. Current architecture uses SQLCipher, and the app includes a one-time migration reader that imports legacy records into encrypted SQLite, then removes old box data and marks migration complete.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Legacy cipher format migration
&lt;/h3&gt;

&lt;p&gt;Password payloads and backup payloads have moved from older layouts into AES-GCM-based envelopes.&lt;/p&gt;

&lt;p&gt;For vault records, the app can still read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;legacy CBC envelope format&lt;/li&gt;
&lt;li&gt;older marker-based GCM payloads&lt;/li&gt;
&lt;li&gt;older plaintext-style legacy values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On unlock, rows are scanned and migrated into current &lt;code&gt;or1:v2:gcm&lt;/code&gt; format. Migration is designed to be non-lossy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;read legacy payload&lt;/li&gt;
&lt;li&gt;decrypt or parse&lt;/li&gt;
&lt;li&gt;re-encrypt in latest format&lt;/li&gt;
&lt;li&gt;persist migrated value&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If crypto validation fails, migration hard-fails rather than overwriting questionable data.&lt;/p&gt;

&lt;p&gt;That is exactly what you want in security migration code. Silent best-effort conversion is dangerous when integrity matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup Format and Cryptographic Container
&lt;/h2&gt;

&lt;p&gt;Offline-first apps still need a portability story. Otherwise "no sync" quickly turns into "single-device risk."&lt;/p&gt;

&lt;p&gt;OneRule handles that with encrypted backup export and import. Current export format is a JSON-based &lt;code&gt;.enc&lt;/code&gt; container with schema version &lt;code&gt;v: 3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The backup pipeline looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;collect vault records&lt;/li&gt;
&lt;li&gt;serialize plaintext JSON&lt;/li&gt;
&lt;li&gt;derive encryption key from backup passphrase using PBKDF2-HMAC-SHA256&lt;/li&gt;
&lt;li&gt;encrypt with AES-256-GCM using random nonce&lt;/li&gt;
&lt;li&gt;emit structured JSON container with metadata&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;KDF metadata&lt;/li&gt;
&lt;li&gt;cipher metadata&lt;/li&gt;
&lt;li&gt;salt&lt;/li&gt;
&lt;li&gt;authenticated encryption envelope&lt;/li&gt;
&lt;li&gt;creation timestamp&lt;/li&gt;
&lt;li&gt;item count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a much better design than "just encrypt some bytes and hope future versions remember how." Explicit metadata makes forward migration, compatibility handling, and debugging safer.&lt;/p&gt;

&lt;p&gt;Current backup writer emits a structured object like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s"&gt;'v'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_backupSchemaVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;'envelope'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;'format'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'onerule-backup'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'version'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'v3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'algorithm'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_gcmAlgorithmName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'nonce'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;'cipherText'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cipherText&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s"&gt;'tag'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secretBox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="s"&gt;'salt'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="s"&gt;'kdf'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;'algorithm'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'PBKDF2-HMAC-SHA256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'iterations'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_pbkdf2Iterations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;'keyBits'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&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;That snippet captures design philosophy well: crypto parameters travel with container, not only with developer memory.&lt;/p&gt;

&lt;p&gt;Import path is also backward-compatible with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current &lt;code&gt;.enc&lt;/code&gt; encrypted backups&lt;/li&gt;
&lt;li&gt;legacy &lt;code&gt;.onerule&lt;/code&gt; extension&lt;/li&gt;
&lt;li&gt;previous schema &lt;code&gt;v: 2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;previous schema &lt;code&gt;v: 1&lt;/code&gt;, including legacy CBC layout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Again, the pattern here is versioned crypto container plus explicit readers, not hidden magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Panic PIN and Privacy Flow
&lt;/h2&gt;

&lt;p&gt;Panic mode is one of the more interesting product decisions because it crosses UX, threat modeling, and implementation details.&lt;/p&gt;

&lt;p&gt;OneRule supports a separate Panic PIN verifier. When this flow is used, the app avoids exposing the real vault and enters a privacy-focused decoy mode.&lt;/p&gt;

&lt;p&gt;There are two implementation details worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;provider state sets panic mode and suppresses real vault data&lt;/li&gt;
&lt;li&gt;home screen renders decoy credential entries for that mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means panic mode is not implemented as "delete everything" or "rename list to empty." It is an explicit alternate UI and state flow.&lt;/p&gt;

&lt;p&gt;This is a subtle but meaningful distinction. Security-sensitive UX should not depend on the user remembering whether a blank screen is real, broken, or intentional.&lt;/p&gt;

&lt;p&gt;The app also warns users that panic mode is a decoy flow and can be confusing if triggered accidentally. That kind of warning is good product engineering. Safety features that are easy to misunderstand become liabilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Android Autofill Boundary
&lt;/h2&gt;

&lt;p&gt;Password managers live or die on ergonomics, and Android Autofill is one place where architecture decisions show up clearly.&lt;/p&gt;

&lt;p&gt;OneRule uses a native Android &lt;code&gt;AutofillService&lt;/code&gt; for API 26+ and bridges it from Flutter over a platform channel. The relevant boundary is important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Flutter owns normal vault UX and decrypted entry access after unlock&lt;/li&gt;
&lt;li&gt;Flutter syncs an &lt;strong&gt;encrypted credential snapshot&lt;/strong&gt; to native storage&lt;/li&gt;
&lt;li&gt;Flutter also provides a session key to native layer for active unlock window&lt;/li&gt;
&lt;li&gt;native Autofill service performs decryption only when it needs to fill a request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means Autofill is not backed by a permanently stored plaintext cache. Instead, encrypted envelopes are synced to native side, and usable decryption depends on active session state.&lt;/p&gt;

&lt;p&gt;The platform methods reflect that design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;syncAutofillCredentialSnapshot&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setAutofillSessionKey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;clearAutofillSessionKey&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If session key is cleared on lock/logout, Autofill stops returning datasets.&lt;/p&gt;

&lt;p&gt;Flutter side bridge is intentionally thin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;syncCredentialSnapshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sessionKeyBase64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;availability&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="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;AutofillMvpAvailability&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;available&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_bridge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSessionKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionKeyBase64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_bridge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;syncCredentialSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&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 sequence matters. Native side receives encrypted snapshot plus active session key only when feature is available and vault is in usable state.&lt;/p&gt;

&lt;p&gt;This is a strong boundary design because it minimizes plaintext residency and ties Autofill usefulness to vault unlock state rather than treating Autofill as a separate always-on data channel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locking, Clipboard, and Leakage Controls
&lt;/h2&gt;

&lt;p&gt;Security work is often ruined by small post-decrypt leaks rather than broken cryptography.&lt;/p&gt;

&lt;p&gt;OneRule includes several controls in this category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;screenshot prevention and screen-capture leakage protection on mobile&lt;/li&gt;
&lt;li&gt;auto-lock timeout based on app lifecycle transitions&lt;/li&gt;
&lt;li&gt;clipboard auto-clear after configurable delay&lt;/li&gt;
&lt;li&gt;optional application of clipboard policy to usernames as well&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lifecycle lock path is especially relevant in Flutter apps, where it is easy to focus on route state and forget process state. OneRule tracks pause/resume transitions and clears session state when idle time exceeds configured threshold.&lt;/p&gt;

&lt;p&gt;The relevant branch in lifecycle observer is simple and effective:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;inSeconds&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;timeoutSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;clearSessionKey&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PlatformCredentialProvider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;onVaultLocked&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="n"&gt;unawaited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PasswordProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lockForSession&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="n"&gt;Navigator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pushAndRemoveUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;MaterialPageRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;builder:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;LoginScreen&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is exactly kind of code I like seeing in security-sensitive mobile apps: short, direct, and tied to explicit state invalidation.&lt;/p&gt;

&lt;p&gt;That is not glamorous, but it is real security work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vault Health Analysis
&lt;/h2&gt;

&lt;p&gt;OneRule also includes a local "Vault Health" analyzer. This is not cryptography, but it is useful security-adjacent logic.&lt;/p&gt;

&lt;p&gt;The current report model scores the vault from 0 to 100 based on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;weak passwords&lt;/li&gt;
&lt;li&gt;exact-match duplicate passwords&lt;/li&gt;
&lt;li&gt;stale credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Duplicate detection uses local hash-based comparison logic rather than remote checks, which keeps analysis offline and aligned with rest of system design.&lt;/p&gt;

&lt;p&gt;This is a good example of how offline-first does not need to mean feature-light. You can still deliver useful security insights locally if the data model and processing path are designed for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Flutter Works Here
&lt;/h2&gt;

&lt;p&gt;Security software is often assumed to require fully native implementation, but OneRule is a good example of when Flutter is enough for most of stack and native code is reserved for true platform edges.&lt;/p&gt;

&lt;p&gt;Flutter handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;application UI&lt;/li&gt;
&lt;li&gt;state management&lt;/li&gt;
&lt;li&gt;settings and backup flows&lt;/li&gt;
&lt;li&gt;vault rendering and search&lt;/li&gt;
&lt;li&gt;generator and health screens&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Native Android handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Autofill service&lt;/li&gt;
&lt;li&gt;platform-specific storage and bridge responsibilities&lt;/li&gt;
&lt;li&gt;native crash logging hooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a practical split.&lt;/p&gt;

&lt;p&gt;Flutter gives fast iteration and a unified codebase. Native code stays focused on surfaces where Android APIs are non-negotiable. For a solo-built security product, that balance can make a big difference in maintainability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threat Model Boundaries
&lt;/h2&gt;

&lt;p&gt;Good security writeups should include limits, not only protections.&lt;/p&gt;

&lt;p&gt;OneRule is designed to defend against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;offline access to copied app data without the derived key&lt;/li&gt;
&lt;li&gt;theft or exfiltration of encrypted database files&lt;/li&gt;
&lt;li&gt;tampering of AES-GCM protected vault fields and backups&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not designed to fully defend against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;rooted or fully compromised operating systems&lt;/li&gt;
&lt;li&gt;malware with runtime access while vault is unlocked&lt;/li&gt;
&lt;li&gt;PIN disclosure by user coercion, phishing, or keylogging&lt;/li&gt;
&lt;li&gt;clipboard interception before auto-clear executes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a reasonable and honest scope. Offline encryption can do a lot. It cannot fix a hostile runtime after trust boundary is already lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Project Is Interesting
&lt;/h2&gt;

&lt;p&gt;A lot of apps claim privacy because they do not have obvious ads or trackers. OneRule is more interesting than that because privacy is expressed in structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no backend dependency for core vault flows&lt;/li&gt;
&lt;li&gt;explicit key derivation&lt;/li&gt;
&lt;li&gt;versioned encrypted payloads&lt;/li&gt;
&lt;li&gt;layered storage protection&lt;/li&gt;
&lt;li&gt;local-only backup generation&lt;/li&gt;
&lt;li&gt;native Autofill integration without persistent plaintext snapshot model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is what I like about the project technically. The product promise and implementation choices mostly line up.&lt;/p&gt;

&lt;p&gt;That is rarer than it should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;If you want to study a Flutter security app that takes local-first architecture seriously, OneRule is worth reading through.&lt;/p&gt;

&lt;p&gt;It is not trying to out-feature every cloud password manager. It is solving a narrower problem: how to build a usable password vault where local storage, transparent cryptographic boundaries, and offline operation are first-class constraints rather than marketing copy.&lt;/p&gt;

&lt;p&gt;Project links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/seralifatih/OneRule" rel="noopener noreferrer"&gt;https://github.com/seralifatih/OneRule&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Play: &lt;a href="https://play.google.com/store/apps/details?id=com.fidevelopment.onerule" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.fidevelopment.onerule&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build security software in Flutter, this kind of project is a useful reminder that "mobile-first" and "security-conscious" do not have to be opposites. With careful boundaries, versioned crypto payloads, and disciplined state handling, a local-first password manager can stay practical without outsourcing trust to infrastructure.&lt;/p&gt;

</description>
      <category>android</category>
      <category>architecture</category>
      <category>flutter</category>
      <category>security</category>
    </item>
    <item>
      <title>How I Built LoopSignal: Public Feedback Boards, GitHub Sync, and Changelogs for Indie Developers</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Tue, 07 Apr 2026 04:40:22 +0000</pubDate>
      <link>https://dev.to/seralifatih/how-i-built-loopsignal-public-feedback-boards-github-sync-and-changelogs-for-indie-developers-ecg</link>
      <guid>https://dev.to/seralifatih/how-i-built-loopsignal-public-feedback-boards-github-sync-and-changelogs-for-indie-developers-ecg</guid>
      <description>&lt;p&gt;If you are building a SaaS, user feedback usually ends up everywhere except the one place where it is actually useful.&lt;/p&gt;

&lt;p&gt;Some requests arrive through email. Some show up in support chats. A few land in GitHub issues. One power user sends a long DM. Then roadmap time comes around and you realize you are mostly guessing.&lt;/p&gt;

&lt;p&gt;That frustration is what led me to build &lt;a href="https://loopsignal.dev/" rel="noopener noreferrer"&gt;&lt;strong&gt;LoopSignal&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;LoopSignal is a lightweight feedback platform for indie developers and small teams. It gives every product a public board where users can submit ideas and vote on requests, a public changelog to show what shipped, a widget you can embed inside your app, and a GitHub integration that connects feedback directly to development work.&lt;/p&gt;

&lt;p&gt;The goal was simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make feedback easy to collect.&lt;/li&gt;
&lt;li&gt;Make prioritization obvious.&lt;/li&gt;
&lt;li&gt;Make it easy to show users that their feedback mattered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last part is the reason for the name. I did not just want a feature request board. I wanted a tool that &lt;strong&gt;closed the loop&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I wanted to solve
&lt;/h2&gt;

&lt;p&gt;Most feedback tools I looked at fell into one of two buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They were powerful, polished, and priced for larger product teams.&lt;/li&gt;
&lt;li&gt;They were simple, but too limited or too disconnected from a developer-led workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For indie hackers and small SaaS teams, that leaves an awkward gap.&lt;/p&gt;

&lt;p&gt;You do not need an enterprise product suite just to answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What are users asking for most often?&lt;/li&gt;
&lt;li&gt;Which requests are repeated by multiple customers?&lt;/li&gt;
&lt;li&gt;What should I build next?&lt;/li&gt;
&lt;li&gt;How do I tell users their request is now shipped?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need a system that is small, fast, and opinionated in the right ways.&lt;/p&gt;

&lt;p&gt;I wanted something that let me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;collect feedback without forcing end users to create accounts&lt;/li&gt;
&lt;li&gt;let people vote so demand becomes visible&lt;/li&gt;
&lt;li&gt;moderate low-quality or spammy posts&lt;/li&gt;
&lt;li&gt;push approved requests into GitHub automatically&lt;/li&gt;
&lt;li&gt;publish a changelog from shipped work&lt;/li&gt;
&lt;li&gt;drop a widget into any app with one script tag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That became the product spec for LoopSignal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LoopSignal does
&lt;/h2&gt;

&lt;p&gt;At a high level, each project in LoopSignal gets its own public feedback workflow.&lt;/p&gt;

&lt;p&gt;Here is what that includes today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public feedback boards&lt;/strong&gt; where users can submit feature requests and vote on existing ones&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anonymous participation&lt;/strong&gt; so end users do not need accounts just to leave feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflow states&lt;/strong&gt; like Open, Planned, In Progress, Completed, and Closed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moderation and spam checks&lt;/strong&gt; to keep the board usable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub integration&lt;/strong&gt; that creates issues from approved feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub webhook sync&lt;/strong&gt; so closing an issue can mark the related request as completed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public changelog pages&lt;/strong&gt; generated from shipped work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddable widget&lt;/strong&gt; that works with a single script tag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email notifications&lt;/strong&gt; when a request changes status&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team roles&lt;/strong&gt; for owner, admin, and member access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics and CSV export&lt;/strong&gt; so teams can understand what is happening on the board&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API access&lt;/strong&gt; for teams that want to script or automate around it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow I cared about most looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user submits feedback from a public board or an in-app widget.&lt;/li&gt;
&lt;li&gt;Other users vote on it.&lt;/li&gt;
&lt;li&gt;The team reviews and approves it.&lt;/li&gt;
&lt;li&gt;LoopSignal creates a GitHub issue automatically.&lt;/li&gt;
&lt;li&gt;The issue gets worked on in the normal development flow.&lt;/li&gt;
&lt;li&gt;When the issue is closed, the feedback item becomes completed and shows up in the public changelog.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole product in one loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it for developers first
&lt;/h2&gt;

&lt;p&gt;A lot of feedback tools are built like product management software. That makes sense for larger organizations, but it also creates friction for small teams.&lt;/p&gt;

&lt;p&gt;Small teams do not want another heavyweight system to maintain. They want something that fits the tools they already use.&lt;/p&gt;

&lt;p&gt;That is why LoopSignal is intentionally developer-friendly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub is a first-class part of the workflow&lt;/li&gt;
&lt;li&gt;the widget is just a script tag&lt;/li&gt;
&lt;li&gt;the product exposes basic API endpoints&lt;/li&gt;
&lt;li&gt;the public board is simple enough that users can start contributing immediately&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted the setup to feel closer to "connect repo, share link, start collecting signal" than "spend a week configuring a process."&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack behind it
&lt;/h2&gt;

&lt;p&gt;I built LoopSignal with a stack that is well suited to founder-led SaaS products:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 15&lt;/strong&gt; for the app, routing, server rendering, and server actions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React 19&lt;/strong&gt; for the UI layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for Postgres, auth, and database access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for deployment via OpenNext&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paddle&lt;/strong&gt; for billing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resend&lt;/strong&gt; for transactional emails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS + shadcn/ui&lt;/strong&gt; for the UI&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This stack gave me a nice balance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fast iteration&lt;/li&gt;
&lt;li&gt;a single TypeScript codebase&lt;/li&gt;
&lt;li&gt;no separate backend service to maintain&lt;/li&gt;
&lt;li&gt;enough structure to support auth, billing, and multi-project access cleanly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How the core product flow works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Public submission without account friction
&lt;/h3&gt;

&lt;p&gt;One of the easiest ways to kill feedback volume is to ask users to create an account before they can submit an idea.&lt;/p&gt;

&lt;p&gt;LoopSignal avoids that. Users can submit feedback from the public board or embedded widget with an optional email if they want updates later.&lt;/p&gt;

&lt;p&gt;The submission flow does a few things before a post is created:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validates the title and description&lt;/li&gt;
&lt;li&gt;rate-limits submissions&lt;/li&gt;
&lt;li&gt;runs a simple spam scoring pass&lt;/li&gt;
&lt;li&gt;stores the post as &lt;code&gt;pending&lt;/code&gt; for moderation&lt;/li&gt;
&lt;li&gt;auto-subscribes the submitter for future status emails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the heart of that server action:&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;spam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkSpam&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;description&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="nx"&gt;ip&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;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSpam&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;logSpam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;spam_detected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;description&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="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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your submission was flagged.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newPost&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;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;project_id&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;description&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="na"&gt;email&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="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="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;spam_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;moderation_status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&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="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="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;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is intentionally simple. The product is optimized for useful signal, not for forcing people through a heavy workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Voting as lightweight prioritization
&lt;/h3&gt;

&lt;p&gt;Once a post is approved, it appears on the public board where users can vote on it.&lt;/p&gt;

&lt;p&gt;That matters because raw feedback is noisy. Voting turns isolated requests into visible demand. It gives teams a better signal than the loudest customer email.&lt;/p&gt;

&lt;p&gt;On the public board, users can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sort by most voted or newest&lt;/li&gt;
&lt;li&gt;filter by status&lt;/li&gt;
&lt;li&gt;filter by category&lt;/li&gt;
&lt;li&gt;open individual request pages&lt;/li&gt;
&lt;li&gt;see admin replies directly under posts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That keeps the board useful as it grows instead of turning into a long unstructured list.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. GitHub issue creation on approval
&lt;/h3&gt;

&lt;p&gt;The most important product decision I made was to connect approved feedback directly to GitHub.&lt;/p&gt;

&lt;p&gt;Once a request is approved, LoopSignal can create a GitHub issue automatically for the selected repository. The issue body includes the feedback description and gets labeled as &lt;code&gt;feedback&lt;/code&gt;, plus the category if one exists.&lt;/p&gt;

&lt;p&gt;This is the core issue creation logic:&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;body&lt;/span&gt; &lt;span class="o"&gt;=&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="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;---&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;*Created from LoopSignal feedback*&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&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;`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;githubToken&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&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;application/json&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;User-Agent&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;LoopSignal&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;labels&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 sounds small, but it changes the product from "a place where ideas go to sit" into "a place where ideas can enter the actual engineering workflow."&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Closing the loop with GitHub webhooks
&lt;/h3&gt;

&lt;p&gt;Creating an issue is only half the story.&lt;/p&gt;

&lt;p&gt;The more interesting part is the reverse sync. When a GitHub issue is closed, LoopSignal looks up the linked post and updates the workflow status to &lt;code&gt;completed&lt;/code&gt;. If the issue is reopened, the post moves back to &lt;code&gt;open&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That logic is intentionally tiny:&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of my favorite parts of the app because it removes a manual step. Teams do not have to remember to update the feedback board after shipping. Their existing development workflow updates it for them.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Public changelog generation
&lt;/h3&gt;

&lt;p&gt;Once a post reaches a shipped state, it can appear on the project's public changelog page.&lt;/p&gt;

&lt;p&gt;That matters because collecting feedback is only half the job. Users need to see that something happened because they spoke up.&lt;/p&gt;

&lt;p&gt;A good changelog does a few things at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;shows active development&lt;/li&gt;
&lt;li&gt;builds trust with current users&lt;/li&gt;
&lt;li&gt;gives potential customers confidence that the product is alive&lt;/li&gt;
&lt;li&gt;makes submitters more likely to contribute again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In LoopSignal, the changelog is just another view of the same underlying feedback data. That keeps the system simple and makes the "close the loop" story visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Embeddable widget with one script tag
&lt;/h3&gt;

&lt;p&gt;I also wanted the product to work inside existing apps without requiring a deep integration.&lt;/p&gt;

&lt;p&gt;That led to a simple embed script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script
  &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://loopsignal.dev/embed.js"&lt;/span&gt;
  &lt;span class="na"&gt;data-slug=&lt;/span&gt;&lt;span class="s"&gt;"your-project"&lt;/span&gt;
  &lt;span class="na"&gt;defer&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script renders an iframe-based widget. It can run in a floating mode with a feedback button or in an inline mode inside a chosen container.&lt;/p&gt;

&lt;p&gt;The important part is not technical complexity. It is setup speed. If someone can paste one script and start collecting feedback in two minutes, they are much more likely to actually use the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few implementation details I cared about
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Server actions for mutations
&lt;/h3&gt;

&lt;p&gt;I used server actions for most write operations. For a product like this, they are a good fit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fewer separate mutation endpoints to manage&lt;/li&gt;
&lt;li&gt;direct access to auth context and headers&lt;/li&gt;
&lt;li&gt;straightforward cache revalidation with &lt;code&gt;revalidatePath&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That made actions like submitting feedback, voting, updating statuses, and managing settings feel compact and easy to reason about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase for auth and data, but with careful access checks
&lt;/h3&gt;

&lt;p&gt;Supabase gave me a lot quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Postgres&lt;/li&gt;
&lt;li&gt;auth flows&lt;/li&gt;
&lt;li&gt;admin access for privileged operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But once team-based access enters the picture, things get more complicated. Ownership, admin permissions, member permissions, public access, and API access all intersect in subtle ways.&lt;/p&gt;

&lt;p&gt;I ended up centralizing access checks in auth helpers so permission logic was not duplicated all over the app. That was much easier to maintain than sprinkling project ownership checks throughout every action and page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Simple anti-spam instead of overengineering
&lt;/h3&gt;

&lt;p&gt;For public boards, spam is not optional. If anyone can submit, someone eventually will submit garbage.&lt;/p&gt;

&lt;p&gt;Instead of starting with a complicated external moderation service, I began with a simple scoring system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;all-caps titles&lt;/li&gt;
&lt;li&gt;too many URLs&lt;/li&gt;
&lt;li&gt;repetitive characters&lt;/li&gt;
&lt;li&gt;suspicious email patterns&lt;/li&gt;
&lt;li&gt;known spam phrases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is enough to block obvious junk while keeping the product lightweight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Basic API surface for power users
&lt;/h3&gt;

&lt;p&gt;LoopSignal also exposes a small API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /api/v1/[slug]/posts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;POST /api/v1/[slug]/posts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /api/v1/[slug]/stats&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The write endpoint uses an &lt;code&gt;x-api-key&lt;/code&gt; header and is gated to the Pro plan. I wanted the API to be useful for simple automation without expanding the scope into a huge platform surface area too early.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hardest parts of building it
&lt;/h2&gt;

&lt;p&gt;No SaaS build is complete without a few annoying edges.&lt;/p&gt;

&lt;p&gt;Here are the biggest ones I ran into:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Deployment on Cloudflare Workers
&lt;/h3&gt;

&lt;p&gt;Running Next.js 15 on Cloudflare Workers is possible, but not frictionless. OpenNext helps a lot, but you still have to think about environment assumptions, missing Node APIs, and deployment quirks.&lt;/p&gt;

&lt;p&gt;I like the performance and cost profile, but it definitely required more care than a default Vercel deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Permissions get complex fast
&lt;/h3&gt;

&lt;p&gt;The moment a product supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;project owners&lt;/li&gt;
&lt;li&gt;admins&lt;/li&gt;
&lt;li&gt;members&lt;/li&gt;
&lt;li&gt;public users&lt;/li&gt;
&lt;li&gt;API clients&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...your permission model gets real very quickly.&lt;/p&gt;

&lt;p&gt;What looks simple at the UI level can turn messy in the backend if you do not centralize access control early.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Billing logic touches more of the app than you expect
&lt;/h3&gt;

&lt;p&gt;Billing was one of those features that looked isolated at first and then ended up touching a lot of product behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;project limits&lt;/li&gt;
&lt;li&gt;team member limits&lt;/li&gt;
&lt;li&gt;Pro-only analytics&lt;/li&gt;
&lt;li&gt;Pro-only API access&lt;/li&gt;
&lt;li&gt;expired trial handling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is a good reminder that billing is rarely "just a checkout page."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from building LoopSignal
&lt;/h2&gt;

&lt;p&gt;The biggest lesson was that product scope matters more than feature count.&lt;/p&gt;

&lt;p&gt;It would have been easy to keep adding "nice to have" ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;more integrations&lt;/li&gt;
&lt;li&gt;custom branding controls everywhere&lt;/li&gt;
&lt;li&gt;more advanced automation&lt;/li&gt;
&lt;li&gt;internal product planning features&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the product gets stronger when the core loop stays obvious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;collect feedback -&amp;gt; validate demand -&amp;gt; sync to development -&amp;gt; show what shipped&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything in LoopSignal is there to support that loop.&lt;/p&gt;

&lt;p&gt;The second big lesson is that feedback products live or die on friction.&lt;/p&gt;

&lt;p&gt;If users need too many steps to submit feedback, they will not do it.&lt;br&gt;
If teams need too many steps to act on feedback, they will ignore it.&lt;br&gt;
If nobody sees what shipped, the system does not build trust.&lt;/p&gt;

&lt;p&gt;Reducing friction at each of those points mattered more than adding complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who I built it for
&lt;/h2&gt;

&lt;p&gt;LoopSignal is aimed at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;solo founders&lt;/li&gt;
&lt;li&gt;indie hackers&lt;/li&gt;
&lt;li&gt;small SaaS teams&lt;/li&gt;
&lt;li&gt;developer-led products that already live in GitHub&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not trying to be an all-in-one enterprise product management platform.&lt;/p&gt;

&lt;p&gt;It is trying to be the fastest way for a small team to go from scattered user requests to a visible, structured product feedback workflow.&lt;/p&gt;

&lt;p&gt;Pricing reflects that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Starter: $19/month&lt;/li&gt;
&lt;li&gt;Pro: $49/month&lt;/li&gt;
&lt;li&gt;14-day free trial with full Pro access&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Starter plan covers the public board, changelog, widget, GitHub integration, notifications, and CSV export. Pro adds things like API access and analytics, plus unlimited projects and team members.&lt;/p&gt;

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

&lt;p&gt;I built LoopSignal because I wanted a feedback tool that fit the way small teams actually work.&lt;/p&gt;

&lt;p&gt;Not every product needs a giant roadmap suite. Sometimes you just need one clear place to collect requests, let users vote, connect the important ones to GitHub, and show people that you shipped what mattered.&lt;/p&gt;

&lt;p&gt;That is what LoopSignal is for.&lt;/p&gt;

&lt;p&gt;If you want to check it out, the product is live at &lt;a href="https://loopsignal.dev" rel="noopener noreferrer"&gt;loopsignal.dev&lt;/a&gt;. I am also dogfooding it by collecting feedback about LoopSignal on LoopSignal itself, which felt like the right way to launch a product like this.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>tooling</category>
    </item>
    <item>
      <title>I Built 3 APIs for Turkey’s Used-Car Market with Apify</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Mon, 06 Apr 2026 08:37:59 +0000</pubDate>
      <link>https://dev.to/seralifatih/i-built-3-apis-for-turkeys-used-car-market-with-apify-7ph</link>
      <guid>https://dev.to/seralifatih/i-built-3-apis-for-turkeys-used-car-market-with-apify-7ph</guid>
      <description>&lt;p&gt;Turkey’s used-car market is massive, fragmented, and surprisingly hard to work with if you want structured data.&lt;/p&gt;

&lt;p&gt;Listings live across marketplaces, dealer pages are inconsistent, pricing changes fast, and even simple questions like “What is this car worth?” or “Which dealers dominate Istanbul for this brand?” are harder than they should be.&lt;/p&gt;

&lt;p&gt;So I built three focused APIs on top of Apify to solve different layers of the problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A listing extraction API for Arabam&lt;/li&gt;
&lt;li&gt;A valuation API for Arabam + Sahibinden&lt;/li&gt;
&lt;li&gt;A dealer intelligence API for Arabam + Sahibinden&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are built for developers, analysts, insurers, lenders, marketplaces, and automotive businesses that need clean Turkish vehicle data instead of brittle scraping scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Arabam.com Vehicle Scraper API
&lt;/h2&gt;

&lt;p&gt;The first API is the raw data layer.&lt;/p&gt;

&lt;p&gt;It extracts structured used-car listings from Arabam.com, including title, make, model, year, price, mileage, fuel type, transmission, city, seller type, and optional detail-page enrichment like condition and seller metadata.&lt;/p&gt;

&lt;p&gt;This is the API you use when you want the source-of-truth listing data itself.&lt;/p&gt;

&lt;p&gt;Typical use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;building your own vehicle marketplace dataset&lt;/li&gt;
&lt;li&gt;monitoring listing supply for a specific make/model&lt;/li&gt;
&lt;li&gt;collecting comps for downstream analysis&lt;/li&gt;
&lt;li&gt;feeding machine learning or BI pipelines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example 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;"searchUrls"&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.arabam.com/ikinci-el/otomobil/volkswagen-passat"&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;"maxListings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scrapeDetails"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output fields:&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;"listingId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"38718353"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Galeriden Volkswagen Passat 1.6 TDi BlueMotion Business 2020 Model Mersin 124.000 km Füme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"make"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Volkswagen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Passat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"year"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2020&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2025000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TRY"&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;"mileage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;124000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fuelType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dizel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transmission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yarı_otomatik"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Yüksek Mh. Erdemli, Mersin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sellerType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"galeri"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.arabam.com/ilan/..."&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;If you need listing-level data, this is the API to start with.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Turkish Auto Price Tracker API
&lt;/h2&gt;

&lt;p&gt;The second API is the decision layer.&lt;/p&gt;

&lt;p&gt;Instead of returning only listings, it turns listing data into valuation.&lt;/p&gt;

&lt;p&gt;You provide a vehicle spec such as make, model, year range, fuel type, and transmission. The API searches Arabam and Sahibinden, then returns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;listing-level price records&lt;/li&gt;
&lt;li&gt;median and average price&lt;/li&gt;
&lt;li&gt;min/max range&lt;/li&gt;
&lt;li&gt;percentiles&lt;/li&gt;
&lt;li&gt;seller-type breakdown&lt;/li&gt;
&lt;li&gt;&lt;p&gt;mileage-bucket analysis&lt;br&gt;
This is the API for questions like:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What is a 2020 diesel Passat actually worth today?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is this asking price above market?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;How do dealer prices compare to owner-listed prices?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example 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;"vehicles"&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;"make"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Volkswagen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Passat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"yearMin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2018&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"yearMax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2022&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fuelType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dizel"&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;"platforms"&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;"arabam"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxListingsPerPlatform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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;Example output includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PRICE_RECORD&lt;/li&gt;
&lt;li&gt;PRICE_SUMMARY&lt;/li&gt;
&lt;li&gt;RUN_SUMMARY&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A summary record looks like this in practice:&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;"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;"PRICE_SUMMARY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalListingsUsed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"overall"&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;"averagePrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1839438&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"medianPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1795750&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1525000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2169000&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;This is the most business-friendly API in the set because it converts raw marketplace noise into a usable market signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Turkish Auto Dealer Intelligence API
&lt;/h2&gt;

&lt;p&gt;The third API is the market structure layer.&lt;/p&gt;

&lt;p&gt;This one focuses on dealers rather than individual cars.&lt;/p&gt;

&lt;p&gt;It can scrape direct dealer URLs or discover dealers by city and then return structured dealer profiles with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dealer name&lt;/li&gt;
&lt;li&gt;city and district&lt;/li&gt;
&lt;li&gt;contact details&lt;/li&gt;
&lt;li&gt;trust/profile signals&lt;/li&gt;
&lt;li&gt;optional inventory analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That inventory mode is especially useful because it summarizes what a dealer is actually selling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;total listings&lt;/li&gt;
&lt;li&gt;average price&lt;/li&gt;
&lt;li&gt;median price&lt;/li&gt;
&lt;li&gt;price range&lt;/li&gt;
&lt;li&gt;fuel breakdown&lt;/li&gt;
&lt;li&gt;inventory composition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example 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;"platforms"&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;"arabam"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchByCity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"istanbul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxDealers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeInventory"&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;p&gt;Example dealer output:&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;"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;"DEALER_PROFILE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dealerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CM MOTORS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"İstanbul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"district"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fatih Oto Galeri"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"(0539) 812 32 20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dealerUrl"&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.arabam.com/galeri/cm-motors-istanbul"&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;And with inventory enabled:&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;"inventory"&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;"totalListings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;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;"averagePrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;573483&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"medianPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;654000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"priceRange"&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;"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;259000&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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;789000&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;This is the API for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dealer benchmarking&lt;/li&gt;
&lt;li&gt;lead generation&lt;/li&gt;
&lt;li&gt;market mapping by city&lt;/li&gt;
&lt;li&gt;inventory strategy analysis&lt;/li&gt;
&lt;li&gt;B2B automotive intelligence&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why split this into 3 APIs?
&lt;/h2&gt;

&lt;p&gt;Because these are three different jobs.&lt;/p&gt;

&lt;p&gt;Listing extraction is about raw data.&lt;br&gt;
Price tracking is about market valuation.&lt;br&gt;
Dealer intelligence is about competitive structure.&lt;/p&gt;

&lt;p&gt;Trying to force all of that into one giant “automotive scraper” would make the product harder to understand, harder to price, and harder for developers to integrate.&lt;/p&gt;

&lt;p&gt;Three narrower APIs make the value proposition much clearer.&lt;/p&gt;

&lt;p&gt;One more important point: these APIs are &lt;a href="https://github.com/seralifatih/Turkish-Automotive-Intelligence-Suite" rel="noopener noreferrer"&gt;open source&lt;/a&gt;, and contributions are welcome. If you want to improve marketplace coverage, harden parsers, expand normalization, add new output fields, improve dealer analytics, or help support more Turkish automotive workflows, you can jump in and contribute. I want this to become a practical open data tooling layer for the Turkish automotive ecosystem, not just a closed product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Turkey’s automotive market has a lot of publicly visible data, but not a lot of clean, reusable interfaces.&lt;/p&gt;

&lt;p&gt;That’s the gap these APIs are designed to fill.&lt;/p&gt;

&lt;p&gt;If you want listing-level data, use the vehicle scraper.&lt;br&gt;
If you want valuation, use the price tracker.&lt;br&gt;
If you want dealer-level market intelligence, use the dealer intelligence API.&lt;/p&gt;

&lt;p&gt;That stack gives you a practical data layer for the Turkish used-car market without rebuilding the scraping and normalization work from scratch.&lt;/p&gt;

</description>
      <category>api</category>
      <category>data</category>
      <category>showdev</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>I Built 3 APIs for Turkish E-Commerce Intelligence on Apify</title>
      <dc:creator>Fatih İlhan</dc:creator>
      <pubDate>Sat, 04 Apr 2026 19:50:15 +0000</pubDate>
      <link>https://dev.to/seralifatih/i-built-3-apis-for-turkish-e-commerce-intelligence-on-apify-3m0d</link>
      <guid>https://dev.to/seralifatih/i-built-3-apis-for-turkish-e-commerce-intelligence-on-apify-3m0d</guid>
      <description>&lt;p&gt;If you want structured product, seller, and review data from Turkish marketplaces, you usually end up stitching together brittle scrapers, inconsistent schemas, and platform-specific quirks.&lt;/p&gt;

&lt;p&gt;We decided to package that work into three production-ready Apify Actors that behave like APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;N11 Product Scraper&lt;/li&gt;
&lt;li&gt;Turkish Marketplace Seller Intelligence&lt;/li&gt;
&lt;li&gt;Turkish E-Commerce Review Aggregator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You send JSON input. You get clean, structured output. No custom parser per marketplace. No manual normalization step after the crawl. No guessing what the data shape will look like.&lt;/p&gt;

&lt;p&gt;The problem with Turkish marketplace data&lt;br&gt;
Turkish e-commerce is large, active, and fragmented.&lt;/p&gt;

&lt;p&gt;The hard part is not just collecting pages. The hard part is turning marketplace data into something you can actually use for pricing analysis, seller evaluation, competitor tracking, or product research.&lt;/p&gt;

&lt;p&gt;A few examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Product pages and listing pages expose different fields.&lt;/li&gt;
&lt;li&gt;Seller profile pages vary wildly across Trendyol, Hepsiburada, and N11.&lt;/li&gt;
&lt;li&gt;Review systems are inconsistent, especially once you try to normalize rating scales and optional metadata.&lt;/li&gt;
&lt;li&gt;Even when you can scrape the page, the output is often too messy to plug into dashboards, alerts, or downstream AI workflows.
That’s the gap these actors are meant to close.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I built&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;N11 Product Scraper
This actor extracts structured product data from N11 search results, category pages, and direct product URLs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It returns records like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product title&lt;/li&gt;
&lt;li&gt;brand&lt;/li&gt;
&lt;li&gt;current price and original price&lt;/li&gt;
&lt;li&gt;rating and review count&lt;/li&gt;
&lt;li&gt;seller name and seller URL&lt;/li&gt;
&lt;li&gt;category breadcrumb path&lt;/li&gt;
&lt;li&gt;image URLs&lt;/li&gt;
&lt;li&gt;stock status&lt;/li&gt;
&lt;li&gt;specifications&lt;/li&gt;
&lt;li&gt;description when available
A real output record looks like this in practice:
&lt;/li&gt;
&lt;/ul&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;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"61465"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Logitech MK270 Kablosuz USB Turkce Q Klavye Mouse Seti"&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1329.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;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TRY"&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;"sellerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PETCOM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sellerUrl"&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.n11.com/magaza/petcom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inStock"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That makes it useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;catalog intelligence&lt;/li&gt;
&lt;li&gt;price monitoring&lt;/li&gt;
&lt;li&gt;seller mapping&lt;/li&gt;
&lt;li&gt;assortment comparison&lt;/li&gt;
&lt;li&gt;marketplace research
Pricing: $5 per 1,000 product records&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Turkish Marketplace Seller Intelligence
Products are only half the story. On marketplaces, the seller is often the real unit of analysis.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This actor normalizes seller and store profiles across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Trendyol&lt;/li&gt;
&lt;li&gt;Hepsiburada&lt;/li&gt;
&lt;li&gt;&lt;p&gt;N11&lt;br&gt;
It extracts fields like:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;seller name&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;seller URL&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;overall rating&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;total products&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;follower count&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;badges&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;member since&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;public business details when available&lt;br&gt;
A sample output looks like:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&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;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sellerId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"petcom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sellerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PETCOM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sellerUrl"&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.n11.com/magaza/petcom"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"overallRating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"totalProducts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"badges"&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;"Basarili Magaza"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Hizli Gonderim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Ucretsiz Kargo"&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;This is the actor for teams doing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;supplier evaluation&lt;/li&gt;
&lt;li&gt;marketplace seller scoring&lt;/li&gt;
&lt;li&gt;brand monitoring&lt;/li&gt;
&lt;li&gt;competitive intelligence&lt;/li&gt;
&lt;li&gt;partner screening
Pricing: $8 per 1,000 seller profiles&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Turkish E-Commerce Review Aggregator
Reviews are where marketplace data becomes operational.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This actor pulls reviews from Trendyol, Hepsiburada, and N11 into one unified schema and adds basic Turkish sentiment tagging.&lt;/p&gt;

&lt;p&gt;Each review record includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product URL&lt;/li&gt;
&lt;li&gt;product title&lt;/li&gt;
&lt;li&gt;reviewer name&lt;/li&gt;
&lt;li&gt;rating&lt;/li&gt;
&lt;li&gt;title&lt;/li&gt;
&lt;li&gt;review body&lt;/li&gt;
&lt;li&gt;review date&lt;/li&gt;
&lt;li&gt;helpful count&lt;/li&gt;
&lt;li&gt;images&lt;/li&gt;
&lt;li&gt;seller name&lt;/li&gt;
&lt;li&gt;variant info&lt;/li&gt;
&lt;li&gt;sentiment tag&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example:&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;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"productTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Logitech MK270 Kablosuz USB Turkce Q Klavye Mouse Seti"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"M*** O***"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Iyiydi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sentimentTag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"positive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sellerName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Techburada"&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;This is useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sentiment analysis&lt;/li&gt;
&lt;li&gt;product feedback monitoring&lt;/li&gt;
&lt;li&gt;seller quality tracking&lt;/li&gt;
&lt;li&gt;review mining&lt;/li&gt;
&lt;li&gt;competitor product research
Pricing: $3 per 1,000 reviews&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why these three actors work better together&lt;br&gt;
Individually, each actor solves a clear problem.&lt;/p&gt;

&lt;p&gt;Together, they give you a compact Turkish e-commerce intelligence stack.&lt;/p&gt;

&lt;p&gt;A simple workflow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use the N11 Product Scraper to collect products in a category.&lt;/li&gt;
&lt;li&gt;Extract seller URLs from those product records.&lt;/li&gt;
&lt;li&gt;Pass those seller URLs into Seller Intelligence.&lt;/li&gt;
&lt;li&gt;Pass the product URLs into Review Aggregator.&lt;/li&gt;
&lt;li&gt;Join the outputs on product URL and seller URL.
Now you can answer questions like:&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;Which sellers dominate a category?&lt;/li&gt;
&lt;li&gt;Which sellers have strong trust signals but weak review sentiment?&lt;/li&gt;
&lt;li&gt;Which products are priced aggressively but getting poor feedback?&lt;/li&gt;
&lt;li&gt;Which brands are present across multiple sellers with inconsistent review patterns?
That is much more useful than a raw HTML scraper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Designed like APIs, not like hobby scripts&lt;br&gt;
A lot of scrapers stop at “it works on my machine.”&lt;/p&gt;

&lt;p&gt;These actors were built to behave more like production APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;input validation with clear English errors&lt;/li&gt;
&lt;li&gt;normalized output schemas&lt;/li&gt;
&lt;li&gt;progress logging&lt;/li&gt;
&lt;li&gt;partial completion handling&lt;/li&gt;
&lt;li&gt;final run summaries&lt;/li&gt;
&lt;li&gt;deployment-ready on Apify&lt;/li&gt;
&lt;li&gt;&lt;p&gt;clean dataset outputs for downstream systems&lt;br&gt;
That matters if you want to use them from:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;internal tools&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;BI pipelines&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;LLM workflows&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;agent systems&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;scheduled monitoring jobs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;enrichment pipelines&lt;br&gt;
Why Apify&lt;br&gt;
Apify is a strong fit for this category of product because it gives users a clean way to run, schedule, and consume data extraction jobs without managing crawler infrastructure.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means users can treat these actors as ready-to-use APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;send input&lt;/li&gt;
&lt;li&gt;run actor&lt;/li&gt;
&lt;li&gt;read dataset output&lt;/li&gt;
&lt;li&gt;plug into the next workflow
And because output schemas are defined, the results are easier for both humans and AI agents to understand and chain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Who these are for&lt;br&gt;
These actors are a good fit for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;e-commerce operators in Turkey&lt;/li&gt;
&lt;li&gt;agencies doing marketplace monitoring&lt;/li&gt;
&lt;li&gt;brands tracking sellers and reviews&lt;/li&gt;
&lt;li&gt;sourcing teams evaluating sellers&lt;/li&gt;
&lt;li&gt;analysts building category intelligence dashboards&lt;/li&gt;
&lt;li&gt;founders building vertical data products on top of Turkish commerce data
The pitch, simply put
Turkish marketplace data is valuable, but annoying to operationalize.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These three actors turn it into something you can actually use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product records&lt;/li&gt;
&lt;li&gt;seller profiles&lt;/li&gt;
&lt;li&gt;normalized reviews with sentiment
If you work on Turkish e-commerce intelligence, you should not have to rebuild this stack from scratch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly why I built it.&lt;/p&gt;

&lt;p&gt;Available now&lt;br&gt;
&lt;a href="https://apify.com/seralifatih/n11-product-scraper" rel="noopener noreferrer"&gt;N11 Product Scraper&lt;br&gt;
&lt;/a&gt;&lt;br&gt;
&lt;a href="https://apify.com/seralifatih/turkish-marketplace-seller-intelligence" rel="noopener noreferrer"&gt;Turkish Marketplace Seller Intelligence&lt;br&gt;
&lt;/a&gt;&lt;a href="https://apify.com/seralifatih/turkish-e-commerce-review-aggregator" rel="noopener noreferrer"&gt;Turkish E-Commerce Review Aggregator&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
