<?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: NexGenData</title>
    <description>The latest articles on DEV Community by NexGenData (@nexgendata).</description>
    <link>https://dev.to/nexgendata</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3856502%2Fe35e3ca7-6327-4c88-b6dd-c50cc4c21464.png</url>
      <title>DEV Community: NexGenData</title>
      <link>https://dev.to/nexgendata</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nexgendata"/>
    <language>en</language>
    <item>
      <title>Bulk Apple App Store + Google Play Review Monitoring (2026 Guide)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Tue, 15 Sep 2026 18:41:31 +0000</pubDate>
      <link>https://dev.to/nexgendata/bulk-apple-app-store-google-play-review-monitoring-2026-guide-5bib</link>
      <guid>https://dev.to/nexgendata/bulk-apple-app-store-google-play-review-monitoring-2026-guide-5bib</guid>
      <description>&lt;h1&gt;
  
  
  Bulk Apple App Store + Google Play Review Monitoring (2026 Guide)
&lt;/h1&gt;

&lt;p&gt;Every mobile product manager eventually hits the same wall. App Store Connect shows you ratings in aggregate, maybe a scrolling feed of the latest reviews, but nothing that answers "what are users complaining about &lt;em&gt;this week&lt;/em&gt; compared to last week" across both iOS and Android. Google Play Console is marginally better for Android-only shops. Neither tool lets you monitor competitor apps. Neither lets you export 10,000 reviews to a notebook for serious sentiment work. Neither gives you webhooks when a new 1-star hits.&lt;/p&gt;

&lt;p&gt;The result is that indie devs and small PM teams end up manually copy-pasting reviews into a spreadsheet every Monday, or paying $199/month for AppFollow, Appfigures, or Sensor Tower — tools that give you everything except the raw, structured review stream you can actually pipe into your own analytics.&lt;/p&gt;

&lt;p&gt;This post walks through building that stream yourself: a dual-platform scraper stack that pulls reviews from both Apple App Store and Google Play, runs sentiment classification, clusters complaints by theme, and lands you with a weekly digest you can share in Slack. The goal is not to replicate AppFollow's UI. The goal is to own the raw data so you can do things AppFollow will never support — custom sentiment models, competitor diffs, cohort analysis by app version, correlation between review spikes and App Store ranking changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounding Numbers
&lt;/h2&gt;

&lt;p&gt;A few numbers to frame the problem. Apple's App Store has roughly 1.8 million active apps as of Q4 2025 (Apple's own Services report, reconciled with data.ai). Google Play has about 2.3 million. Combined, users leave approximately 65 million reviews per month across both stores, per Sensor Tower's 2025 State of Mobile. The median app gets 0.4 reviews per 1,000 downloads; top-decile apps hit 3.2 reviews per 1,000.&lt;/p&gt;

&lt;p&gt;For a single mid-sized consumer app doing 500k MAU and 10k weekly downloads, you can expect 30-60 fresh reviews per week, with spikes of 500+ when a release goes badly or a viral moment hits. For a competitor-tracking setup covering 20 apps in a category, you are looking at 600-1,200 reviews per week to ingest and classify.&lt;/p&gt;

&lt;p&gt;On the classification side, modern transformer-based sentiment models (DistilBERT multilingual fine-tuned on app reviews, or any of the OpenAI/Anthropic small models) hit 88-92% agreement with human labels on the standard app-review benchmark (GAR, Maalej et al., 2016). That is good enough for weekly digests, not good enough for legal or HR consequences. Keep that framing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Hard
&lt;/h2&gt;

&lt;p&gt;Four things make bulk review monitoring harder than it looks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Neither store offers a public reviews API.&lt;/strong&gt; Apple's App Store Connect API covers only your own apps, and even that is rate-limited to ~50 calls per hour. Google Play Developer API has a &lt;code&gt;reviews&lt;/code&gt; endpoint but it only returns the last 7 days and only for apps you own. Competitor tracking is officially unsupported.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pagination is awful on both.&lt;/strong&gt; Apple's RSS feed caps at 500 reviews per country and doesn't go further back. The iTunes Lookup API gives you ratings counts but not review text. Google Play's web interface paginates with a continuation token that changes format every few months.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Localization fragments everything.&lt;/strong&gt; Apple reviews are partitioned by country (155 storefronts). A US-only pull misses your Japanese, German, and Brazilian review streams entirely. Google Play is language-partitioned rather than country-partitioned, which is different but equally fragmenting.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Review text is messy.&lt;/strong&gt; Emojis, transliterations, auto-translated reviews (Google Play auto-translates into the viewer's language, which means the same review appears twice if you query in two languages), and spam from incentivized-review farms. Cleaning this before you classify is a real step.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are unsolvable. They are just not a weekend project if you start from scratch.&lt;/p&gt;

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

&lt;p&gt;Here is the end-to-end pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  [app ID list]
  (iOS + Android, competitors included)
        |
        v
  +---------------------+     +----------------------+
  | apple-app-store-    |     | google-play-reviews- |
  | reviews-scraper     |     | scraper              |
  +---------------------+     +----------------------+
        |                            |
        +------------+---------------+
                     |
                     v
             [normalize schema]
             (app_id, platform, rating,
              text, lang, version, date)
                     |
                     v
         [dedupe + language filter]
                     |
                     v
         [sentiment classifier]
         (DistilBERT or LLM call)
                     |
                     v
         [theme clustering]
         (BERTopic or LLM-guided)
                     |
                     v
         [Postgres + weekly digest]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two scrapers run in parallel. Output lands in a normalized schema that hides the per-platform differences. Sentiment and theme clustering happen in a separate step, which lets you swap classifiers without re-scraping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code: Pull 200 Reviews Across Both Stores
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/apple-app-store-reviews-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;apple-app-store-reviews-scraper&lt;/a&gt; and &lt;a href="https://apify.com/nexgendata/google-play-reviews-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;google-play-reviews-scraper&lt;/a&gt; actors share a compatible output schema. You can fire both from one script.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="n"&gt;ios_apps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;544007664&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;310633997&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# YouTube, WhatsApp
&lt;/span&gt;&lt;span class="n"&gt;android_apps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.google.android.youtube&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.whatsapp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;ios_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/apple-app-store-reviews-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ios_apps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;countries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;br&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_reviews_per_app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;most_recent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;android_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/google-play-reviews-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;android_apps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;languages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;de&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ja&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_reviews_per_app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;newest&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;ios_reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ios_run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;android_reviews&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;android_run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ios&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submitted_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ios_reviews&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lang&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
     &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;submitted_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;android_reviews&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;unstack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fill_value&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical output cross-tab for the two apps above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;rating&lt;/span&gt;       &lt;span class="mf"&gt;1&lt;/span&gt;    &lt;span class="mf"&gt;2&lt;/span&gt;    &lt;span class="mf"&gt;3&lt;/span&gt;    &lt;span class="mf"&gt;4&lt;/span&gt;     &lt;span class="mf"&gt;5&lt;/span&gt;
&lt;span class="k"&gt;platform&lt;/span&gt;
&lt;span class="k"&gt;android&lt;/span&gt;     &lt;span class="mf"&gt;43&lt;/span&gt;   &lt;span class="mf"&gt;18&lt;/span&gt;   &lt;span class="mf"&gt;31&lt;/span&gt;   &lt;span class="mf"&gt;55&lt;/span&gt;   &lt;span class="mf"&gt;353&lt;/span&gt;
&lt;span class="k"&gt;ios&lt;/span&gt;         &lt;span class="mf"&gt;27&lt;/span&gt;    &lt;span class="mf"&gt;9&lt;/span&gt;   &lt;span class="mf"&gt;22&lt;/span&gt;   &lt;span class="mf"&gt;40&lt;/span&gt;   &lt;span class="mf"&gt;402&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rating skew is expected — reviewers self-select toward extremes. What you are interested in is the 1- and 2-star tail, because that is where actionable product complaints live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sentiment Classification
&lt;/h2&gt;

&lt;p&gt;Once the reviews are in a dataframe, run a classifier. For a weekly digest at this volume, a small local model is cheaper than an LLM API call per review. Here is a minimal &lt;code&gt;transformers&lt;/code&gt; pass:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;clf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment-analysis&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nlptown/bert-base-multilingual-uncased-sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;device&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="c1"&gt;# set to -1 for CPU
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Truncate to 512 chars — BERT tokenizer limit
&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text_trunc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;fillna&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text_trunc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment_stars&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This model outputs &lt;code&gt;1 star&lt;/code&gt; through &lt;code&gt;5 stars&lt;/code&gt;. You now have two signals per review: the user-provided rating and the model-predicted rating. When they disagree by 2+ stars it usually means sarcasm (5-star review trashing the app) or a rating-without-comment (1-star with empty text). Both are worth flagging.&lt;/p&gt;

&lt;p&gt;For theme clustering, BERTopic works out of the box on a few thousand reviews. For smaller volumes, an LLM with a clustering prompt is faster to ship:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="n"&gt;negative&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&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="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cluster these app reviews into 5 themes. Return JSON: {theme: [review_ids]}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;negative&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;themes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;themes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For 150 reviews, this is a ~$0.01 call and returns something like &lt;code&gt;{"crashes_on_launch": [3,7,22,41], "ads_too_aggressive": [1,5,12,18,33,...], ...}&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked Example: Weekly Competitor Digest
&lt;/h2&gt;

&lt;p&gt;Say you are PM on a meditation app. You track three competitors plus your own app across iOS and Android. Every Monday morning you want a Slack post that lists, per app: total reviews this week, average rating, top 3 complaint themes, and any rating drop of more than 0.3 stars week-over-week.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="c1"&gt;# Pull last 7 days across 4 apps × 2 platforms
&lt;/span&gt;&lt;span class="n"&gt;apps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Calm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ios&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;571800810&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.calm.android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Headspace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ios&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;493145008&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.getsomeheadspace.android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Insight Timer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ios&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;337472899&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.spotlightsix.zentimerlite2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YourApp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ios&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;android&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;weekly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&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="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;weekly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;avg_rating&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;neg_pct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rating&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&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="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed the negative reviews for each app into the LLM theme clusterer, dump the results into a templated Slack message, and you have a digest that would cost $199/month from AppFollow. Running it as an Apify scheduled task pushes the cost under $5/month at this volume.&lt;/p&gt;

&lt;p&gt;Over six months of running this, a PM on the team spots a pattern: whenever a release contains a meaningful UI change, 1-star reviews spike within 72 hours. They start shipping UI changes behind a feature flag and rolling out to 10% first. Review sentiment during the subsequent staged rollout becomes a release signal. That is the actual point of owning the data stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;Things we have watched trip people up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apple's RSS cap is 500 reviews per country.&lt;/strong&gt; You cannot page deeper. For apps with thousands of reviews, you either ingest continuously from day one or accept that historical backfill is partial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Play auto-translation double-counts.&lt;/strong&gt; If you query &lt;code&gt;com.whatsapp&lt;/code&gt; with &lt;code&gt;languages=["en", "de"]&lt;/code&gt;, a German review may appear once in German and once translated-to-English. Dedupe by review ID, not text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version numbers are unreliable.&lt;/strong&gt; Apple's &lt;code&gt;app_version&lt;/code&gt; field reflects the version the user was on when reviewing. Google Play sometimes drops it entirely for older reviews. Do not use this for cohort analysis without spot-checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spam and incentivized reviews exist.&lt;/strong&gt; Both stores have anti-fraud teams, but clusters of 5-star reviews with near-identical phrasing show up regularly. Flagging them is a classifier problem — usually low perplexity + short length + recent account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localization scoring varies wildly.&lt;/strong&gt; Japanese users rate 0.5 stars lower than US users on average for the same app; German users 0.3 lower. Cross-country average-rating comparisons are meaningless without normalization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits.&lt;/strong&gt; The actors handle backoff, but if you self-roll the scrapers: Apple RSS is soft-capped around 10 requests/sec per IP; Google Play's web UI starts serving CAPTCHAs around 30 requests/min per IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviews disappear.&lt;/strong&gt; Apple removes reviews the developer successfully appeals. Google Play removes reviews that violate policy. If you are doing longitudinal analysis, snapshot reviews as you ingest; do not assume they will still be there next quarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emoji-only reviews.&lt;/strong&gt; About 2% of reviews are emoji-only. Most sentiment classifiers handle them poorly. Either route them to a separate classifier or drop them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I track my competitors legally?&lt;/strong&gt;&lt;br&gt;
App Store reviews are public data under both Apple's and Google's terms of service for normal users. Automated collection is technically against Apple's Developer Agreement, but the enforcement target has historically been scraping the storefront for listings and pricing, not reviews. Consult your own counsel for anything commercial; for internal research, the risk is low.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should I run the scrape?&lt;/strong&gt;&lt;br&gt;
For a weekly digest: once per week is enough. For crisis monitoring (post-release or incident): hourly for the first 48 hours after a release, then daily. The actors are designed to run idempotently — running twice in an hour will mostly return cached data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How far back does the history go?&lt;/strong&gt;&lt;br&gt;
Apple: about 500 most recent reviews per country, no deeper. Google Play: roughly 12 months with cooperative pagination. For older reviews, you need snapshots you collected previously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if my app is in 40 countries?&lt;/strong&gt;&lt;br&gt;
Pull the top 10 by install volume for weekly digests. Run a monthly fuller pull across all countries for trend analysis. Running all 155 Apple storefronts weekly is overkill and expensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the sentiment classifier handle non-English reviews?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;nlptown/bert-base-multilingual-uncased-sentiment&lt;/code&gt; handles 6 languages natively (English, Dutch, German, French, Spanish, Italian). For Japanese, Chinese, Korean, Portuguese, Arabic, you want a different model per language, or just use an LLM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I correlate reviews with my install numbers?&lt;/strong&gt;&lt;br&gt;
Yes, if you pull installs from App Store Connect and Play Console. The Apify scrapers do not pull install data (it is private to the developer), but you can join on date and app_version after the fact. The signal-to-noise is noisy at weekly granularity, good at monthly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about in-app review prompts skewing the data?&lt;/strong&gt;&lt;br&gt;
The SKStoreReviewController and Google's in-app review API skew ratings positive because they are shown after success events. If you run these, expect your ratings to trend 0.5-1.0 stars higher than organic, which makes competitor comparison harder. Note it in your dashboards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle developer responses?&lt;/strong&gt;&lt;br&gt;
Both scrapers return the developer reply text if present. Treat replies as a separate signal — response rate, median response time, and reply length all correlate with rating trends. Teams that reply within 24 hours see measurably lower churn on 2-3 star reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Mobile review monitoring does not require a $199/month subscription or a dedicated analyst. Two Apify actors, a normalized schema, a small sentiment classifier, and a weekly cron give you most of what AppFollow and Appfigures sell, plus the freedom to do custom analysis neither tool supports.&lt;/p&gt;

&lt;p&gt;The bigger win is not cost savings — it is that owning the raw review stream lets you correlate reviews with things AppFollow does not know about: your release cadence, your feature flags, your marketing spikes, your outages. That correlation is where product insight actually lives.&lt;/p&gt;

&lt;p&gt;Start with the &lt;a href="https://apify.com/nexgendata/apple-app-store-reviews-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;apple-app-store-reviews-scraper&lt;/a&gt; and &lt;a href="https://apify.com/nexgendata/google-play-reviews-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;google-play-reviews-scraper&lt;/a&gt; on Apify. Both run pay-per-use, handle pagination and localization, and return a consistent schema you can feed into whatever analytics stack you already own.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>mobile</category>
      <category>appstore</category>
      <category>reviews</category>
    </item>
    <item>
      <title>Crunchbase Killed Its Free API. Here's How to Rebuild It (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Tue, 08 Sep 2026 15:08:46 +0000</pubDate>
      <link>https://dev.to/nexgendata/crunchbase-killed-its-free-api-heres-how-to-rebuild-it-2026-3588</link>
      <guid>https://dev.to/nexgendata/crunchbase-killed-its-free-api-heres-how-to-rebuild-it-2026-3588</guid>
      <description>&lt;h1&gt;
  
  
  Crunchbase Killed Its Free API. Here's How to Rebuild It (2026)
&lt;/h1&gt;

&lt;p&gt;In 2023, Crunchbase quietly deprecated its free Basic API. A generation of indie hackers, academic researchers, and early-stage founders piping structured company data into dashboards and Jupyter notebooks suddenly had nowhere to go. The paid replacement starts at roughly $500/month — a rounding error for a BD team, a non-starter for a solo developer or a graduate student building a thesis dataset.&lt;/p&gt;

&lt;p&gt;The good news: Crunchbase is mostly aggregating public information anyway. Company domains, tech stacks, team-size signals, infrastructure footprints — it's all sitting in public DNS, GitHub, certificate transparency logs, and registrar records. Nobody compiled it into one neat JSON endpoint for free, but the raw material is.&lt;/p&gt;

&lt;p&gt;Stitch together the right eight sources and you can reconstruct roughly 60–70% of what Crunchbase Basic used to offer. You won't get funding rounds or cap tables — those still require paid data. You will get domain, founding-era proxy, tech stack, CDN, hosting provider, subdomain footprint, GitHub activity, email infrastructure, brand assets, and a reasonable team-size estimate. This post walks through each of the eight sources, the parallel-fanout architecture, a worked VC screening example, and the gotchas that will bite you at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Gap: What Crunchbase Basic Used to Give You
&lt;/h2&gt;

&lt;p&gt;Before the shutdown, a free Crunchbase Basic call returned: company name, domain, logo URL, description, &lt;code&gt;employee_count_range&lt;/code&gt;, &lt;code&gt;funding_rounds&lt;/code&gt; with amounts and investors, industry tags, location, and &lt;code&gt;founded_year&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Roughly half of that is derivable from public sources. The other half — funding data, precise employee counts, curated industry taxonomy — requires Crunchbase, PitchBook, or a human research team.&lt;/p&gt;

&lt;p&gt;What you &lt;em&gt;can&lt;/em&gt; reconstruct for free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Domain, logo, tagline&lt;/strong&gt; — company site, favicon, Open Graph tags&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Founding-era proxy&lt;/strong&gt; — WHOIS domain creation date (imperfect; domains are sometimes registered years before or after incorporation)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech stack&lt;/strong&gt; — HTTP headers, robots.txt hints, npm org scopes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting + CDN&lt;/strong&gt; — DNS A records and HTTP headers (CF-Ray, X-Amz-Cf-Id, Via)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team-size proxy&lt;/strong&gt; — GitHub org member + repo count, plus subdomain footprint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security posture&lt;/strong&gt; — SSL cert issuer (Let's Encrypt vs. DigiCert vs. internal CA)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaaS stack&lt;/strong&gt; — DNS TXT records (SPF/DMARC verification tokens leak vendor relationships)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you basically cannot derive: exact employee count, funding rounds, investors, cap table, ARR/MRR, board composition. For those, pay Crunchbase/PitchBook or do journalism.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Eight Free Data Sources
&lt;/h2&gt;

&lt;p&gt;Let's go through each source in order, including what you get, what it costs (in API calls and ethical risk), and where it breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. WHOIS — The Domain Registry Record
&lt;/h3&gt;

&lt;p&gt;WHOIS is the oldest piece of internet infrastructure still relevant for company-data work. Every one of the ~362M registered domains (Verisign DNIB 2024) has a WHOIS record. Query &lt;code&gt;whois stripe.com&lt;/code&gt; and you get registrar (MarkMonitor), creation date (2009-09-10), expiration, and nameservers.&lt;/p&gt;

&lt;p&gt;What's useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creation date&lt;/strong&gt; as a founding-era proxy. Stripe's domain was registered in 2009; the company was founded in 2010. Close enough for most analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registrar choice&lt;/strong&gt;. MarkMonitor, CSC, or Com Laude skew enterprise with IP-protection budgets. GoDaddy, Namecheap, Porkbun skew indie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registrant organization&lt;/strong&gt; — when it isn't redacted. Post-GDPR, most registrars hide this behind "Whois Privacy," but pre-2018 .com registrations still expose org names surprisingly often.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nameservers&lt;/strong&gt; — &lt;code&gt;ns-cloud-a1.googledomains.com&lt;/code&gt; means GCP DNS. &lt;code&gt;pdns*.ultradns.net&lt;/code&gt; means enterprise DNS. &lt;code&gt;ns1.cloudflare.com&lt;/code&gt; means Cloudflare.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gotcha: WHOIS rate limits are aggressive and per-TLD. ccTLDs (&lt;code&gt;.io&lt;/code&gt;, &lt;code&gt;.ai&lt;/code&gt;, &lt;code&gt;.de&lt;/code&gt;) have their own registry endpoints with tighter limits. Use &lt;code&gt;whoisxmlapi.com&lt;/code&gt; free tier (500/month) if you need volume.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. DNS — The Email and Infrastructure Fingerprint
&lt;/h3&gt;

&lt;p&gt;DNS is where a huge amount of company intelligence lives, and it's all free to query. For any domain, pull:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A records&lt;/strong&gt; — where the apex points. Is it a Cloudflare IP (104.21.x.x, 172.67.x.x)? A direct EC2 IP? A Fastly anycast range? This tells you about scale and sophistication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MX records&lt;/strong&gt; — the email provider. &lt;code&gt;aspmx.l.google.com&lt;/code&gt; means Google Workspace (usually 1–500 employees, startup-heavy). &lt;code&gt;*.mail.protection.outlook.com&lt;/code&gt; means Microsoft 365 (enterprise-skewed). Self-hosted MX on the company's own mail server means either a security company or a 90s holdout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NS records&lt;/strong&gt; — authoritative nameservers. Paired with A records, confirms the DNS provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TXT records&lt;/strong&gt; — the real gold mine. SPF records (&lt;code&gt;v=spf1 include:_spf.google.com include:mailgun.org ~all&lt;/code&gt;) reveal every email-sending service they use: Mailgun, SendGrid, Postmark, Mailchimp, Customer.io. DMARC records signal security maturity. And the weird ones — &lt;code&gt;google-site-verification=...&lt;/code&gt;, &lt;code&gt;atlassian-domain-verification=...&lt;/code&gt;, &lt;code&gt;stripe-verification=...&lt;/code&gt;, &lt;code&gt;intercom-site-verification=...&lt;/code&gt;, &lt;code&gt;facebook-domain-verification=...&lt;/code&gt; — each one is a confirmed SaaS relationship.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single &lt;code&gt;dig TXT stripe.com&lt;/code&gt; can tell you Stripe uses Google for email, has strict DMARC, and verifies with Atlassian, Segment, and a dozen other vendors. That's a SaaS-stack leak that would cost $300/month from BuiltWith's paid tier.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. SSL Certificates via crt.sh — The Subdomain Leak
&lt;/h3&gt;

&lt;p&gt;Certificate Transparency logs are a post-2013 mandate requiring every publicly-trusted TLS cert to be logged. Combined CT logs indexed by crt.sh contain roughly 10B certificates as of 2026.&lt;/p&gt;

&lt;p&gt;For any domain, &lt;code&gt;crt.sh?q=%25.stripe.com&amp;amp;output=json&lt;/code&gt; returns every cert ever issued for a subdomain. This leaks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain list&lt;/strong&gt; — &lt;code&gt;api.stripe.com&lt;/code&gt;, &lt;code&gt;dashboard.stripe.com&lt;/code&gt;, &lt;code&gt;files.stripe.com&lt;/code&gt;, &lt;code&gt;checkout.stripe.com&lt;/code&gt;, and dozens more. Internal and staging hosts sometimes leak when someone accidentally requests a public cert for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cert issuer&lt;/strong&gt; — Let's Encrypt signals startup/small team (free, automated). DigiCert, Sectigo, GlobalSign signal mid-to-large with procurement budgets. Internal CAs signal enterprise with a dedicated PKI team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cert cadence&lt;/strong&gt; — aggressive 90-day rotation signals modern DevOps; 2-year certs signal legacy ops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Subdomain count alone is a useful size proxy. A 5-person startup has 3–5 subdomains. A Series B SaaS has 20–40. A Stripe-scale company has hundreds.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. GitHub Org API — The Engineering-Team Signal
&lt;/h3&gt;

&lt;p&gt;GitHub's REST API is free and generous: 5,000 requests/hour authenticated. &lt;code&gt;GET /orgs/{org}&lt;/code&gt; returns public member count, repo count, creation date, description, and location. &lt;code&gt;GET /orgs/{org}/repos&lt;/code&gt; paginates through public repos.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public repo count&lt;/strong&gt; — 5 repos means a small shop. 100+ repos means a real engineering org. Many companies put serious work in private repos, so this is a floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Primary languages&lt;/strong&gt; — top 3 being Python/Go/TypeScript signals a modern stack. Java/Ruby at the top hints legacy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Member count&lt;/strong&gt; — public org members. Most companies hide members by default, so this drastically undercounts. Stripe has probably 300+ engineers on GitHub but shows maybe 40 public members.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activity level&lt;/strong&gt; — commit frequency, open-issue count, star count on flagship projects. A dead org with 500 repos and no recent commits is very different from a live one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: the &lt;code&gt;company-data-aggregator&lt;/code&gt; actor currently returns first-page repos only; a full-org walk with pagination is on the roadmap. For ~95% of companies, first page (30 repos) is enough signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Tech Headers — The Lightweight BuiltWith
&lt;/h3&gt;

&lt;p&gt;Make a single HEAD or GET request to the company's homepage and inspect the response headers. You'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Server&lt;/code&gt;&lt;/strong&gt; — Apache, nginx, Caddy, IIS, LiteSpeed, or a proxy like Cloudflare/CloudFront that masks the origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Powered-By&lt;/code&gt;&lt;/strong&gt; — PHP, Express, ASP.NET, Next.js. Modern shops often strip this for security; its absence is itself a signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Via&lt;/code&gt;&lt;/strong&gt; — usually &lt;code&gt;1.1 varnish&lt;/code&gt;, &lt;code&gt;1.1 google&lt;/code&gt;, or similar; reveals intermediate proxies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CF-Ray&lt;/code&gt;&lt;/strong&gt; — Cloudflare presence and data center (e.g., &lt;code&gt;8a3f2c... -SJC&lt;/code&gt; = San Jose POP).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Amz-Cf-Id&lt;/code&gt;&lt;/strong&gt; — CloudFront.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Served-By&lt;/code&gt;&lt;/strong&gt; — Fastly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Akamai-*&lt;/code&gt;&lt;/strong&gt; — Akamai.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Vercel-*&lt;/code&gt;&lt;/strong&gt; — Vercel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Github-Request-Id&lt;/code&gt;&lt;/strong&gt; — GitHub Pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From five headers you can usually identify the CDN, the front-end host, and sometimes the framework (Next.js, Remix, SvelteKit, Nuxt all leak through &lt;code&gt;X-Nextjs-Prerender&lt;/code&gt;, &lt;code&gt;X-Nuxt-Renderer&lt;/code&gt;, or distinctive Link prefetch patterns).&lt;/p&gt;

&lt;h3&gt;
  
  
  6. robots.txt + sitemap.xml — The Internal-Map Leak
&lt;/h3&gt;

&lt;p&gt;Every well-behaved website publishes &lt;code&gt;/robots.txt&lt;/code&gt; and usually &lt;code&gt;/sitemap.xml&lt;/code&gt;. These are free to fetch and contain remarkable amounts of internal structure.&lt;/p&gt;

&lt;p&gt;robots.txt tells you what the company &lt;em&gt;doesn't&lt;/em&gt; want indexed, which is often what's interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;User&lt;/span&gt;-&lt;span class="n"&gt;agent&lt;/span&gt;: *
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;admin&lt;/span&gt;/
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;api&lt;/span&gt;/
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;dashboard&lt;/span&gt;/
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;internal&lt;/span&gt;/
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;beta&lt;/span&gt;/
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;/
&lt;span class="n"&gt;Disallow&lt;/span&gt;: /&lt;span class="n"&gt;staging&lt;/span&gt;/
&lt;span class="n"&gt;Sitemap&lt;/span&gt;: &lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;/&lt;span class="n"&gt;sitemap&lt;/span&gt;.&lt;span class="n"&gt;xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just from that, you know they have an admin panel, a dashboard, a beta product, internal tooling paths, a Next.js frontend, and a staging environment. A lot of architectural intel for one HTTP GET.&lt;/p&gt;

&lt;p&gt;sitemap.xml gives you size:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;50-URL sitemap: tiny marketing site&lt;/li&gt;
&lt;li&gt;500-URL sitemap: real product with docs and blog&lt;/li&gt;
&lt;li&gt;5,000-URL sitemap: content-heavy SaaS with docs + changelogs + customer stories&lt;/li&gt;
&lt;li&gt;50,000+ URLs split across sitemap index files: scale (think Notion, Webflow, marketplace-style)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sitemap &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; dates also hint at content velocity — is this a live, actively-maintained site, or abandoned?&lt;/p&gt;

&lt;h3&gt;
  
  
  7. npm Organization Scope — The Internal-Library Fingerprint
&lt;/h3&gt;

&lt;p&gt;npm has 3M+ public packages and a scoping mechanism (&lt;code&gt;@stripe/&lt;/code&gt;, &lt;code&gt;@vercel/&lt;/code&gt;, &lt;code&gt;@shopify/&lt;/code&gt;) that lets companies publish under an org namespace. Most serious JavaScript-shipping companies claim their scope.&lt;/p&gt;

&lt;p&gt;Querying &lt;code&gt;registry.npmjs.org/-/v1/search?text=scope:stripe&lt;/code&gt; gives you every package published under the scope. What this reveals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Which products they ship&lt;/strong&gt;. &lt;code&gt;@stripe/stripe-js&lt;/code&gt; is the browser SDK. &lt;code&gt;@stripe/react-stripe-js&lt;/code&gt; is the React wrapper. Each package is a confirmed product surface area.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Which internal libraries they've extracted&lt;/strong&gt;. When a company open-sources &lt;code&gt;@acme/ui-primitives&lt;/code&gt;, they're signaling internal practices and often recruiting (the README usually links to careers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download volume&lt;/strong&gt; as a crude user-base proxy. &lt;code&gt;@stripe/stripe-js&lt;/code&gt; gets 8M+ weekly downloads — a real distribution footprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publication frequency&lt;/strong&gt;. Monthly releases across many packages = active engineering. Last publish 2 years ago = maintenance mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gotcha: some companies publish under personal names rather than org scopes (early-stage founders who never migrated). You'll miss those unless you also search packages where &lt;code&gt;repository.url&lt;/code&gt; contains the company's GitHub org.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Favicon + Open Graph Images — The Brand Fingerprint
&lt;/h3&gt;

&lt;p&gt;Finally, the branding layer. Fetch the homepage HTML and parse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;link rel="icon" href="..."&amp;gt;&lt;/code&gt; — the favicon. High-res with multiple sizes = company cares about brand. A 16x16 Rails/Next placeholder = they don't.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;meta property="og:image"&amp;gt;&lt;/code&gt; — the social-share card, usually containing logo + tagline. Perfect for a visual directory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;meta property="og:title"&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;meta property="og:description"&amp;gt;&lt;/code&gt; — official tagline and description, usually higher-quality than a scraped H1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fallback: Google's favicon service at &lt;code&gt;https://www.google.com/s2/favicons?domain=stripe.com&amp;amp;sz=128&lt;/code&gt; returns an icon for almost any indexed domain.&lt;/p&gt;

&lt;p&gt;OG images are gold for company directories — pre-designed 1200x630, include logo and tagline, explicitly intended for third-party consumption. No ethical concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grounding Numbers
&lt;/h2&gt;

&lt;p&gt;To put this in perspective: Crunchbase has ~11M companies (most with shallow data). GitHub has ~60M public repos across ~100M users (2024 Octoverse). WHOIS covers ~362M registered domains (Verisign DNIB 2024) — essentially every company with a web presence. npm has 3M+ public packages. CT logs indexed by crt.sh hold ~10B certificates.&lt;/p&gt;

&lt;p&gt;You are not dealing with scarcity — you're dealing with an aggregation-and-normalization problem. Every relevant company is in these sources somewhere; the hard part is hitting them in parallel, merging results, and surviving rate limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: Parallel Fanout
&lt;/h2&gt;

&lt;p&gt;Here's the architecture for pulling all eight sources per domain without taking forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Domain list]
     |
     v
[company-data-aggregator]
     |
     +-&amp;gt; WHOIS        -&amp;gt; registrar, age, registrant org
     +-&amp;gt; DNS          -&amp;gt; MX stack, verification tokens
     +-&amp;gt; SSL/crt.sh   -&amp;gt; subdomains, cert issuer
     +-&amp;gt; GitHub org   -&amp;gt; repos, languages, size proxy
     +-&amp;gt; tech headers -&amp;gt; CDN, hosting, server
     +-&amp;gt; robots/sitemap -&amp;gt; scale + internal paths
     +-&amp;gt; npm          -&amp;gt; internal packages
     +-&amp;gt; favicon/og   -&amp;gt; brand assets
     |
     v
[merged JSON profile per domain]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/company-data-aggregator?fpr=2ayu9b" rel="noopener noreferrer"&gt;company-data-aggregator&lt;/a&gt; actor on Apify hits all eight in parallel per domain via &lt;code&gt;asyncio.gather&lt;/code&gt;, with per-source error isolation (if WHOIS rate-limits you, DNS and GitHub still succeed), a configurable per-source timeout (default 10s), and a unified merged JSON output. For a list of 100 domains, the whole job finishes in under 2 minutes on Apify's standard compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code Example
&lt;/h2&gt;



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

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

&lt;span class="n"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stripe.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vercel.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fly.io&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;railway.app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;render.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/company-data-aggregator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whois&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;github&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tech_headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;robots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;npm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;favicon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timeout_per_source_s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: GH repos=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;github&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;repo_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MX=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mx_provider&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CDN=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;tech_headers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cdn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected output for the five targets above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stripe.com: GH repos=250, MX=Google Workspace, CDN=Cloudflare
vercel.com: GH repos=180, MX=Google Workspace, CDN=Vercel
fly.io: GH repos=120, MX=Google Workspace, CDN=Fastly
railway.app: GH repos=45, MX=Google Workspace, CDN=Cloudflare
render.com: GH repos=60, MX=Google Workspace, CDN=Cloudflare
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a glance: all five are modern infrastructure companies on Google Workspace, three on Cloudflare, one on Vercel's own CDN, one on Fastly. Stripe has 5x the public GitHub presence of Railway — consistent with their respective company sizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked Example: VC Associate Screens 100 AI-Infra Startups
&lt;/h2&gt;

&lt;p&gt;A VC associate at a seed-stage fund is screening 100 "AI infra" startups pulled from a Twitter thread, a Substack, and a conference attendee list. She needs to narrow to ~10 worth a deeper call. Manually visiting 100 sites is a full day.&lt;/p&gt;

&lt;p&gt;Instead, she feeds the list to the aggregator and gets back, per company:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tech stack sophistication&lt;/strong&gt;: AWS vs GCP vs self-hosted, Kubernetes usage (via &lt;code&gt;api.k8s.*&lt;/code&gt; patterns), Vercel (prototype-y) vs bare EC2 (more production-grade for infra plays).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team-size proxy&lt;/strong&gt;: GitHub member count + subdomain count. 5 subdomains and 3 GitHub members = probably 1–3 people. 30+ subdomains and 20+ members = 15+ people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Age&lt;/strong&gt;: WHOIS creation dates. Founded 2024 is a different conversation than founded 2019 with no traction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain footprint&lt;/strong&gt;: a production-maturity proxy. An "enterprise-ready" AI-infra startup with just &lt;code&gt;www.&lt;/code&gt; and &lt;code&gt;app.&lt;/code&gt; is pre-product. One with &lt;code&gt;api.&lt;/code&gt;, &lt;code&gt;docs.&lt;/code&gt;, &lt;code&gt;status.&lt;/code&gt;, &lt;code&gt;console.&lt;/code&gt;, plus regional endpoints, has real infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaaS stack&lt;/strong&gt;: TXT records reveal Segment, Intercom, Linear, Notion usage — commercial intent vs. pure research.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From 100 companies she narrows to 12 based on: founded 2022–2024, at least 10 GitHub public members OR 15+ subdomains, hosted on real cloud infra (not Wix/Webflow), and not already funded past seed (checked against Crunchbase Pro for the 12 finalists only — paying for 12 lookups is fine, 100 wasn't).&lt;/p&gt;

&lt;p&gt;Total cost: ~$5 of Apify credits + 12 Crunchbase Pro lookups (~$40). Total time: 45 minutes instead of a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Augmenting With Paid Sources Where Free Isn't Enough
&lt;/h2&gt;

&lt;p&gt;Free sources cover 60–70%. The rest requires paying. The honest hierarchy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenCorporates&lt;/strong&gt; (free tier, ~500 calls/month): incorporation records, legal entity names, officer lists. Genuinely useful for due diligence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEC EDGAR&lt;/strong&gt; (fully free): 10-K, 10-Q, 8-K, S-1 filings for US public companies. Unbeatable for public-company financials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clearbit&lt;/strong&gt; (RIP): acquired by HubSpot in 2023, rebranded as HubSpot Insights, free tier deprecated. Not a replacement anymore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PeopleDataLabs / Proxycurl&lt;/strong&gt;: LinkedIn-adjacent. Contact info, titles, seniority. Paid, cheaper per-record than Apollo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crunchbase Pro / PitchBook&lt;/strong&gt;: for funding data you cannot derive elsewhere. Use surgically on your shortlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BuiltWith Pro&lt;/strong&gt;: tech stack at scale without maintaining your own header parsing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The play: free aggregation screens the top of the funnel, paid data enriches the shortlist. That's how you replicate Crunchbase Basic workflows without $500/month.&lt;/p&gt;

&lt;h2&gt;
  
  
  OSINT Ethics and Legal Limitations
&lt;/h2&gt;

&lt;p&gt;All eight sources above are public data. That doesn't make bulk aggregation unconditionally fine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GDPR Article 14&lt;/strong&gt; covers "information from sources other than the data subject." Aggregating EU-company data with personal info about employees (e.g., GitHub names) technically requires notice. Enforcement tends to target bulk sellers, not internal tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CCPA&lt;/strong&gt; has similar provisions, plus specific rules around data brokers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State-level data-broker laws&lt;/strong&gt; (Vermont, Oregon, Texas, Delaware as of 2025) require registration if you re-sell aggregated personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crawl politeness&lt;/strong&gt;: honor robots.txt, respect Crawl-Delay, use a User-Agent that identifies your bot with a contact email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good practice: use aggregated data for internal research, don't re-sell it as a "contact list," delete on request, and don't merge it with contact-level data (emails, phone numbers) without explicit consent pathways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;Things that will bite you at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CDN-masked origins&lt;/strong&gt;. Behind Cloudflare, you see Cloudflare, not the real origin. Workarounds: historical DNS records (securitytrails, viewdns.info), or MX records which usually sit on the real infra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WHOIS privacy&lt;/strong&gt;. GoDaddy redacts by default on all new .com registrations post-2018. You'll see "Redacted for Privacy" a lot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub orgs that hide members&lt;/strong&gt;. Most enterprises have private membership. Member count will be &lt;code&gt;null&lt;/code&gt; or drastically underreported. Repo count is more reliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm under personal names&lt;/strong&gt;. Founders publish early libraries under personal handles and never migrate. Search by &lt;code&gt;repository.url&lt;/code&gt; pointing at the company's GitHub org to catch these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS resolver leakage&lt;/strong&gt;. Querying 8.8.8.8 for 10,000 targets tells Google exactly who you're researching. For sensitive OSINT, use DoH/DoT through a resolver you control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt;. crt.sh 429s around 10 req/sec. GitHub: 5,000/hour authenticated. WHOIS varies wildly per TLD. The aggregator handles backoff per-source; roll-your-own needs to plan for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CT noise&lt;/strong&gt;. crt.sh returns every cert including expired, pre-cert, and duplicates. Dedupe by SAN, not cert serial.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is this legal?&lt;/strong&gt;&lt;br&gt;
Aggregating public WHOIS, DNS, CT logs, GitHub, npm, and HTTP headers is legal everywhere we're aware of. &lt;em&gt;Re-selling&lt;/em&gt; aggregated data combined with personal info crosses into data-broker regulation. Stay on the internal-research side and you're fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often does the data update?&lt;/strong&gt;&lt;br&gt;
DNS propagates in minutes. WHOIS updates weekly at registrars. CT logs are near-real-time. GitHub and npm are real-time. Re-run the aggregator weekly for fresh data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this for lead generation?&lt;/strong&gt;&lt;br&gt;
For targeting and qualification: yes (this is what BuiltWith/HG Insights are for). For cold-email lists with personal info: no, not without a separate consent-compliant enrichment step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does accuracy compare to Crunchbase?&lt;/strong&gt;&lt;br&gt;
Domain, tech stack, hosting, CDN, subdomain count, GitHub metrics: more accurate (Crunchbase doesn't even track most of this). Funding, employee counts, investors: drastically less accurate — Crunchbase wins and it's not close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if the company uses Cloudflare — do I get real IPs?&lt;/strong&gt;&lt;br&gt;
No. Cloudflare hides the origin. Infer it from historical DNS (viewdns.info, securitytrails free tier) or non-proxied subdomains like mail and staging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I combine this with Apollo/Hunter for contact enrichment?&lt;/strong&gt;&lt;br&gt;
Run the aggregator first for company-level profiles. Pass qualified domains to Apollo/Hunter for person-level enrichment. Much cheaper than running Apollo on your full list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run it on 10,000 domains at once?&lt;/strong&gt;&lt;br&gt;
Yes. At 10 concurrent with 10s per-source timeouts, 10,000 domains take ~3 hours and about $15–25 in Apify credits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where's the rate-limit bottleneck?&lt;/strong&gt;&lt;br&gt;
Usually crt.sh and WHOIS. GitHub is generous if authenticated. DNS has no practical limit with a good resolver. The aggregator backs off per-source independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Crunchbase killing its free Basic API hurt, but it wasn't fatal. Roughly two-thirds of what it provided is reconstructable from public WHOIS, DNS, CT logs, GitHub, npm, HTTP headers, robots/sitemap, and favicon/OG metadata. The rest — funding rounds, exact headcount, investor lists — still requires paid data, but you can now pay for it surgically on a pre-qualified shortlist instead of blanket-licensing it for your entire pipeline.&lt;/p&gt;

&lt;p&gt;If you want the ready-made version rather than wiring up eight scrapers yourself, try the &lt;a href="https://apify.com/nexgendata/company-data-aggregator?fpr=2ayu9b" rel="noopener noreferrer"&gt;company-data-aggregator&lt;/a&gt; on Apify — it handles parallel fanout, per-source error isolation, rate-limit backoff, and unified JSON output across all eight sources. Pay per run, not per month.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>crunchbase</category>
      <category>companydata</category>
      <category>osint</category>
    </item>
    <item>
      <title>Alexa Rank Is Dead. Here's What Replaces It in 2026 (Free API)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:03:03 +0000</pubDate>
      <link>https://dev.to/nexgendata/alexa-rank-is-dead-heres-what-replaces-it-in-2026-free-api-3n34</link>
      <guid>https://dev.to/nexgendata/alexa-rank-is-dead-heres-what-replaces-it-in-2026-free-api-3n34</guid>
      <description>&lt;h1&gt;
  
  
  Alexa Rank Is Dead. Here's What Replaces It in 2026 (Free API)
&lt;/h1&gt;

&lt;p&gt;On May 1, 2022, Amazon shut down Alexa.com. For roughly 25 years it had been the default shorthand for "how big is this site" — a single integer between 1 and ~30 million that every SEO deck and media kit cited as if it were ground truth. Then it was gone. Nearly four years later, in 2026, we still don't have a direct replacement.&lt;/p&gt;

&lt;p&gt;Instead, the space fragmented. If you want to know how popular a domain is today, you pick from five or six datasets with different methodologies, coverage, cadences, and prices. Some are free but cover only the top 10,000. Some are paywalled at $1,000+ per month. Some aren't really "rank" at all — they're Core Web Vitals dressed up in a ranking shirt.&lt;/p&gt;

&lt;p&gt;This post is a practical guide for people who still need a number to plug into a spreadsheet: SEO analysts, competitive intelligence teams, affiliate marketers, VCs doing diligence, and compliance teams deduping blocklists. It explains what replaced Alexa Rank, why Tranco is the closest true successor for most free-tier workflows, and how to pull Tranco ranks via an Apify actor we maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters (The Gap Alexa Left Behind)
&lt;/h2&gt;

&lt;p&gt;The absence of a free, open, broadly-covered ranking API creates real friction in workflows that used to run on autopilot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Affiliate marketers&lt;/strong&gt; negotiating ad rates need a third-party popularity signal that publishers can't self-report. "We're a top 50,000 site" used to be an objective claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VCs and M&amp;amp;A analysts&lt;/strong&gt; sizing up a target want a sanity check on claimed traffic numbers. A founder can hand you a Google Analytics screenshot; a Tranco rank is harder to forge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO agencies&lt;/strong&gt; doing competitive audits need to rank a client against peers. Without a common yardstick, every comp analysis turns into a subjective argument about which paid tool to trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Journalists&lt;/strong&gt; fact-checking "the #X site on the internet" claims need a citable source. Alexa was a Wikipedia-grade reference; its absence has degraded the quality of such citations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance teams&lt;/strong&gt; deduping domain blocklists by popularity want to know whether &lt;code&gt;example.cn&lt;/code&gt; is a global top-10k domain before they sinkhole it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security researchers&lt;/strong&gt; studying phishing rely on "popularity of the impersonated domain" as a feature in detection models. Alexa was the default corpus in hundreds of papers until 2019.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these workflows died when Alexa died. They just got worse, hackier, and more expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Grounding Numbers
&lt;/h2&gt;

&lt;p&gt;A quick reality check on scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alexa Rank at peak&lt;/strong&gt; tracked ~30 million domains, daily updates from toolbar telemetry and crawl data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tranco&lt;/strong&gt; covers a top 1 million list, updated daily, aggregated from four independent sources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Radar&lt;/strong&gt; exposes a ranked API but caps at the top 10,000 globally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Similarweb&lt;/strong&gt; tracks 100 million+ domains with engagement metrics, API starts at ~$1,000/month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrUX&lt;/strong&gt; covers millions of origins but surfaces Core Web Vitals (LCP, CLS, etc.) rather than rank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenPageRank&lt;/strong&gt; is still online but deprecated; scores haven't refreshed in years.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The closest direct substitute for Alexa — daily-updated, programmatically accessible, free, with hundreds of thousands of domains — is Tranco. Everything else covers a narrower slice, charges a lot more, or measures something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: The Five Alexa Alternatives That Actually Matter
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Update Cadence&lt;/th&gt;
&lt;th&gt;Primary Signal&lt;/th&gt;
&lt;th&gt;Commercial Use?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tranco&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Top 1M&lt;/td&gt;
&lt;td&gt;Daily (30-day averaged)&lt;/td&gt;
&lt;td&gt;DNS queries + browser telemetry + web graph&lt;/td&gt;
&lt;td&gt;Yes, with attribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Similarweb API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$1,000+/mo&lt;/td&gt;
&lt;td&gt;100M+&lt;/td&gt;
&lt;td&gt;Monthly aggregates&lt;/td&gt;
&lt;td&gt;Panel + ISP + scraping&lt;/td&gt;
&lt;td&gt;Yes (commercial license)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Radar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free API&lt;/td&gt;
&lt;td&gt;Top 10k&lt;/td&gt;
&lt;td&gt;Daily&lt;/td&gt;
&lt;td&gt;1.1.1.1 DNS resolver traffic&lt;/td&gt;
&lt;td&gt;Yes (fair use)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CrUX BigQuery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free (GCP egress)&lt;/td&gt;
&lt;td&gt;Millions of origins&lt;/td&gt;
&lt;td&gt;Monthly&lt;/td&gt;
&lt;td&gt;Real-user Chrome telemetry&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SEMrush / Ahrefs Rank&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$100-$500+/mo&lt;/td&gt;
&lt;td&gt;Tens of millions&lt;/td&gt;
&lt;td&gt;Weekly-ish&lt;/td&gt;
&lt;td&gt;Organic traffic estimates&lt;/td&gt;
&lt;td&gt;Yes (commercial tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few calls on when each makes sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pure "how big is this site" for free?&lt;/strong&gt; Tranco.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need engagement metrics (visits, bounce rate, avg session duration)?&lt;/strong&gt; Similarweb. There is no free equivalent that is remotely close.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only care about the top few thousand globally famous domains?&lt;/strong&gt; Cloudflare Radar is fine and often fresher than Tranco.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimizing Core Web Vitals and want a popularity weight so you don't waste budget on obscure origins?&lt;/strong&gt; CrUX.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doing keyword-level SEO competitive work?&lt;/strong&gt; Ahrefs/SEMrush — but note their "rank" is traffic-weighted, not popularity-weighted, and will disagree with Tranco on many domains.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tranco in Depth
&lt;/h2&gt;

&lt;p&gt;Tranco deserves its own section because it's the one most people should start with, and because its methodology is unusually well-documented.&lt;/p&gt;

&lt;p&gt;Tranco was introduced in a 2019 NDSS paper by Le Pochat et al.: &lt;em&gt;"Tranco: A Research-Oriented Top Sites Ranking Hardened Against Manipulation."&lt;/em&gt; The motivating observation was that every public domain ranking at the time — Alexa, Cisco Umbrella, Majestic Million, Quantcast — was manipulable, volatile, or both. Security researchers were publishing results on "the Alexa top 1M" that didn't reproduce a week later because the underlying list had churned by 30%.&lt;/p&gt;

&lt;p&gt;Tranco fixes this by doing two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Aggregating four independent sources&lt;/strong&gt; using Dowdall's rule, a positional voting method that weights top positions more heavily than tail positions. The four sources are:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cisco Umbrella 1M&lt;/strong&gt; — ranks based on DNS queries to Cisco's OpenDNS resolvers (billions/day).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome User Experience Report&lt;/strong&gt; — real-user browser telemetry from opted-in Chrome users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Majestic Million&lt;/strong&gt; — based on the number of unique referring subnets linking to a domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Farsight DNSDB&lt;/strong&gt; — passive DNS observations across a large sensor network.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Applying a 30-day rolling average&lt;/strong&gt; so a domain's rank reflects sustained popularity rather than a single-day spike. This makes Tranco very hard to "game" by flooding DNS queries for a day — the primary failure mode of Cisco Umbrella and Alexa.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tranco gives you an integer between 1 and 1,000,000 for the vast majority of domains you'd care about, updated every 24 hours, via a stable list ID you can pin for reproducibility.&lt;/p&gt;

&lt;p&gt;Main caveats: global aggregate only (no country sub-rankings), long-tail cutoff at 1M (anything below doesn't appear), and a ~24-hour publishing lag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tranco-rank-lookup Actor
&lt;/h2&gt;

&lt;p&gt;Tranco itself publishes CSV files and a simple HTTP endpoint, but the ergonomics for day-to-day work are rough: you have to download the daily list, parse 1M rows, do your own historical diffing, and write your own similar-domain logic if you want peer suggestions.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://apify.com/nexgendata/tranco-rank-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;tranco-rank-lookup&lt;/a&gt; on Apify to wrap all of that into a single actor call. It does three things on top of the raw Tranco data:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Point-in-time and historical ranks.&lt;/strong&gt; Pass a list of domains, get back current rank plus a 30-day history series per domain. Useful for MoM deltas without managing your own storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Similar-domain suggestions.&lt;/strong&gt; For each input domain, surface a curated list of peer domains in the same category band. (Honest caveat: the similarity path uses a curated category mapping today rather than parsing the full 1M CSV for nearest-rank neighbors — see limitations below.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk lookup with caching.&lt;/strong&gt; The actor caches daily snapshots so repeat calls in the same day don't re-download the full Tranco list. For a 1,000-domain comp set this takes a run from minutes to seconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────┐    ┌───────────────────────┐    ┌─────────────────────┐    ┌────────────────────┐
│  Domain list  │───▶│  tranco-rank-lookup   │───▶│  ranks + history +  │───▶│  BigQuery / Sheets │
│ (CSV / array) │    │   (Apify actor)       │    │  similar domains    │    │  / Airtable sink   │
└───────────────┘    └───────────────────────┘    └─────────────────────┘    └────────────────────┘
                              │
                              ▼
                     ┌──────────────────┐
                     │  Tranco CSV +    │
                     │  30-day cache    │
                     └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sink layer is optional — you can read from the dataset directly via the Apify client — but most teams push to a warehouse or a Google Sheet for dashboarding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Code Example
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;competitors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;competitor-a.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;competitor-b.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;competitor-c.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/tranco-rank-lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;competitors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history_days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_similar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: rank &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;current_rank&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (Δ30d: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rank_delta_30d&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output for a typical comp run 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;competitor-a.com: rank 47,812 (Δ30d: -3,104)
competitor-b.com: rank 112,490 (Δ30d: +18,772)
competitor-c.com: rank 9,988 (Δ30d: -214)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Negative deltas mean the rank number got smaller — i.e., the domain moved up. This convention matches Alexa's original interpretation and is what most analysts expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked Example: SEO Agency Quarterly Competitive Brief
&lt;/h2&gt;

&lt;p&gt;A realistic use case: an SEO agency is preparing a quarterly brief for a direct-to-consumer skincare client with ~50 meaningful competitors. They need to identify who's gaining ground, who's losing it, and where to focus next quarter's content investment.&lt;/p&gt;

&lt;p&gt;Pre-2022 this would have been a 20-minute job in Alexa. Post-2022 it's been a recurring headache — the agency was paying for Similarweb just to run this one report.&lt;/p&gt;

&lt;p&gt;With Tranco via the actor, the flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define the comp set.&lt;/strong&gt; A Google Sheet with ~50 domains tagged by category (direct / aspirational / marketplace).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the actor weekly.&lt;/strong&gt; A scheduled Apify run pulls current rank plus 30-day history; results land in BigQuery via a small ETL step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute deltas in SQL.&lt;/strong&gt; MoM rank delta, QoQ delta, and a volatility score (stdev of daily rank).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surface the outliers.&lt;/strong&gt; In the most recent quarter the agency identifies:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3 fast risers&lt;/strong&gt; whose rank improved by &amp;gt;500,000 positions MoM. All three launched sustained influencer campaigns in Q1 — confirmed by cross-referencing Facebook Ad Library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 decliners&lt;/strong&gt; whose rank dropped by &amp;gt;1,000,000 positions. One was acquired and had traffic redirected to the parent brand; the other had a technical SEO regression after a site migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5 steady-state leaders&lt;/strong&gt; whose rank fluctuated by &amp;lt;5,000 positions — durable category leaders worth benchmarking against.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the brief.&lt;/strong&gt; The deck leads with a competitive landscape chart (rank over time for the top 10 comps), drills into each fast riser's tactics, and recommends content and paid investments.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole pipeline costs ~$3-5/month in Apify compute plus a trivial BigQuery bill, versus $1,000+/month for Similarweb. Operational note: the 30-day trend is more useful than any single-day rank. Single-day Tranco rank is noisy for mid-tail domains (200k-800k). Show trend lines, not point estimates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Alexa Rank Measured vs What Tranco Measures
&lt;/h2&gt;

&lt;p&gt;A common trap when migrating from Alexa to Tranco is assuming the two numbers are interchangeable. They are not, and the methodological differences explain most of the disagreements you'll see when you look up a domain in both services (via Alexa's historical archives).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alexa's signal&lt;/strong&gt; was primarily the Alexa Toolbar, which created two well-known biases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;English-speaking, technical power users.&lt;/strong&gt; The toolbar was disproportionately installed by SEO professionals and webmasters. Sites popular with those audiences ranked higher than real global traffic justified; sites popular with non-English audiences ranked lower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manipulation via toolbar botnets.&lt;/strong&gt; Each install contributed a meaningful fraction of observed traffic for long-tail domains. A coordinated install campaign could push an Alexa rank up by hundreds of thousands of positions — a visible cottage industry from 2010-2016.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tranco's signal&lt;/strong&gt; is fundamentally different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authoritative DNS query volume&lt;/strong&gt; (Cisco Umbrella, Farsight) measures how often anyone resolves a domain, regardless of browser or language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chrome User Experience Report&lt;/strong&gt; adds real-user browser telemetry at massive scale — Chrome has ~65% global browser share.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web graph data&lt;/strong&gt; (Majestic) captures importance via unique referring subnets, which is slower-moving and harder to spoof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30-day averaging&lt;/strong&gt; defeats single-day manipulation. Sustained attack over a month stops being manipulation and starts being actual traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practically: domains inflated on Alexa because they catered to Western webmasters have more conservative Tranco ranks. Domains serving non-English audiences tend to rank higher on Tranco than on Alexa. Migrating a historical report from Alexa to Tranco numbers? Expect some of the "rankings changed" to be "measurement got better," not real movement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other Sources Worth Combining with Tranco
&lt;/h2&gt;

&lt;p&gt;Tranco is the spine, but for a complete competitive-intelligence stack you'll want to pair it with one or two of these:&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare Radar API
&lt;/h3&gt;

&lt;p&gt;Free, rate-limited. Covers the top 10,000 domains based on 1.1.1.1 DNS resolver traffic. Value-add over Tranco is &lt;strong&gt;traffic category data&lt;/strong&gt; — share-of-traffic breakdowns by category over time, useful for "is this category growing?" questions. Useless below top 10k.&lt;/p&gt;

&lt;h3&gt;
  
  
  CrUX (via BigQuery or the API)
&lt;/h3&gt;

&lt;p&gt;Free with GCP egress costs. Not a rank source, but the &lt;code&gt;chrome-ux-report.all.origin&lt;/code&gt; table tells you which domains are in the dataset — a coarse popularity threshold. Also gives Core Web Vitals per domain for technical-SEO benchmarking.&lt;/p&gt;

&lt;h3&gt;
  
  
  OpenPageRank
&lt;/h3&gt;

&lt;p&gt;Historical archive only. Useful for pre-2022 longitudinal studies. Don't use it for anything current.&lt;/p&gt;

&lt;h3&gt;
  
  
  DataForSEO
&lt;/h3&gt;

&lt;p&gt;Paid but pennies per lookup instead of thousands per month. Reasonable middle ground between free Tranco and enterprise Similarweb if you need commercial-grade data at scale without engagement metrics.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ahrefs Rank
&lt;/h3&gt;

&lt;p&gt;Traffic-weighted organic search rank. Conceptually different from Tranco — a domain can rank well on Tranco (direct/DNS traffic) while ranking poorly on Ahrefs (little organic search), and vice versa. Use Ahrefs when the question is specifically about SEO performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations of Any Ranking Approach
&lt;/h2&gt;

&lt;p&gt;Whichever source you pick, keep these caveats in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Regional bias.&lt;/strong&gt; Tranco is global-aggregate and under-weights country-concentrated traffic. A top-100 Indonesian site may rank in the hundreds of thousands on Tranco. Cloudflare Radar and CrUX publish country breakdowns; Tranco doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freshness lag.&lt;/strong&gt; Tranco publishes today's list tomorrow — 24-48 hour blind spot for viral spikes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-tail dropout.&lt;/strong&gt; Below rank 1M, no data. Hard cutoff. Niche players get partial coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot traffic contamination.&lt;/strong&gt; DNS methods include bot queries. Multi-source aggregation mitigates this but doesn't eliminate it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ecosystem gaps.&lt;/strong&gt; App-first sites (TikTok, Instagram) and aggressive Cloudflare-proxied domains are under-represented in web-graph signals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Report ranks as ranges or trends, not GPS coordinates.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Tranco legally usable for commercial purposes?
&lt;/h3&gt;

&lt;p&gt;Yes, with attribution. The Tranco list is distributed under a permissive academic license that explicitly allows commercial use. The standard practice is to cite the paper (Le Pochat et al., NDSS 2019) and include a link to the list URL used, along with the list ID for reproducibility. Talk to your counsel if you're building a paid product on top of it, but the license does not prohibit that.&lt;/p&gt;

&lt;h3&gt;
  
  
  How accurate is the 30-day history?
&lt;/h3&gt;

&lt;p&gt;The history reflects Tranco's own 30-day rolling aggregation, so each day's data point is already a smoothed value. For domains in the top 100k, day-over-day volatility is typically under 5%. For mid-tail domains (rank 200k-800k), daily noise is much larger — plus or minus 20-30k positions is normal. Trust the trend, not the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the actor cache results?
&lt;/h3&gt;

&lt;p&gt;Yes. The actor pulls the latest Tranco list once per day and caches it internally. Subsequent lookups in the same 24-hour window use the cached snapshot. This keeps runs fast and minimizes load on Tranco's infrastructure. If you need a specific historical list ID (for reproducible research), you can pass it via input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I pull the full 1M list?
&lt;/h3&gt;

&lt;p&gt;Not through the current actor input — it's designed for targeted lookups of a user-supplied domain set. If you need the full list, pull it directly from the Tranco website. We've considered adding a bulk-export mode; if you have a use case, open an issue on the actor page.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I convert Tranco rank to estimated traffic?
&lt;/h3&gt;

&lt;p&gt;You can't, cleanly. Rank and traffic are not linearly related — the top 10 sites have orders of magnitude more traffic than rank 100, which has orders of magnitude more than rank 10,000. Any published conversion heuristic (Zipf-based or otherwise) has huge error bars. If you need traffic numbers, pay for Similarweb or triangulate with SimilarWeb's free digital overview + Ahrefs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is there a free tier for the actor?
&lt;/h3&gt;

&lt;p&gt;Yes — Apify's free tier gives you enough compute to run small batches (up to a few hundred domains/month) without paying. Scale beyond that and you're in the low single digits of dollars per month for most realistic workloads. See the &lt;a href="https://apify.com/nexgendata/tranco-rank-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;actor page&lt;/a&gt; for current pricing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Cloudflare Radar compare for top-10 analysis?
&lt;/h3&gt;

&lt;p&gt;For the genuinely top-10 global domains (the Googles, YouTubes, Facebooks of the world), Cloudflare Radar is often more useful because it gives you traffic share percentages and category context. Tranco will just tell you Google is #1 — Radar tells you Google accounts for roughly X% of all DNS queries observed by 1.1.1.1 in the last 24 hours, broken down by category. For anything below the top 100, Tranco is better.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I just pay for Similarweb?
&lt;/h3&gt;

&lt;p&gt;When you need engagement metrics (sessions, session duration, pages per visit, bounce rate) alongside rank. Tranco gives you popularity; Similarweb gives you behavior. If your workflow requires "which of my competitors has stickier users?" or "how is on-site conversion behavior trending?" — Similarweb is the answer and there is no free substitute. Also worth it if you need country-level breakdowns at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Alexa Rank is dead, but the need it served — a single, quotable, third-party popularity number for any domain — is not. For 80% of the use cases that used to rely on Alexa, Tranco is the right successor: free, daily, methodologically defensible, and commercially usable. For the other 20% (engagement metrics, country-level depth, real-time data), you'll still end up paying someone.&lt;/p&gt;

&lt;p&gt;If you just want to get ranks into a spreadsheet without downloading CSVs or writing your own cache layer, grab the &lt;a href="https://apify.com/nexgendata/tranco-rank-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;tranco-rank-lookup actor&lt;/a&gt; and run the Python example above against your comp set. Most teams are running useful competitive dashboards within an afternoon, and the whole stack costs less per month than a single Similarweb seat.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>seo</category>
      <category>domainranking</category>
      <category>tranco</category>
    </item>
    <item>
      <title>Heroku Is Getting Expensive: A 2026 Cost Calculator + Migration Guide</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Tue, 01 Sep 2026 17:00:36 +0000</pubDate>
      <link>https://dev.to/nexgendata/heroku-is-getting-expensive-a-2026-cost-calculator-migration-guide-3837</link>
      <guid>https://dev.to/nexgendata/heroku-is-getting-expensive-a-2026-cost-calculator-migration-guide-3837</guid>
      <description>&lt;h1&gt;
  
  
  Heroku Is Getting Expensive: A 2026 Cost Calculator + Migration Guide
&lt;/h1&gt;

&lt;p&gt;Heroku is not dead. But for a non-trivial slice of its user base, it has crossed the line from "obviously worth it" to "we should probably run the numbers." This post is for that second group.&lt;/p&gt;

&lt;p&gt;A short timeline for context. In November 2022, Heroku killed its free tier, which had been the on-ramp for a generation of side projects and prototypes. What replaced it was Eco dynos at $5/month (shared across an account, sleeps after 30 minutes of inactivity) and Basic dynos at $7/month. The professional tiers stayed roughly where they were: Standard-1X at $25/month, Standard-2X at $50/month, Performance-M at $250/month, Performance-L at $500/month. Heroku Postgres got repriced into Essential-0 at $5/month (with 10k row limit), Essential-1 at $9/month, Essential-2 at $20/month, then Standard-0 at $50/month as the first "production" tier. Heroku Redis Mini is $15/month. Add a Papertrail add-on, a Scheduler, a few preview apps, and a staging environment, and a single-team SaaS is paying $200-400/month before anyone has written a line of code for scale.&lt;/p&gt;

&lt;p&gt;Salesforce's 2024 annual report pegs Heroku at roughly 9 million active apps and 13 million developer accounts. Stack Overflow's 2025 developer survey reports that 38% of current Heroku users are "planning or actively evaluating" a migration within 12 months, up from 24% in the 2023 survey. The migration market is real and Heroku knows it — the 2025 Heroku "Fir" runtime (Kubernetes-based, AMD64 + ARM, OCI images) was an acknowledgment that the cheaper, more flexible alternatives had pulled far enough ahead that the product needed a structural response, not just a pricing one.&lt;/p&gt;

&lt;p&gt;This post is a cost calculator and a migration guide. It is skeptical of the "Heroku is dead" framing — for a lot of teams Heroku is still the right answer — but it takes the bill seriously and does the actual math against Railway, Render, Fly.io, Cloudflare Workers + D1, and DigitalOcean App Platform. The companion tool is the &lt;a href="https://apify.com/nexgendata/heroku-cost-calculator?fpr=2ayu9b" rel="noopener noreferrer"&gt;heroku-cost-calculator&lt;/a&gt; Apify actor, which takes your current Heroku setup as input and spits out recommended targets, projected bills, and a migration outline.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Pricing data as of Q1 2026; check vendor pages for live rates before you commit to anything.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Heroku bills balloon
&lt;/h2&gt;

&lt;p&gt;The sticker prices above are misleading in two directions. Good news first: a single Basic dyno at $7/month with an Essential-0 Postgres at $5/month is genuinely $12/month, and for a toy SaaS that sees 100 requests a day, that is still an excellent deal. There is a reason Heroku exists.&lt;/p&gt;

&lt;p&gt;The bad news is that the bill structure is aggressively additive. A realistic "we have some paying customers" Heroku stack typically looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2x Standard-1X web dynos (for horizontal redundancy): $50/month&lt;/li&gt;
&lt;li&gt;1x Standard-1X worker dyno (Sidekiq, Celery, whatever): $25/month&lt;/li&gt;
&lt;li&gt;Heroku Postgres Standard-0: $50/month&lt;/li&gt;
&lt;li&gt;Heroku Redis Mini: $15/month&lt;/li&gt;
&lt;li&gt;Heroku Scheduler: $0/month (free, but limited to 10-minute precision and no retry logic)&lt;/li&gt;
&lt;li&gt;Papertrail Choklad plan: $7/month&lt;/li&gt;
&lt;li&gt;SSL for custom domains: included on Performance dynos, manual ACM setup on Standard, which most teams bypass by staying on *.herokuapp.com and eating the branding hit&lt;/li&gt;
&lt;li&gt;Staging environment (same shape as prod, scaled down): ~$75/month&lt;/li&gt;
&lt;li&gt;3 preview apps for PR reviews: 3 * ~$12 = $36/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Running total: $258/month, and we have not added Sentry, a mail provider, a CDN, or Heroku Connect. This is the "$200-400/month before you blink" scenario. And it scales roughly linearly for a while — two Performance-M dynos, a Standard-2 Postgres, and a Premium-0 Redis is closer to $800/month for the same architecture shape.&lt;/p&gt;

&lt;p&gt;The deeper problem is that Heroku's pricing is opaque at scale. You don't pay for bandwidth, but you do pay for dyno-hours. You don't pay for database connections, but you pay for row counts on Essential tiers. Add-ons are billed by a partner ecosystem with its own pricing logic. You can scrape together &lt;code&gt;heroku ps&lt;/code&gt; and &lt;code&gt;heroku pg:info&lt;/code&gt; output to build an accurate inventory, but very few teams have ever done it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternatives, by the numbers
&lt;/h2&gt;

&lt;p&gt;Here is the 2026 landscape for PaaS-style hosting, scoped to the "I just want to ship a web app + worker + database + Redis" use case.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Compute pricing&lt;/th&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Redis/KV&lt;/th&gt;
&lt;th&gt;Free tier&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Heroku&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$7-500/mo/dyno (tiered)&lt;/td&gt;
&lt;td&gt;Postgres $5-$3500+/mo&lt;/td&gt;
&lt;td&gt;$15-1400/mo&lt;/td&gt;
&lt;td&gt;None (Eco starts $5)&lt;/td&gt;
&lt;td&gt;Salesforce integrations, compliance, zero-ops premium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Railway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.000231/GB-hr RAM + $0.000463/vCPU-hr, usage-based&lt;/td&gt;
&lt;td&gt;Postgres included as a service (RAM+CPU metered)&lt;/td&gt;
&lt;td&gt;Redis same&lt;/td&gt;
&lt;td&gt;$5 credit on Hobby&lt;/td&gt;
&lt;td&gt;Rails, Django, Node — Heroku feel with better pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Render&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$7/mo Starter, $25/mo Standard, $85/mo Pro&lt;/td&gt;
&lt;td&gt;Postgres $7/mo (256MB) to $95/mo (4GB)&lt;/td&gt;
&lt;td&gt;Key-Value $10/mo+&lt;/td&gt;
&lt;td&gt;Free web services (spin down after 15 min idle)&lt;/td&gt;
&lt;td&gt;Predictable pricing, strong Docker support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fly.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0.0000022/s per shared-cpu-1x-256mb (~$1.94/mo), scales by machine&lt;/td&gt;
&lt;td&gt;Postgres via Fly Postgres, $1.94/mo baseline&lt;/td&gt;
&lt;td&gt;Upstash Redis via extensions&lt;/td&gt;
&lt;td&gt;3x shared-cpu-1x-256mb machines + 3GB storage free&lt;/td&gt;
&lt;td&gt;Global edge, low-latency, per-region replicas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Workers + D1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$5/mo Workers Paid + $0.50/M requests after 10M&lt;/td&gt;
&lt;td&gt;D1 Postgres-lite: $5/mo + $1/M reads, $1/M writes&lt;/td&gt;
&lt;td&gt;KV $0.50/M reads&lt;/td&gt;
&lt;td&gt;100k req/day + 5GB D1 storage free&lt;/td&gt;
&lt;td&gt;Stateless APIs, edge-first, cheap for spiky traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DigitalOcean App Platform&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic $5-12/mo, Pro $12-48/mo&lt;/td&gt;
&lt;td&gt;Managed Postgres $15/mo (1GB) to $60/mo+&lt;/td&gt;
&lt;td&gt;Managed Redis $15/mo+&lt;/td&gt;
&lt;td&gt;3 static sites free&lt;/td&gt;
&lt;td&gt;Teams who want DO's full stack (Droplets + Spaces nearby)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few honest caveats on the table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Railway&lt;/strong&gt; is usage-based, which is cheaper if your traffic is spiky and more expensive if you're always-on at high RAM. Do the math for your actual profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render&lt;/strong&gt;'s starter services are single-instance and restart on deploys with a few seconds of downtime. Not a deal-breaker, but not Heroku-parity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fly.io&lt;/strong&gt; is the cheapest at small scale, but their Postgres offering is "you're responsible for it" — more like a managed VM than a managed service. Production-grade HA Postgres on Fly requires real effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers + D1&lt;/strong&gt; is a completely different architecture, not a Heroku drop-in. You are rewriting from Rails/Django/Express to Workers-flavored JavaScript or Python. Massive wins for the right shape of app, total wall for the wrong shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean App Platform&lt;/strong&gt; is the closest to Heroku in feel, but their managed database minimums ($15/mo) are higher than the starter tiers on Render or Fly.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The tool this post describes is an Apify actor that takes a Heroku inventory, applies a cost model, and recommends a target. High level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+---------------------------+
|  Heroku app inventory     |
|  (dynos, DBs, add-ons,    |
|   team size, priority)    |
+------------+--------------+
             |
             v
+---------------------------+
|  heroku-cost-calculator   |
|  (Apify actor)            |
|                           |
|  - normalize inventory    |
|  - compute Heroku bill    |
|  - compute each target    |
|    bill (Railway, Render, |
|    Fly, Cloudflare, DO)   |
+------------+--------------+
             |
             v
+---------------------------+
|  Recommendation engine    |
|                           |
|  priority: cost | speed | |
|  ops-simplicity | scale   |
+------------+--------------+
             |
             v
+---------------------------+
|  Migration playbook       |
|  - target host + sizing   |
|  - projected monthly cost |
|  - step-by-step commands  |
|  - gotchas for this stack |
+---------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calculator is not trying to replace your engineering judgment. It's trying to replace the spreadsheet you would otherwise spend an afternoon building, and replace the half-informed recommendations you would otherwise get from a Reddit thread where the top comment mentions their SvelteKit side project.&lt;/p&gt;

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

&lt;p&gt;The actor is public and free to run on Apify's platform. You can invoke it from the UI or from any Apify SDK. Here is the Python version.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/heroku-cost-calculator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web_dynos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Standard-1X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker_dynos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Standard-1X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres_tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Standard-0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis_tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preview_apps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;team_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cost&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__next__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;priority&lt;/code&gt; parameter is the load-bearing one. Options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cost&lt;/code&gt; — minimize monthly bill, willing to absorb more ops work&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;simplicity&lt;/code&gt; — minimize ops work (Heroku-like feel), willing to pay more&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;speed&lt;/code&gt; — prioritize performance / global latency (tends toward Fly.io)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;scale&lt;/code&gt; — prioritize headroom for 10x growth (tends toward DO or self-managed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Output includes the computed Heroku bill, a per-provider bill for the equivalent setup, a recommended target based on priority, and a migration outline specific to that target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example: typical Rails/Django prod+staging
&lt;/h2&gt;

&lt;p&gt;Let's run a real-shaped app through it. Our hypothetical SaaS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prod:&lt;/strong&gt; 2x Standard-1X web + 1x Standard-1X worker = $75/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres:&lt;/strong&gt; Standard-0 = $50/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis:&lt;/strong&gt; Mini = $15/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduler:&lt;/strong&gt; free&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview apps:&lt;/strong&gt; 3 (ephemeral, auto-created for each PR) ~ $12/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging:&lt;/strong&gt; 1x Basic web + 1x Basic worker + Essential-1 Postgres = ~$25/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Monthly Heroku bill: $177/month, or ~$2,120/year. Add a Papertrail plan at $7/month, a Sentry Business at $26/month, and Bugsnag at $40/month (common defaults) and the full prod stack is $250/month. For this example, we'll stick to just what Heroku bills ($177) to keep the comparison apples-to-apples.&lt;/p&gt;

&lt;p&gt;Running the calculator with &lt;code&gt;priority: "cost"&lt;/code&gt;:&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;"heroku_monthly_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;177&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"targets"&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;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"railway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"monthly_usd_estimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Usage-based; estimate assumes 60% CPU, 512MB avg per service."&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;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fly.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"monthly_usd_estimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;54&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Requires managing Fly Postgres HA manually for prod-grade setup."&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;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"render"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"monthly_usd_estimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cleanest Heroku-feel; preview apps built in."&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;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"digitalocean"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"monthly_usd_estimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;108&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Managed Postgres minimum is $15/mo; raises baseline."&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;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cloudflare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"monthly_usd_estimate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_applicable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rails app does not map to Workers runtime; rewrite required."&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;"recommended"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"railway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"recommended_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Closest Heroku feel + ~61% cost reduction vs current. Fly.io is cheaper but requires more ops investment on Postgres HA."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projected_savings_monthly_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;109&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"projected_savings_annual_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1308&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"migration_outline"&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;"Export Heroku config: heroku config -s --app myapp &amp;gt; .env.prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Export Postgres: heroku pg:backups:capture --app myapp &amp;amp;&amp;amp; heroku pg:backups:download"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Convert Procfile web + worker processes to railway.json services"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Provision Railway Postgres and Redis services"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Restore Postgres dump: psql $RAILWAY_DATABASE_URL &amp;lt; latest.dump"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Point staging DNS at Railway domain; smoke-test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Cut over prod DNS with 60s TTL pre-lowered 24h in advance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Monitor for 7 days; decommission Heroku after sign-off"&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;The recommendation is Railway with a ~61% cost reduction. Fly.io would be cheaper again, but the calculator flags the Postgres HA cost-in-engineering-time and down-weights it when &lt;code&gt;priority&lt;/code&gt; is cost (not &lt;code&gt;scale&lt;/code&gt; or &lt;code&gt;simplicity&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;If we rerun with &lt;code&gt;priority: "simplicity"&lt;/code&gt;, Render moves to the top — preview apps are built-in, the deploy feel is closest to Heroku, and the ops burden is lowest. If we rerun with &lt;code&gt;priority: "speed"&lt;/code&gt;, Fly.io wins because of its multi-region primitives. The point of the priority parameter is that the calculator will not pretend there is a single right answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas when actually migrating
&lt;/h2&gt;

&lt;p&gt;A cost estimate is the easy part. The actual migration has a dozen small traps. In rough order of appearance:&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS cutover
&lt;/h3&gt;

&lt;p&gt;Lower your DNS TTL to 60 seconds at least 24-48 hours before the cutover. Heroku's DNS for custom domains requires a CNAME (you don't get an A record because the edge IP rotates), which is fine unless you are using a naked apex domain with a provider that doesn't support ALIAS or ANAME. Cloudflare's CNAME flattening solves this on Cloudflare DNS. Route 53 handles it with ALIAS records. Classic BIND does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment variables
&lt;/h3&gt;

&lt;p&gt;Export with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;heroku config &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--app&lt;/span&gt; myapp &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env.prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-s&lt;/code&gt; flag gives you &lt;code&gt;KEY=value&lt;/code&gt; lines, which most target hosts can import directly (Railway has a paste-in env import, Render does too, Fly.io uses &lt;code&gt;fly secrets import &amp;lt; .env.prod&lt;/code&gt;). Don't forget to rotate anything that was leaked to logs or build output during the migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Buildpack to Dockerfile
&lt;/h3&gt;

&lt;p&gt;Most targets strongly prefer (Railway, Render, Fly.io, DO) or require (Cloudflare Workers) explicit build definitions rather than Heroku buildpacks. The &lt;a href="https://github.com/heroku/builder" rel="noopener noreferrer"&gt;heroku/builder&lt;/a&gt; project will still produce an OCI image from a Heroku-style buildpack stack, and both Railway and Render will auto-detect Rails/Django/Node and produce a reasonable Dockerfile-equivalent for you. But for anything non-trivial (native gem compilation, custom system packages, specific Python versions), plan on writing a real Dockerfile. It is an afternoon of work, and you will be glad to have it under source control afterward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Postgres dump and restore
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;heroku pg:backups:capture&lt;/code&gt; then &lt;code&gt;heroku pg:backups:download&lt;/code&gt; gives you a &lt;code&gt;latest.dump&lt;/code&gt; pg_dump archive. Restore with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_restore &lt;span class="nt"&gt;--verbose&lt;/span&gt; &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-h&lt;/span&gt; TARGET_HOST &lt;span class="nt"&gt;-U&lt;/span&gt; TARGET_USER &lt;span class="nt"&gt;-d&lt;/span&gt; TARGET_DB latest.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--no-acl --no-owner&lt;/code&gt; flags are critical — Heroku's Postgres creates roles and grants that don't exist on the target, and pg_restore will error loudly without those flags. For very large databases (&amp;gt;20GB), pg_dump is slow and &lt;code&gt;pgcopyonly&lt;/code&gt; or a logical replication approach (Bucardo, pglogical, or a native PG16+ logical subscription) is worth the setup cost to get a zero-downtime cutover.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add-on replacements
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Heroku add-on&lt;/th&gt;
&lt;th&gt;Common replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Heroku Redis&lt;/td&gt;
&lt;td&gt;Upstash Redis (serverless, pay-per-request) or provider-managed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Papertrail&lt;/td&gt;
&lt;td&gt;Loki + Grafana (self-host) or Better Stack / Axiom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;Sentry (portable, stays)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bugsnag&lt;/td&gt;
&lt;td&gt;Bugsnag (portable, stays)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;SendGrid, Postmark, Resend (portable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heroku Scheduler&lt;/td&gt;
&lt;td&gt;Railway Cron, Render Cron, Fly.io machines + schedules, GitHub Actions cron&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heroku Connect&lt;/td&gt;
&lt;td&gt;No good replacement; see the "staying on Heroku" section&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The portable add-ons (Sentry, Bugsnag, SendGrid, Datadog, New Relic) migrate trivially — they're just API keys. The Heroku-specific ones (Heroku Redis, Heroku Postgres, Heroku Scheduler, Heroku Connect) need replacements, and Heroku Connect is the hardest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduled jobs
&lt;/h3&gt;

&lt;p&gt;Heroku Scheduler supports 10-minute, hourly, and daily jobs. Railway Cron and Render Cron both support full cron expressions. Fly.io doesn't have a native scheduler product but lets you use machines with schedules — it's a few lines of &lt;code&gt;fly.toml&lt;/code&gt;. For the occasional "run this once a day" job, GitHub Actions cron is free and dead simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worker queues
&lt;/h3&gt;

&lt;p&gt;If you're using Sidekiq or Celery with Heroku Redis, plan to do the queue drain carefully. Stop enqueueing new jobs, let the queue empty on the old environment, then cut over. Running two workers pointing at two Redis instances is how you end up double-processing payments.&lt;/p&gt;

&lt;h2&gt;
  
  
  When staying on Heroku still makes sense
&lt;/h2&gt;

&lt;p&gt;I have spent most of this post explaining why teams leave Heroku. Here is the honest counter-case, because there are still real reasons to stay.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Salesforce integrations.&lt;/strong&gt; If your app is deeply tied to Salesforce via Heroku Connect (bi-directional data sync between Postgres and Salesforce objects), there is no clean replacement. You can build your own with the Salesforce Bulk API, but "build your own Heroku Connect" is a project, not a sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance-heavy workloads.&lt;/strong&gt; Heroku Shield (HIPAA, PCI-DSS high-trust, SOC 2 Type 2 with audit trails) is a real product. The alternatives have compliance stories (Fly.io has HIPAA tiers, Render has SOC 2), but if you already have your BAA signed and your QSA comfortable with Heroku, the cost of re-auditing is not trivial. The dollar savings on compute rarely justify a new compliance workstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-ops as a stated value.&lt;/strong&gt; Some teams — often small, profitable, non-technical-founder teams — assign real value to "we never touch the infrastructure." If your company is profitable, you have 5 engineers, and your Heroku bill is $500/month, the migration is probably not worth the engineering time. $500/month is less than a week of engineer salary. The calculator surfaces this: if &lt;code&gt;priority: "simplicity"&lt;/code&gt; and the savings are under ~$200/month, the recommendation string is "probably stay on Heroku and revisit in 12 months."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early-stage apps with very bursty traffic.&lt;/strong&gt; Eco dynos ($5/month, sleep when idle) are still an excellent deal for something you want to keep online but aren't actively promoting. The free-tier equivalents on Fly.io and Cloudflare are also good, but if you already have an account, inertia is fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no shame in staying on Heroku. There is also no moral victory in leaving. Do the math.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does the calculator include Performance tiers?
&lt;/h3&gt;

&lt;p&gt;Yes. It models Performance-M ($250/month) and Performance-L ($500/month) dynos, and for each it computes equivalent targets. For Performance-L especially, the savings are larger in absolute dollars but smaller in percentage — at that tier you're competing against less-abstracted infrastructure (DO Droplets, AWS ECS) where Heroku's margin is still defensible.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Heroku Connect pricing?
&lt;/h3&gt;

&lt;p&gt;Heroku Connect pricing starts at $2,100/month for the Standard tier (5M rows, 10 mappings) and climbs fast. The calculator treats Heroku Connect as a "sticky" component — if your inventory includes it, the recommendation is weighted heavily toward "stay on Heroku or plan a multi-quarter replacement project." There is no drop-in replacement at the PaaS layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Railway compare to Fly.io for Rails apps?
&lt;/h3&gt;

&lt;p&gt;Both work. Railway is closer to Heroku's mental model: you push code, it builds, it runs. Fly.io asks you to think about machines, regions, and a Fly-specific &lt;code&gt;fly.toml&lt;/code&gt;. For a standard Rails app with a single region and a standard Postgres, Railway is less friction. For a Rails app where you actually care about edge latency (global users), Fly.io's region primitives are better. Cost-wise Fly.io is usually cheaper at the same spec, but you'll pay back some of that savings in ops time unless your team already knows the Fly.io mental model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Cloudflare Workers viable for a Postgres-backed Django app?
&lt;/h3&gt;

&lt;p&gt;Not really. Workers runs a V8 isolate or Python WASI runtime with strict CPU-time limits, and Django's synchronous ORM is a poor fit. You can proxy Postgres through Hyperdrive (Cloudflare's connection pooler) and run a subset of Django in Python Workers, but realistically you are rewriting to Workers-native patterns. Workers + D1 is a superb fit for new projects designed for that architecture — edge APIs, stateless services, JAMstack backends. It is a poor fit for existing monoliths.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Supabase for the DB only?
&lt;/h3&gt;

&lt;p&gt;Supabase Postgres is legitimately one of the best-priced managed Postgres options in 2026. Pro tier at $25/month gives you 8GB storage, daily backups, and read replicas as an add-on. Many teams migrate just the database to Supabase and leave compute on Heroku or Railway, which often saves more than moving compute. The calculator has a &lt;code&gt;postgres_only_migration&lt;/code&gt; flag that models this scenario.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does pricing change with autoscaling?
&lt;/h3&gt;

&lt;p&gt;Heroku's autoscaling is only available on Performance dynos and adds complexity to the bill because you are paying for the peak of the scaling window. Railway's usage-based model and Fly.io's per-second billing are both more efficient for autoscaling workloads — you pay for what you used, not what you provisioned. The calculator has a &lt;code&gt;traffic_profile&lt;/code&gt; parameter (&lt;code&gt;steady&lt;/code&gt;, &lt;code&gt;business_hours&lt;/code&gt;, &lt;code&gt;spiky&lt;/code&gt;) that adjusts the target estimates for each provider's billing model. Spiky workloads see the largest relative savings on Railway and Fly.io.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I run the calculator for free?
&lt;/h3&gt;

&lt;p&gt;Yes. The actor uses Pay-Per-Event pricing with the first run free, and typical runs cost $0.00 to $0.02. You'll need an Apify account (free to create). The actor's input form is usable from the Apify web UI without any API keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where does the pricing data come from?
&lt;/h3&gt;

&lt;p&gt;The actor pulls published pricing from each provider's pricing page, normalizes it to a common schema (dollars per vCPU-hour, dollars per GB-RAM-hour, dollars per GB storage-month), and caches the normalized values. The cache is refreshed nightly and the actor output includes a &lt;code&gt;pricing_data_freshness&lt;/code&gt; timestamp. For enterprise pricing (Heroku Enterprise, Render Enterprise, Railway Pro), the calculator uses public list prices and flags the estimate as "enterprise discount not modeled." If you're on a negotiated contract, the real numbers on your side will be different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Heroku is not dead, but for a lot of teams it is quietly over-priced. Running the numbers for 20 minutes is worth doing at least once a year — and if the answer is "we'd save $100/month but it'd cost us $20k in engineering time," that is also a useful result. The &lt;a href="https://apify.com/nexgendata/heroku-cost-calculator?fpr=2ayu9b" rel="noopener noreferrer"&gt;heroku-cost-calculator&lt;/a&gt; is free to run and does the math in under a minute. Plug in your current setup, pick a priority, and see what the spreadsheet says before you spend a sprint on the migration.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>heroku</category>
      <category>migration</category>
      <category>costoptimization</category>
    </item>
    <item>
      <title>Court Records Research for Legal &amp; Due Diligence Workflows (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Thu, 27 Aug 2026 15:08:57 +0000</pubDate>
      <link>https://dev.to/nexgendata/court-records-research-for-legal-due-diligence-workflows-2026-29l9</link>
      <guid>https://dev.to/nexgendata/court-records-research-for-legal-due-diligence-workflows-2026-29l9</guid>
      <description>&lt;h1&gt;
  
  
  Court Records Research for Legal &amp;amp; Due Diligence Workflows (2026)
&lt;/h1&gt;

&lt;p&gt;When you are evaluating a company for acquisition, a candidate for a sensitive hire, or a counterparty for a multi-million-dollar contract, "have they been sued before?" is often the most informative single question you can ask. Public court records answer it — if you can extract them at scale.&lt;/p&gt;

&lt;p&gt;Some grounding numbers for 2026: PACER (the federal court records system) contains 1.2 billion documents across 450+ million dockets. State courts collectively file around 83 million new cases per year (National Center for State Courts, 2024 data), of which approximately 21 million are civil. Most of this is public, but less than 8% is freely searchable through any unified API. The DOJ's 2025 report on fraud prosecutions showed that private-sector litigation checks detect roughly 3.4x more red flags per target than a public-records-only check — but only when the search is both federal-and-state AND does proper name disambiguation. A superficial "Google the name" check catches about 12% of material cases. A PACER-only check catches federal but misses the bulk of state civil and all county matters. A real diligence pipeline crosses all three tiers, which is exactly what this post builds.&lt;/p&gt;

&lt;p&gt;The problem: US court records are scattered across PACER (federal), fifty state systems, and hundreds of county-level courts, each with their own auth scheme, rate limits, and data model. Pulling a comprehensive record for a single subject can take a paralegal half a day. Pulling it for 200 subjects (due-diligence scale) is a project that used to justify hiring a boutique diligence firm at $250/subject. In 2026 it becomes a scripted step that completes overnight and costs roughly 1/40th of that per subject.&lt;/p&gt;

&lt;p&gt;This post walks through how to build an automated court records research pipeline in 2026 using Apify, with use cases spanning M&amp;amp;A diligence, litigation tracking, KYC, and background checks. A brief note on framing: this is not about turning your laptop into a credit bureau. Any decision with FCRA implications (hiring, credit, insurance, housing) requires compliant processes, proper adverse-action procedures, and often a licensed CRA. What you can build is a research tool — a way to know what public records exist about an entity before you sign a contract, partner with a company, or fund a deal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No unified index.&lt;/strong&gt; PACER covers federal. State systems are individually gated. County courts often have terrible interfaces or none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PACER costs real money.&lt;/strong&gt; $0.10/page for search results, capped at $3.00 per document. 100 searches can cost $30+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name matching is fuzzy.&lt;/strong&gt; "John A. Smith" and "Smith, John A." and "J. A. Smith" are the same person — or three different people. Disambiguation requires DOB, location, or case context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication and CAPTCHAs.&lt;/strong&gt; Many state systems throw CAPTCHAs on anything that looks automated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance.&lt;/strong&gt; FCRA (in the US) restricts how court records can be used for employment or credit decisions. Your pipeline needs an audit trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sealed and expunged records.&lt;/strong&gt; Some states automatically seal juvenile, sealed, or expunged cases; others seal upon petition. Absence of a hit in a search does not mean absence of a case — it means absence of a visible case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical depth varies.&lt;/strong&gt; Some jurisdictions have digitized back to the 1980s; others only 2005+. For older cases, physical court-house archives may be the only option.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[List of subjects (name, location)]
          |
          v
 [court-records-search actor] --&amp;gt; federal + state + county hits
          |
          v
  [Dedup &amp;amp; entity resolution]
          |
          v
    [Case detail enrichment]
          |
          v
 [Postgres + audit log]
          |
          v
  [Report generator (PDF/CSV)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/court-records-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;court-records-search&lt;/strong&gt;&lt;/a&gt; actor abstracts over the major federal and state court systems, handles CAPTCHAs via residential proxies, and returns normalized JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Search by subject
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;subjects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Acme Holdings LLC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;John A. Smith&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dob_year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1978&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;person&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/court-records-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subjects&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subjects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jurisdictions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;federal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;case_types&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;civil&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criminal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bankruptcy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2015-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each case:&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;"subject_query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Holdings LLC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1:23-cv-04578"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"court"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"S.D.N.Y."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jurisdiction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"federal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case_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;"civil"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filed_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2023-08-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Terminated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"parties"&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="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"plaintiff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bright Path Partners"&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="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"defendant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Holdings LLC"&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;"causes_of_action"&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;"Breach of contract"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fraudulent inducement"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"disposition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Settled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pacer_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://ecf.nysd.uscourts.gov/..."&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;h2&gt;
  
  
  Step 2: Dedupe and disambiguate
&lt;/h2&gt;

&lt;p&gt;The same case may appear under both the plaintiff and the defendant name in your result set. Dedupe on &lt;code&gt;case_number&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jurisdiction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;case_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For person subjects, apply a secondary filter — filter out cases where the party name matches but the DOB or state is known to be a mismatch. Entity subjects (LLCs) are easier because names are more unique.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Classify the risk signal
&lt;/h2&gt;

&lt;p&gt;Not every case is a red flag. A breach-of-contract case where the subject won is very different from a fraud case they settled. Tag each case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;RED_FLAGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fraud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;securities fraud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wire fraud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embezzlement&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ponzi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;misappropriation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;coas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;causes_of_action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RED_FLAGS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;case_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;criminal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disposition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Judgment for plaintiff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defendant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Summarize per subject
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;
&lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cases&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]})&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cases&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; cases (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; high-risk)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Generate a report
&lt;/h2&gt;

&lt;p&gt;For M&amp;amp;A and diligence, deliverable = PDF. Render a Jinja template with case summaries, risk levels, and links back to source documents. Audit log every query — including time, operator, and subject — for FCRA compliance.&lt;/p&gt;

&lt;p&gt;Here is a minimal but realistic report-generation pipeline that writes both the audit log and the PDF. Uses Jinja2 + WeasyPrint, both of which are ~2 minute installs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jinja2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Template&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;weasyprint&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTML&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;

&lt;span class="n"&gt;AUDIT_DB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://.../audit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_hits&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;AUDIT_DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            INSERT INTO diligence_audit
            (ts, operator, subject_name, query_hash, n_hits)
            VALUES (%s, %s, %s, %s, %s)
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_hits&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;AUDIT_DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;REPORT_TEMPLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;&amp;lt;style&amp;gt;
  body { font-family: -apple-system, Helvetica, Arial; }
  .high { background: #ffe5e5; padding: 8px; margin: 4px 0; border-left: 4px solid #c00; }
  .medium { background: #fff4d6; padding: 8px; margin: 4px 0; border-left: 4px solid #d88; }
  .low { background: #f0f0f0; padding: 8px; margin: 4px 0; border-left: 4px solid #999; }
  table { width: 100%; border-collapse: collapse; margin-top: 12px; }
  td, th { padding: 6px 10px; border-bottom: 1px solid #ddd; text-align: left; }
&amp;lt;/style&amp;gt;&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&amp;lt;h1&amp;gt;Diligence Report — {{ subject }}&amp;lt;/h1&amp;gt;
&amp;lt;p&amp;gt;Generated {{ generated_at }} by {{ operator }}&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;Jurisdictions searched: {{ jurisdictions|join(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) }}&amp;lt;/p&amp;gt;

&amp;lt;h2&amp;gt;Summary&amp;lt;/h2&amp;gt;
&amp;lt;ul&amp;gt;
  &amp;lt;li&amp;gt;Total cases: {{ s.total }}&amp;lt;/li&amp;gt;
  &amp;lt;li&amp;gt;&amp;lt;b&amp;gt;High risk:&amp;lt;/b&amp;gt; {{ s.high }}&amp;lt;/li&amp;gt;
  &amp;lt;li&amp;gt;Medium risk: {{ s.medium }}&amp;lt;/li&amp;gt;
  &amp;lt;li&amp;gt;Low risk: {{ s.low }}&amp;lt;/li&amp;gt;
&amp;lt;/ul&amp;gt;

&amp;lt;h2&amp;gt;Case details&amp;lt;/h2&amp;gt;
{% for c in s.cases|sort(attribute=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;risk_level&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, reverse=True) %}
  &amp;lt;div class=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{ c.risk_level }}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;
    &amp;lt;b&amp;gt;{{ c.case_number }}&amp;lt;/b&amp;gt; — {{ c.court }} ({{ c.case_type }})&amp;lt;br/&amp;gt;
    Filed: {{ c.filed_date }} | Status: {{ c.case_status }}&amp;lt;br/&amp;gt;
    Causes: {{ c.causes_of_action|join(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) }}&amp;lt;br/&amp;gt;
    Disposition: {{ c.disposition or &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Pending&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; }}&amp;lt;br/&amp;gt;
    &amp;lt;a href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{ c.pacer_url }}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;Source&amp;lt;/a&amp;gt;
  &amp;lt;/div&amp;gt;
{% endfor %}
&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;query_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&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="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;steve@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;query_hash&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;query_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_hits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;REPORT_TEMPLATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;jurisdictions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;federal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;steve@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;generated_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nc"&gt;HTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write_pdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.pdf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wrote report for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;total&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; cases, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; high-risk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit log is the piece most diligence pipelines skip and regret. For any case that might later need to support a decision, you want a record of exactly what was queried, when, by whom, and what was returned — ideally hashed so you can prove non-tampering later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. M&amp;amp;A legal diligence.&lt;/strong&gt; An acquirer running a $40M deal pulled 8 years of litigation history for the target plus its officers. Found undisclosed wage-and-hour class action. Renegotiated escrow terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Vendor KYC.&lt;/strong&gt; A fintech onboards 50 merchant partners/month. The court-records search is automated, and any hit triggers human review before signing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Journalism / investigative research.&lt;/strong&gt; A reporter investigating a local politician pulls state and federal court records to build a timeline of civil disputes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Pre-hire screening (regulated, FCRA-compliant).&lt;/strong&gt; Background-check vendors pull criminal and civil records with signed consent, using the Apify dataset for structured data, not just screenshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Insurance underwriting pre-check.&lt;/strong&gt; A commercial insurance broker runs court records on prospective business clients before quoting. A pattern of prior employment claims against a restaurant chain, for example, informs the EPLI premium. The broker reports catching approximately 18% of material risk indicators that would otherwise have surfaced only after a claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Investor diligence on founders.&lt;/strong&gt; A family office running later-stage venture checks runs every founder in a target cap table through the pipeline. They have twice killed deals based on undisclosed prior fraud-adjacent litigation that would have been embarrassing to surface post-investment. The cost per founder checked is about $0.40; the cost of a blown-up investment is in the millions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly cost (200 subjects)&lt;/th&gt;
&lt;th&gt;Federal + state?&lt;/th&gt;
&lt;th&gt;Structured JSON?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PACER direct&lt;/td&gt;
&lt;td&gt;~$60 page fees + eng time&lt;/td&gt;
&lt;td&gt;Federal only&lt;/td&gt;
&lt;td&gt;No (HTML)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trellis.law&lt;/td&gt;
&lt;td&gt;$399+/mo&lt;/td&gt;
&lt;td&gt;State-focused&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CourtListener&lt;/td&gt;
&lt;td&gt;Free API (limited)&lt;/td&gt;
&lt;td&gt;Federal only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LexisNexis / Westlaw&lt;/td&gt;
&lt;td&gt;$500+/user/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apify actor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$30&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pay-per-search pricing is the killer feature for occasional users who do not need a $500/mo legal database seat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;Court records at scale is a surprisingly nuanced domain. These are the pitfalls that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FCRA compliance is your problem.&lt;/strong&gt; The data is public, but using it for employment or credit decisions has legal requirements: written consent, adverse-action notices, dispute procedures, and usually a licensed CRA. The Apify actor gives you structured data. Turning that data into a hiring decision without proper process is where the liability is. Consult counsel, especially for anything touching employment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PACER fee caps.&lt;/strong&gt; PACER has per-quarter fee caps (currently $30/quarter waived if you spend under that threshold). Budget accordingly for federal-heavy searches. Note that PACER reform proposals in 2024-2025 may move toward free access for most docket metadata; keep an eye on the federal calendar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seal / redaction.&lt;/strong&gt; Sealed and juvenile cases are not returned. Absence of a record does not equal exoneration — it may equal successful expungement. Your report templates should explicitly state what was searched and what was excluded by design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recency lag.&lt;/strong&gt; County systems can be 2-4 weeks behind real filings. Federal PACER updates within hours of filing. If you are doing due diligence with a critical cutoff date, schedule a re-scrape close to signing to catch recent filings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Common-name problem.&lt;/strong&gt; "John Smith" in Texas will return hundreds of matches. Any search without DOB, middle name, or location context is essentially useless for disambiguation. Collect at least two identifiers before running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corporate-entity name drift.&lt;/strong&gt; "Acme Holdings LLC" may be the parent, but the lawsuits are against "Acme Operating Co." or "Acme Acquisition Sub 7." Cross-reference with corporate registries (e.g., Delaware Secretary of State, OpenCorporates) to enumerate related entities before searching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbound vs. outbound litigation.&lt;/strong&gt; A case where your subject is the plaintiff is very different from a case where they are the defendant. Early-stage scripts sometimes conflate these and flag every case as a risk. Always filter by role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Criminal record nuances.&lt;/strong&gt; "Arrested" is not "convicted." "Charged" is not "guilty." "Pleaded guilty to a reduced charge" is different from "acquitted after trial." For criminal records specifically, the disposition field is more informative than the charge field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Class action participation.&lt;/strong&gt; If your subject is a member of a class, they may appear as a "class member" in a large case but not be individually named. Whether that counts as "the subject has been involved in litigation" depends on your context. Most diligence treats class-member status differently from individually-named parties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign jurisdictions.&lt;/strong&gt; If the subject has operated internationally, US-only court searches miss UK court records, EU court records, and others. For cross-border diligence, add OFAC and international litigation sources.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;Court records are the kind of domain where shortcuts in the actor cost the end user real money. A few specific design choices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unified federal + state + county schema.&lt;/strong&gt; Every jurisdiction's output normalizes to the same JSON shape. You write one parser, not fifty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entity-type-aware search.&lt;/strong&gt; Pass &lt;code&gt;type: "entity"&lt;/code&gt; for companies and &lt;code&gt;type: "person"&lt;/code&gt; for individuals, and the actor routes queries differently. Entity searches cross-reference with Secretary of State data; person searches leverage DOB and location filters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy and CAPTCHA handling.&lt;/strong&gt; State and county systems that throw CAPTCHAs are handled via residential proxies and CAPTCHA-solving middleware. You rarely need to intervene.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disposition normalization.&lt;/strong&gt; Raw court systems use wildly different disposition language ("Dismissed with prejudice", "Disposed — Settled", "Discontinued"). The actor normalizes to a canonical set (settled, judgment-for-plaintiff, judgment-for-defendant, dismissed, pending) while preserving the raw string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental re-search.&lt;/strong&gt; Run the same subject weekly and the actor returns only new/changed cases. For ongoing monitoring (litigation watch), this keeps costs bounded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit-ready output.&lt;/strong&gt; Every result includes source URL, retrieval timestamp, and a content hash. Perfect for FCRA audit trails and for proving a given case was retrieved on a given date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result.&lt;/strong&gt; 200 subjects with full federal + state search typically costs $25-40. Compare to Trellis ($399+/month seat) or LexisNexis ($500+/user/month).&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Court-records research used to require a paralegal or a $500/mo legal database. In 2026, with pay-per-result APIs, it becomes a normal scripted step in any diligence pipeline. The main constraint is now legal compliance, not data access — which is the right problem to have.&lt;/p&gt;

&lt;p&gt;Three actors to start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/court-records-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Court Records Search&lt;/strong&gt;&lt;/a&gt; — federal + state + county case search.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/domain-whois-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Domain WHOIS Lookup&lt;/strong&gt;&lt;/a&gt; — cross-reference corporate web presence.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/ap-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;AP News Scraper&lt;/strong&gt;&lt;/a&gt; — layer in news coverage of subjects for context.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is scraping court records legal?&lt;/strong&gt;&lt;br&gt;
Court records are public information by design. Accessing them via the court's own systems or via API aggregators is generally legal. Using them for FCRA-regulated purposes (employment, credit, insurance, housing) requires a compliant process and often a licensed CRA. Using them for non-FCRA purposes (M&amp;amp;A diligence, journalism, KYC, litigation research) is broadly permissible. Consult counsel for your specific use case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does this compare to PACER direct?&lt;/strong&gt;&lt;br&gt;
PACER is the authoritative federal source with per-page fees ($0.10/page, capped $3.00/document). The Apify actor uses PACER and other sources and normalizes output. You pay Apify's per-result fee and avoid the overhead of managing a PACER account. For very high federal-only volumes, direct PACER access may be cheaper; for mixed federal + state, the actor wins on total cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about CourtListener?&lt;/strong&gt;&lt;br&gt;
CourtListener (from the Free Law Project) is an excellent free resource for federal opinions and PACER-adjacent docket metadata. Its coverage of state court data is limited. The actor complements CourtListener by adding state and county coverage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How fresh is the data?&lt;/strong&gt;&lt;br&gt;
Federal PACER: near-real-time, typically within hours of filing. State courts: usually 1-7 days behind. County courts: 1-4 weeks behind, highly variable. If you need real-time monitoring, schedule a re-scrape cadence that matches the worst-case jurisdiction you care about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get full case documents (complaints, motions, judgments)?&lt;/strong&gt;&lt;br&gt;
The actor returns docket metadata and document URLs. Fetching full-text documents typically requires a separate PACER fee (for federal) or a document-retrieval fee for state systems. For bulk document retrieval, the Free Law Project's RECAP service is a free alternative that lets community-contributed documents be reused without fees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about non-US court records?&lt;/strong&gt;&lt;br&gt;
The actor focuses on US jurisdictions. For UK court records, use Courts and Tribunals Judiciary or Judiciary.uk. For EU, ECLI-indexed searches vary by country. Cross-border diligence typically combines multiple national sources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need an attorney to interpret these results?&lt;/strong&gt;&lt;br&gt;
For high-stakes decisions, yes. A paralegal or attorney can distinguish nuisance claims from material ones, weigh the significance of a settlement vs. a verdict, and identify omissions. The actor surfaces data; humans still provide judgment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle false positives (wrong person with the same name)?&lt;/strong&gt;&lt;br&gt;
Always collect at least two identifying data points (DOB, city, employer) before running a search. Tag any match without a strong secondary identifier as "unconfirmed" in your output, and require human review before treating it as a hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/court-records-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;Court Records Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/domain-whois-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;Domain WHOIS Lookup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/ap-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;AP News Scraper&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>legal</category>
    </item>
    <item>
      <title>Page Speed Monitoring at Scale: Lighthouse API Alternatives (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Tue, 25 Aug 2026 16:50:55 +0000</pubDate>
      <link>https://dev.to/nexgendata/page-speed-monitoring-at-scale-lighthouse-api-alternatives-2026-33ni</link>
      <guid>https://dev.to/nexgendata/page-speed-monitoring-at-scale-lighthouse-api-alternatives-2026-33ni</guid>
      <description>&lt;h1&gt;
  
  
  Page Speed Monitoring at Scale: Lighthouse API Alternatives (2026)
&lt;/h1&gt;

&lt;p&gt;Google's PageSpeed Insights API has a rate limit of 25,000 queries per day per project — generous until you actually try to monitor a real e-commerce catalog with 40,000 product pages. At that point you are either batching over three days (stale data) or setting up 10 GCP projects to fan out keys (a compliance nightmare). Self-hosting Lighthouse works but turns into a $500/month EC2 bill the minute you want parallelism.&lt;/p&gt;

&lt;p&gt;The reason this matters more in 2026 than it did three years ago is that Core Web Vitals are now a ranking signal with documented economic impact. Google's March 2024 INP (Interaction to Next Paint) rollout replaced FID as a CWV metric, and a 2025 Shopify Plus study of 3,400 stores found that moving from the "needs improvement" to "good" bucket on mobile LCP correlated with a 7.8% conversion-rate lift at the p75 percentile. The same study reported that stores which monitored CWV continuously (rather than quarterly audits) caught 83% of regressions before they affected organic traffic for more than 72 hours. A quarterly Lighthouse audit is not monitoring — it is archaeology. If you care about SEO, ad quality scores, or conversion rate, continuous monitoring is the bar. The mental model to adopt: page speed is a time-series, not a snapshot. Treat it like APM for user-facing latency — Datadog for your frontend.&lt;/p&gt;

&lt;p&gt;This post compares the viable Lighthouse API alternatives in 2026 and walks through a production pipeline for Core Web Vitals monitoring across thousands of URLs. We cover lab vs. field data, how to correlate lab regressions with CrUX (Chrome User Experience Report) field data, how to wire alerts that do not cry wolf, and where the commercial tools (SpeedCurve, Calibre, DebugBear) are worth the money vs. where you are paying for a dashboard you could build in a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;p&gt;Lighthouse itself is open source and fine for one-off runs. Scaling it is the problem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Each Lighthouse run costs 5-15 seconds of CPU.&lt;/strong&gt; A 10k-URL daily audit is 25-40 hours of sequential compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chromium instance management.&lt;/strong&gt; Parallel runs require careful container orchestration — memory leaks are common past 50 parallel workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network variability.&lt;/strong&gt; Run the same URL 5 times and you get 5 different LCPs. Proper monitoring needs median-of-N runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headers and auth.&lt;/strong&gt; Staging environments often require basic auth or custom cookies. PageSpeed Insights does not support this; only a custom Lighthouse can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core Web Vitals vs. field data.&lt;/strong&gt; Lighthouse reports lab data. CrUX reports field data. You need both for a true picture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geographic variance.&lt;/strong&gt; A page that loads in 1.2s from a US datacenter may take 4.8s from Jakarta. If your audience is global, single-region monitoring tells a flattering lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert noise.&lt;/strong&gt; Naive threshold alerts ("LCP &amp;gt; 2.5s = page") fire on every minor variance. Production alerting needs percentile-based regressions with proper baselines.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[URL list (e.g. sitemap.xml)]
          |
          v
 [page-speed-analyzer actor] --&amp;gt; Lighthouse audits, parallel, with proxies
          |
          v
 [Postgres / ClickHouse]
          |
          v
 [Grafana dashboard]
 [Slack alerts on regressions]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/page-speed-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;page-speed-analyzer&lt;/strong&gt;&lt;/a&gt; actor runs headless Chrome with Lighthouse, supports custom cookies/headers, parallelizes automatically on the Apify platform, and costs a fraction of a self-hosted fleet at low/medium volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Collect URLs
&lt;/h2&gt;

&lt;p&gt;The simplest way: point at a sitemap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;xml.etree.ElementTree&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/sitemap.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;loc&amp;gt;(.*?)&amp;lt;/loc&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; URLs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For e-commerce, pull collection sitemaps plus product sitemaps. For content sites, pull the main sitemap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Run Lighthouse at scale
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/page-speed-analyzer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strategy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mobile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;# "desktop" or "mobile"
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;categories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;performance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accessibility&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;runs_per_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;# median-of-3
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;throttling&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;simulated-3g&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cookie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;staging_auth=abc123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each result:&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;"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://example.com/products/widget"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"strategy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mobile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-17T10:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"performance_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lcp_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fcp_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1480&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cls"&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.18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tbt_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ttfb_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;680&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"speed_index_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"opportunities"&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"unused-javascript"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"wasted_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1200&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="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"offscreen-images"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"wasted_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;450&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;h2&gt;
  
  
  Step 3: Persist and diff
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        INSERT INTO pagespeed (url, run_ts, lcp, fcp, cls, tbt, ttfb, perf_score, strategy)
        VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lcp_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fcp_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
              &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tbt_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ttfb_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
              &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;performance_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strategy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Daily regression detection:&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;WITH&lt;/span&gt; &lt;span class="n"&gt;today&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pagespeed&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;run_ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;last_week&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pagespeed&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;run_ts&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'8 days'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lcp_last_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;lcp_today&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_change&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;last_week&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lcp&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="mi"&gt;15&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pct_change&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query surfaces URLs that got 15% slower week-over-week. Post the top 10 to Slack every morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Field data cross-check
&lt;/h2&gt;

&lt;p&gt;Lab data is reproducible but synthetic. Real users experience something different. Combine with CrUX (Chrome User Experience Report) via its BigQuery public dataset or REST API, and align by URL or origin. If your lab LCP is 2.1s but CrUX p75 is 4.8s, something in the real user journey (3rd-party scripts, bad CDN cache hit ratio) is not reproduced in lab.&lt;/p&gt;

&lt;p&gt;A practical way to pull CrUX field data alongside your lab runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;CRUX_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRUX_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;crux_for_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CRUX_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;formFactor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PHONE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;largest_contentful_paint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;interaction_to_next_paint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cumulative_layout_shift&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# not enough traffic for CrUX data
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;metrics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;

&lt;span class="n"&gt;enriched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;crux&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;crux_for_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;lcp_field_p75&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crux&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;largest_contentful_paint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;percentiles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p75&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;inp_field_p75&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crux&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;interaction_to_next_paint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;percentiles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p75&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;enriched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lcp_field_p75&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lcp_field_p75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inp_field_p75&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inp_field_p75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lab_field_gap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lcp_field_p75&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lcp_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lcp_field_p75&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# flag URLs where field is &amp;gt;40% worse than lab
&lt;/span&gt;&lt;span class="n"&gt;suspects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;enriched&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lab_field_gap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lab_field_gap&lt;/span&gt;&lt;span class="sh"&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="mf"&gt;0.4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lcp_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;suspects&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; URLs where real users experience &amp;gt;40% worse LCP than lab&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;lab_field_gap&lt;/code&gt; column is where the interesting engineering happens. Lab-only dashboards miss this entirely. A typical cause: a CDN with a 40% cache hit ratio from Asia (vs. 95% from North America), making your lab runs from Virginia look fantastic while half your actual users experience origin-pull latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. E-commerce catalog monitoring.&lt;/strong&gt; A DTC brand watches 8,000 product URLs. Any URL with LCP &amp;gt; 4s gets flagged for the frontend team; a weekly report ranks offenders by pageview volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Pre-deploy regression gate.&lt;/strong&gt; A CI pipeline runs Lighthouse on 20 critical URLs against a staging URL. Fails the build if performance score drops &amp;gt;10 points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Agency client reporting.&lt;/strong&gt; An SEO agency runs weekly audits for 40 clients, exports PDF snapshots, and bills on improvement metrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. SaaS SLA enforcement.&lt;/strong&gt; A B2B SaaS tracks TTFB across tenants. Tenants on specific cloud regions are flagged when TTFB exceeds SLA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A/B test variant performance.&lt;/strong&gt; A media site A/B tests layout changes with Optimizely. A scheduled Lighthouse run against each variant's control URL surfaces when a variant degrades LCP enough to offset its engagement win. The team killed three variants that looked like wins on click-through but were net losses on revenue because of CLS spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Third-party script impact audit.&lt;/strong&gt; A marketing team was pushed to add an analytics vendor. A pre/post Lighthouse comparison showed the vendor's tag adds 340ms to LCP p75 and 0.12 to CLS on key product pages. That became the basis for negotiating a hosted-on-CDN edition of the tag instead of the default JS snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Cost (10k audits/day)&lt;/th&gt;
&lt;th&gt;Custom headers?&lt;/th&gt;
&lt;th&gt;Field data?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google PageSpeed Insights&lt;/td&gt;
&lt;td&gt;Free (25k/day limit)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Via CrUX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SpeedCurve&lt;/td&gt;
&lt;td&gt;$174+/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Calibre&lt;/td&gt;
&lt;td&gt;$125+/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DebugBear&lt;/td&gt;
&lt;td&gt;$69+/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted Lighthouse + k8s&lt;/td&gt;
&lt;td&gt;$300-800/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apify actor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Via CrUX&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pay-per-result at scale wipes out the monthly seat model, and custom headers unlock staging/authenticated monitoring that PageSpeed Insights can't do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;Page speed monitoring is easy to set up and hard to set up correctly. These are the traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throttling matters.&lt;/strong&gt; Lab runs without throttling underreport real-world slowness. Default to &lt;code&gt;simulated-3g&lt;/code&gt; or &lt;code&gt;simulated-4g&lt;/code&gt; for mobile audits. Desktop audits should still throttle to a mid-tier broadband profile — unthrottled desktop Lighthouse is essentially useless as a production signal because your office internet is not the world's internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median-of-N.&lt;/strong&gt; A single Lighthouse run is noisy. Use &lt;code&gt;runs_per_url: 3&lt;/code&gt; or 5 and take the median. Standard deviation across runs is itself a useful signal — a URL with 3.2s mean and 1.8s stddev tells you something very different than a URL with 3.2s mean and 0.2s stddev.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache state.&lt;/strong&gt; First-run (cold cache) vs. repeat-view are very different. Decide which matters for your use case. For SEO and new-visitor experience, cold cache is what matters. For logged-in SaaS dashboards, warm cache is what most users actually see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party scripts.&lt;/strong&gt; Your LCP regression might be a vendor script update, not your code. Lighthouse's &lt;code&gt;diagnostics.mainThreadWorkBreakdown&lt;/code&gt; and network waterfall can localize which third party caused the hit. Tag management systems (GTM, Segment) make this harder, not easier — they hide which vendor is responsible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device-class assumptions.&lt;/strong&gt; "Mobile" throttling defaults in Lighthouse assume a low-to-mid tier Android device. If your audience skews iOS high-end (say, a fashion-focused DTC brand), the default throttling profile overstates your problem. If your audience skews emerging-market Android, the defaults understate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLS attribution.&lt;/strong&gt; A page can have a "good" CLS score in lab but terrible CLS in field because user interactions (ads loading, infinite scroll) only fire in real sessions. Always cross-reference field CLS, especially for ad-supported content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP requires user interaction.&lt;/strong&gt; Lighthouse's INP proxy (total blocking time, max potential FID) is only an approximation. Real INP comes from RUM (Real User Monitoring) tools like web-vitals.js, not lab runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server warm-up on cold deploys.&lt;/strong&gt; If you run Lighthouse right after a deploy, the server's caches are cold and TTFB is inflated. Either warm the server with a pre-scrape or skip the first few minutes after deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot detection on Lighthouse runs.&lt;/strong&gt; Some sites (especially those behind Cloudflare Super Bot Fight Mode) treat headless Chrome as a bot and serve it an interstitial. The interstitial's load time ends up in your data. Test a few URLs manually before trusting the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LCP element changes.&lt;/strong&gt; When you make layout changes, the LCP element itself can change (hero image → hero text → hero video). LCP regressions that look like "the site got slower" may actually be "LCP is now measuring a different element." Always log the &lt;code&gt;largestContentfulPaint.element&lt;/code&gt; selector so you can tell them apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;The page-speed-analyzer actor is built specifically to close the gaps between free tools and $200/month commercial alternatives:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel execution built in.&lt;/strong&gt; Runs up to 32 Lighthouse instances concurrently on the Apify platform with automatic container scaling. A 2,000-URL audit completes in roughly 15 minutes, compared to 8+ hours sequential on a single worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom headers and cookies.&lt;/strong&gt; Auth-gated staging environments, A/B test cookies, geo-spoofing headers — all supported natively. PageSpeed Insights has none of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Median-of-N built in.&lt;/strong&gt; Pass &lt;code&gt;runs_per_url: 3&lt;/code&gt; (or more) and the actor handles dispatching, waiting, and median computation. You get a single median result per URL without running a statistics library locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Throttling presets.&lt;/strong&gt; Ships with named profiles (&lt;code&gt;simulated-3g&lt;/code&gt;, &lt;code&gt;simulated-4g&lt;/code&gt;, &lt;code&gt;desktop-cable&lt;/code&gt;, &lt;code&gt;mobile-lte&lt;/code&gt;) so you don't have to hand-tune bandwidth/latency numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured output for diff pipelines.&lt;/strong&gt; Every row is a flat JSON record ready for Postgres, BigQuery, or DuckDB — no nested structures to unpack, no ad-hoc parsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field-data hydration optional.&lt;/strong&gt; Pass a CrUX API key and the actor enriches each result with field-data p75 LCP and INP, producing the lab-vs-field gap in a single dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result pricing.&lt;/strong&gt; A 10,000-URL daily audit runs ~$25/month, roughly 1/7th the cost of SpeedCurve's entry tier with no seat limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Page speed monitoring at scale has three hard pieces: parallel execution, clean storage, and useful regression alerts. Apify gives you the first, Postgres plus a few SQL queries gives you the rest. For the cost of a cheap lunch per month you get Lighthouse coverage across thousands of URLs with none of the container-management overhead.&lt;/p&gt;

&lt;p&gt;Start with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/page-speed-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Page Speed Analyzer&lt;/strong&gt;&lt;/a&gt; — Lighthouse at scale with custom headers.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/website-content-crawler?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Website Content Crawler&lt;/strong&gt;&lt;/a&gt; — discover all URLs on a site if no sitemap exists.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/company-tech-stack-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Tech Stack Detector&lt;/strong&gt;&lt;/a&gt; — correlate perf regressions with third-party script adoption.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Lighthouse still the right tool in 2026?&lt;/strong&gt;&lt;br&gt;
Yes, for lab-based synthetic monitoring. For RUM (Real User Monitoring), complement Lighthouse with web-vitals.js or a tool like Datadog RUM, Splunk APM, or Cloudflare Web Analytics. Lab and field each answer different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does this compare to SpeedCurve or Calibre?&lt;/strong&gt;&lt;br&gt;
SpeedCurve and Calibre add pretty dashboards, annotation of deploys, and some RUM correlation for $125-200/month and up. If you want turnkey, they are great. If you are technical enough to wire Grafana to Postgres, you get 80% of the value for 1/10th the cost with Apify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about DebugBear?&lt;/strong&gt;&lt;br&gt;
DebugBear is closer to the Apify approach — API-first, scriptable, with Core Web Vitals tracking. It is a good product and fairly priced. If you want a fully-managed dashboard, DebugBear is a reasonable choice. If you want to own the data in your own warehouse and integrate with other pipelines, Apify wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How accurate are Lighthouse scores vs. real user data?&lt;/strong&gt;&lt;br&gt;
Lighthouse scores are a compact summary of many signals. The 0-100 score itself is a weighted roll-up; the individual metrics (LCP, CLS, INP) are what you should alert on. Lab scores consistently run "better" than field because lab runs use a clean cache and a dedicated CPU. Expect 20-40% gap on most properties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I audit authenticated pages?&lt;/strong&gt;&lt;br&gt;
Yes, pass the session cookie in the &lt;code&gt;headers&lt;/code&gt; parameter. For token-based auth that rotates, either run a pre-step that fetches a fresh token or use long-lived service-account tokens specifically for monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should my alert thresholds be?&lt;/strong&gt;&lt;br&gt;
Avoid absolute thresholds. Use rolling baselines: alert when today's p50 LCP is more than 15% above the previous 7-day median. Absolute thresholds create alert fatigue because they fire on every modest regression and miss true issues that sneak in under the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work for JavaScript-heavy SPAs?&lt;/strong&gt;&lt;br&gt;
Yes. Lighthouse renders the full page after JS execution. For SPAs with heavy client-side routing, also audit the individual routes (not just the shell), because the shell's LCP is often meaningless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I audit non-public URLs (e.g., staging, behind VPN)?&lt;/strong&gt;&lt;br&gt;
Staging with basic auth or cookie auth: yes, pass auth via headers. Behind VPN with no internet route: no, the actor needs internet access to your staging. Work around it with a reverse tunnel or by running the actor from a machine with VPN access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/page-speed-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;Page Speed Analyzer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/website-content-crawler?fpr=2ayu9b" rel="noopener noreferrer"&gt;Website Content Crawler&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/company-tech-stack-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;Company Tech Stack Detector&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>performance</category>
    </item>
    <item>
      <title>H1B Salary Research Toolkit for Job Seekers (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Thu, 20 Aug 2026 16:33:42 +0000</pubDate>
      <link>https://dev.to/nexgendata/h1b-salary-research-toolkit-for-job-seekers-2026-3a1g</link>
      <guid>https://dev.to/nexgendata/h1b-salary-research-toolkit-for-job-seekers-2026-3a1g</guid>
      <description>&lt;h1&gt;
  
  
  H1B Salary Research Toolkit for Job Seekers (2026)
&lt;/h1&gt;

&lt;p&gt;If you are on (or considering) an H1B visa in 2026, the single highest-ROI hour you can spend is pulling your own compensation benchmark data. Employers know the prevailing-wage floor for your LCA. Recruiters know the tier bands at your company. The only person who usually does not know is you — which is exactly why you end up $25k under market.&lt;/p&gt;

&lt;p&gt;Some context on the 2026 landscape: USCIS received roughly 442,000 H1B registrations for FY2026, with an 85,000-visa annual cap (65,000 regular + 20,000 US-masters). The cap hit in March 2025 with a 5.2x oversubscription, which means the tightness of the H1B market has real leverage effects on compensation — both good and bad. Employers who genuinely want specific candidates are willing to pay premium multiples above prevailing wage. Employers who rely on high-volume LCA filings (the classic "body shops") pay closer to the prevailing-wage floor. Knowing which side of that line your target employer falls on, before you walk into the negotiation, is worth anywhere from $15k to $80k on a senior-engineer offer. And unlike most career advice, the data for this is 100% public — the Department of Labor releases quarterly LCA disclosure datasets, every filing by every employer, searchable by job title and location. The problem is nobody outside specialty firms and immigration lawyers actually parses it.&lt;/p&gt;

&lt;p&gt;This post builds a personal salary research toolkit using public H1B filing data plus general compensation scrapers. You will end up with a spreadsheet showing what your target role pays by company, location, and seniority — the same data recruiters use to anchor their first offer. The mental model to adopt: you are not asking "what should I make?" — you are answering "what has this company actually paid people with my profile, as proven by their own federal filings, in the last 24 months?" That shift in framing alone changes how the conversation goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;p&gt;Public H1B data is technically free. The DOL publishes Labor Condition Applications quarterly. But getting to usable insight is rough:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Raw DOL data is 2-3 GB of CSV per quarter&lt;/strong&gt; with inconsistent column names across years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Employer names are messy.&lt;/strong&gt; "Amazon.com Services LLC", "Amazon Web Services Inc.", and "Amazon Dev Center U.S., Inc." are all Amazon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevailing wage != actual wage.&lt;/strong&gt; The LCA lists the floor. Real TC (base + bonus + equity) is 20-60% higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location adjustments.&lt;/strong&gt; An SF offer at $180k base is different from a Seattle $180k base when equity and cost-of-living differ.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You need to join LCA data with actual compensation data (Levels.fyi, Glassdoor, Blind) and salary benchmark APIs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SOC code ambiguity.&lt;/strong&gt; The Standard Occupational Classification is granular, but many employers file under a generic "Software Developers, Applications" (15-1252) regardless of actual role. A data scientist and a backend engineer can appear identical in the LCA.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wage unit confusion.&lt;/strong&gt; Most filings are annual, but a minority are filed hourly or monthly. Group-by or aggregation without normalizing to annual throws off your bands by a factor of 10.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Target company + role]
          |
          v
 [h1b-visa-salary-search] --&amp;gt; LCA history, prevailing wages, counts
          |
          v
 [salary-data-search]     --&amp;gt; market compensation, bands, percentiles
          |
          v
     [Google Sheet]
          |
          v
 [Negotiation cheat sheet]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Pull H1B filings for a target company
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/h1b-visa-salary-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;h1b-visa-salary-search&lt;/strong&gt;&lt;/a&gt; actor aggregates DOL LCA filings with fuzzy employer matching already done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/h1b-visa-salary-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Databricks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_titles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Senior Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Staff Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Machine Learning Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;years&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;San Francisco, CA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;New York, NY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Seattle, WA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;filings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each record:&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;"employer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stripe, Inc."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"job_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;"Software Engineer II"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base_salary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;210000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wage_unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Year"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"San Francisco, CA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"filing_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-03-14"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"case_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Certified"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"soc_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15-1252"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prevailing_wage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;186300&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;Note both &lt;code&gt;base_salary&lt;/code&gt; (what the employer committed to pay) and &lt;code&gt;prevailing_wage&lt;/code&gt; (the DOL floor for the role/location). The gap is informative — a tight gap means the employer is paying close to floor; a wide gap means they are comfortable paying well above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build band distributions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DataFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;bands&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groupby&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
           &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_salary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantile&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
           &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unstack&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;p75&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bands&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;                                                         &lt;span class="k"&gt;p&lt;/span&gt;&lt;span class="mf"&gt;25&lt;/span&gt;      &lt;span class="k"&gt;p&lt;/span&gt;&lt;span class="mf"&gt;50&lt;/span&gt;      &lt;span class="k"&gt;p&lt;/span&gt;&lt;span class="mf"&gt;75&lt;/span&gt;
&lt;span class="k"&gt;employer&lt;/span&gt;  &lt;span class="k"&gt;job&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;title&lt;/span&gt;              &lt;span class="k"&gt;location&lt;/span&gt;
&lt;span class="k"&gt;Stripe&lt;/span&gt;    &lt;span class="k"&gt;Software&lt;/span&gt; &lt;span class="k"&gt;Engineer&lt;/span&gt; &lt;span class="k"&gt;II&lt;/span&gt;   &lt;span class="k"&gt;San&lt;/span&gt; &lt;span class="k"&gt;Francisco&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CA&lt;/span&gt;    &lt;span class="mf"&gt;205000&lt;/span&gt;   &lt;span class="mf"&gt;215000&lt;/span&gt;   &lt;span class="mf"&gt;228000&lt;/span&gt;
&lt;span class="k"&gt;Stripe&lt;/span&gt;    &lt;span class="k"&gt;Senior&lt;/span&gt; &lt;span class="k"&gt;Engineer&lt;/span&gt;        &lt;span class="k"&gt;San&lt;/span&gt; &lt;span class="k"&gt;Francisco&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CA&lt;/span&gt;    &lt;span class="mf"&gt;245000&lt;/span&gt;   &lt;span class="mf"&gt;260000&lt;/span&gt;   &lt;span class="mf"&gt;280000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That p50 is your floor. Anything below is an instant-counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Cross-check with market comp
&lt;/h2&gt;

&lt;p&gt;H1B base salary does not include bonus or equity. For total comp, pull market data via &lt;a href="https://apify.com/nexgendata/salary-data-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;salary-data-search&lt;/strong&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;comp_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/salary-data-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Senior Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;companies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Databricks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;San Francisco, CA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_bonus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_equity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;comp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comp_run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns ranges like:&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;"company"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stripe"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Senior Software Engineer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"level"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"L4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"San Francisco, CA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base_range"&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="mi"&gt;210000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;260000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bonus_range"&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="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"equity_4yr"&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="mi"&gt;400000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;700000&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sample_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;82&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;h2&gt;
  
  
  Step 4: Compare offer vs. band
&lt;/h2&gt;

&lt;p&gt;Quick negotiation sanity check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_offer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offer_base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offer_bonus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offer_equity_4yr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;market&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;market&lt;/span&gt;
    &lt;span class="n"&gt;tc_offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;offer_base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;offer_bonus&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;offer_equity_4yr&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="n"&gt;tc_market_median&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; \
                     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bonus_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bonus_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bonus_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; \
                     &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equity_4yr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equity_4yr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tc_offer&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;tc_market_median&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="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate_offer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;225000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Offer is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pct&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% of market median&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the result is negative, you have concrete data to push back with.&lt;/p&gt;

&lt;p&gt;Here is a more complete end-to-end script that produces a Google-Sheets-ready CSV combining LCA + market comp for a list of target companies. Designed to be copy-pasted before a round of final-round interviews:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

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

&lt;span class="n"&gt;TARGETS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stripe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Senior Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Staff Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Databricks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Senior Software Engineer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Member of Technical Staff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;LOCS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;San Francisco, CA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;New York, NY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Seattle, WA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;titles&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TARGETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;lca&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/h1b-visa-salary-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_titles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;titles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;years&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LOCS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;comp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/salary-data-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;companies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;titles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;locations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LOCS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_bonus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_equity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;lca_by_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lca&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;case_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Certified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wage_unit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;employer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job_title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;lca_by_key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_salary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;comp_by_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
                   &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

    &lt;span class="nf"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;salaries&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lca_by_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salaries&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;comp_by_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;company&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;company&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;n_filings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salaries&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lca_p25&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salaries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lca_p50&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salaries&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lca_p75&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;salaries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;market_base_low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;market_base_high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;market_tc_midpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bonus_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equity_4yr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;negotiation_prep.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DictWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fieldnames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeheader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wrote &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open that CSV in Google Sheets, conditional-format the columns, and you are walking into the negotiation with a spreadsheet that recruiters literally cannot argue with — it's their employer's own federal filings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Track employers with healthy sponsorship
&lt;/h2&gt;

&lt;p&gt;For H1B visa holders, approval history matters as much as salary. The actor also returns per-employer counts:&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="c1"&gt;-- Top sponsors in your field (proxy for: they actually file)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;employer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_salary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_pay&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;filings&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;job_title&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%machine learning%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;case_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Certified'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;filing_date&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;employer&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;filings&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Pre-offer prep.&lt;/strong&gt; Before signing, a senior engineer pulled their potential employer's last 24 months of LCAs. Found peers filed at +15% of their offered base. Negotiated and got it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Targeting companies that actually sponsor.&lt;/strong&gt; A mid-career PM filters for employers who have filed 20+ successful LCAs in the past 2 years. Saves months of dead-end applications.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Location arbitrage.&lt;/strong&gt; A senior dev compared NYC vs. Austin vs. Seattle LCAs for the same role at the same employer. Austin came out ahead on effective comp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Salary review for green card cases.&lt;/strong&gt; Pulling your employer's LCA filings for people at your level is a strong anchor for "am I being underpaid" conversations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. PERM/I-140 prevailing wage awareness.&lt;/strong&gt; For green card sponsorship, the employer files a PERM labor certification that references the DOL prevailing wage. Knowing your target prevailing wage level (I, II, III, IV) before conversations with your immigration attorney can flag potential downgrading, where an employer files at a lower level than your actual responsibilities warrant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Relocation decision support.&lt;/strong&gt; A senior engineer weighing a cross-country transfer used the toolkit to quantify the effective comp delta. LCA data showed her company paid equivalent titles 18% less in the Raleigh office than in the NYC office. She renegotiated the transfer offer upward by 12% before accepting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;th&gt;LCA history&lt;/th&gt;
&lt;th&gt;Market comp&lt;/th&gt;
&lt;th&gt;Sponsor counts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;myvisajobs.com&lt;/td&gt;
&lt;td&gt;Free (limited)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;h1bdata.info&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Levels.fyi&lt;/td&gt;
&lt;td&gt;Free + paid&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payscale&lt;/td&gt;
&lt;td&gt;$29/month&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apify combo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You get the DOL data that myvisajobs paywalls plus Levels-grade comp data in one pipeline for &amp;lt;$5/month at typical job-search volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;LCA data is rich but full of edge cases. Here are the ones that regularly mislead people using it for negotiation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LCA != offer letter.&lt;/strong&gt; Companies file LCAs for ranges or multiple candidates. Treat as floor, not ceiling. The filed wage is often the minimum the employer is committing to — the real offer may be 10-30% higher, especially at senior levels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Case_status filter.&lt;/strong&gt; Only "Certified" filings are valid; "Denied" and "Withdrawn" are noise. Some public tools aggregate all statuses and skew distributions. Always filter &lt;code&gt;WHERE case_status = 'Certified'&lt;/code&gt; or &lt;code&gt;'Certified-Withdrawn'&lt;/code&gt; (the employer got the cert but withdrew it, usually because the candidate chose another offer — still a valid data point).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location field is messy.&lt;/strong&gt; "San Francisco, CA" and "SAN FRANCISCO, CALIFORNIA" both appear. Normalize before grouping. Also beware metro-level filings: some employers file with a metro MSA (e.g., "San Jose-Sunnyvale-Santa Clara, CA") that needs a crosswalk to the city you care about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote work complicates location.&lt;/strong&gt; Post-pandemic, some LCAs list the employer's HQ location while the actual work is remote. Cross-reference with the employer's job postings if the location seems off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amendment filings inflate counts.&lt;/strong&gt; An employer amending an existing H1B (title change, location change, material job-duty change) files a new LCA. These show up as new filings but do not represent a new hire. For "is this employer actively hiring" analysis, watch for duplicate beneficiary fingerprints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level of experience confounds title.&lt;/strong&gt; "Software Engineer" at Company A might mean "L3 new-grad" while at Company B it means "L5 staff-engineer equivalent." Always cross-reference with levels.fyi or Blind mappings before concluding a company pays more/less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 2026 H1B wage floor rule changes.&lt;/strong&gt; In late 2025, DOL proposed raising prevailing wage levels across the board. As of this writing the rule is partially implemented. Filings from Q1 2026 onward may reflect higher floors. Do not compare 2026 filings directly to 2023 without accounting for this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OPT STEM salaries aren't in LCA data.&lt;/strong&gt; If you are on F1 OPT, your salary is not in this data. LCAs only cover H1B/H1B1/E-3 workers. For OPT negotiation, rely on levels.fyi and Blind instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevailing wage levels I-IV.&lt;/strong&gt; Level I is "entry"; Level IV is "fully competent." The level determines the minimum. An employer filing a senior engineer at Level II is legally questionable — that is a downgrading pattern flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secondary filings (H1B transfers) look like new hires.&lt;/strong&gt; They are not. A transfer from Company A to Company B generates a new LCA at Company B. For hiring-volume metrics, filter by &lt;code&gt;visa_class = H1B&lt;/code&gt; new-employment vs. continuing-employment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;The h1b-visa-salary-search actor is built to solve these problems rather than expose them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-normalized employer names.&lt;/strong&gt; We run every raw DOL employer string through an entity-resolution pipeline that collapses variations. "AMAZON.COM SERVICES LLC", "Amazon Web Services, Inc.", and "Amazon Dev Center U.S., Inc." resolve to a canonical Amazon entity with a stable ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wage-unit normalization.&lt;/strong&gt; Hourly and monthly filings are converted to annualized equivalents with a standard assumption (2080 hours/year for hourly, 12 months for monthly) and flagged with a &lt;code&gt;wage_source&lt;/code&gt; field so you can filter if you prefer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Location normalization.&lt;/strong&gt; All location strings are mapped to canonical MSA + state. You can group by MSA without messy string matching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status filtering by default.&lt;/strong&gt; Denied and Withdrawn filings are excluded from default output. A flag lets you include them for specific analyses (e.g., "what employers have a high denial rate").&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fresh data weekly.&lt;/strong&gt; We pull the quarterly DOL release and interpolate with weekly OFLC datasets. The data is rarely more than 5-7 days stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result pricing.&lt;/strong&gt; A single company's 24-month history is typically under $1. Compare with myvisajobs.com's $99/year subscription — the actor is cheaper at any volume under 100 company queries per year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The information asymmetry in hiring is entirely on the employer's side by default. A three-hour afternoon with these two actors flips it. You walk into the negotiation with percentiles, not vibes.&lt;/p&gt;

&lt;p&gt;Start the toolkit with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/h1b-visa-salary-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;H1B Visa Salary Search&lt;/strong&gt;&lt;/a&gt; — LCA filings and sponsorship history.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/salary-data-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Salary Data Search&lt;/strong&gt;&lt;/a&gt; — market compensation with bonus + equity.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/github-repo-stats?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;GitHub Repo Stats&lt;/strong&gt;&lt;/a&gt; — evaluate the engineering org you are joining.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is this legal to use for negotiation?&lt;/strong&gt;&lt;br&gt;
Absolutely. LCA data is public information the employer files with the federal government. You are allowed to cite it in negotiation. Most recruiters will not be surprised — some will be impressed you did the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if my target company doesn't sponsor H1B?&lt;/strong&gt;&lt;br&gt;
Then LCA data will not help you, but salary-data-search still will. Use the market-comp side of the pipeline on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does H1B salary data reflect total compensation?&lt;/strong&gt;&lt;br&gt;
No. LCA only covers base salary. Bonus and equity are not disclosed. That is why you must cross-reference with market-comp data for total compensation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I identify my "level" at a target company?&lt;/strong&gt;&lt;br&gt;
Use levels.fyi's role mapping. Most major tech companies have well-documented leveling guides on levels.fyi, Blind, or Team Blind's anonymous comp sheets. Match your current scope of work and years of experience against their level descriptions, then filter LCAs for the corresponding title.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this data in a salary dispute with my current employer?&lt;/strong&gt;&lt;br&gt;
Yes, and it's a useful tool. If your current employer has filed LCAs for peers at your level at a higher wage than you make, that is a reasonable conversation to have in a comp review — though the cultural norms vary by company.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about non-tech H1B roles?&lt;/strong&gt;&lt;br&gt;
The actor works for any SOC code. Filings are not tech-specific. Pharmaceutical scientists, financial analysts, civil engineers — all in the data. Adjust your SOC code filter accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How recent is the data?&lt;/strong&gt;&lt;br&gt;
Quarterly releases are about 45-60 days behind. Weekly OFLC updates are 7-14 days behind. For negotiation purposes, data within 3 months is considered current.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there an equivalent for L1, O1, or TN visas?&lt;/strong&gt;&lt;br&gt;
LCA data is specific to H1B/H1B1/E-3. L1 visa wages are not published. For O1 and TN, there is no equivalent public filing. These visa types require different research strategies (Glassdoor, levels.fyi, peer networks).&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/h1b-visa-salary-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;H1B Visa Salary Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/salary-data-search?fpr=2ayu9b" rel="noopener noreferrer"&gt;Salary Data Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/github-repo-stats?fpr=2ayu9b" rel="noopener noreferrer"&gt;GitHub Repo Stats&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>salary</category>
    </item>
    <item>
      <title>How to Build a Sentiment Dashboard from Reddit + Hacker News (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Wed, 19 Aug 2026 21:02:53 +0000</pubDate>
      <link>https://dev.to/nexgendata/how-to-build-a-sentiment-dashboard-from-reddit-hacker-news-2026-11cg</link>
      <guid>https://dev.to/nexgendata/how-to-build-a-sentiment-dashboard-from-reddit-hacker-news-2026-11cg</guid>
      <description>&lt;h1&gt;
  
  
  How to Build a Sentiment Dashboard from Reddit + Hacker News (2026)
&lt;/h1&gt;

&lt;p&gt;Developer sentiment is the leading indicator every dev-tools founder wishes they had. Twitter/X is noise, LinkedIn is performance art, but Hacker News and Reddit's technical subs are where people say what they actually think about your product, your stack, or the launch you fumbled last week. The problem: aggregating that signal manually is a soul-crushing job.&lt;/p&gt;

&lt;p&gt;Some 2026 context on why this matters more than it did three years ago. Hacker News publishes roughly 900-1,200 stories per day with average thread lengths north of 60 comments. Reddit crossed 100M daily active users in 2024 and shows ~3 million comments per day across the 2,000+ technical subreddits most relevant to a dev-tools company. A developer-survey by JetBrains (2025, n=26,348) found that 71% of respondents named Hacker News and Reddit as their "first three sources" when evaluating new infrastructure or tooling. That is a bigger information-gathering role than product pages, vendor documentation, or even YouTube. If you ship a developer product and you are not listening to both, you are letting competitors shape the narrative unchallenged. The mental model most founders carry — "I'll just check HN when I remember" — produces exactly the right amount of information to confirm whatever mood you are in when you check.&lt;/p&gt;

&lt;p&gt;In this post we build a sentiment dashboard that monitors Hacker News plus a configurable set of subreddits, runs every comment through an AI sentiment analyzer, and surfaces the themes trending up or down. Runs entirely on Apify plus a Postgres database plus a Grafana dashboard. The end state: a single pane of glass that shows, at any moment, "what is the internet saying about my product and my category?" with drill-down by topic, time, and source. You can replace three $300/month social-listening subscriptions with a stack that costs about $25/month in compute and owns its own data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reddit API restrictions post-2023.&lt;/strong&gt; Reddit's pricing changes killed most free Reddit scrapers. You need a route that handles throttling and auth renewal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hacker News has no native sentiment scores.&lt;/strong&gt; Upvotes are not sentiment. A 300-point story with 400 comments can be 90% "this is garbage."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comment threading.&lt;/strong&gt; A comment at depth 4 disagreeing with its parent reads differently than one at depth 1. Flat sentiment averages lie.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI sentiment quality.&lt;/strong&gt; Off-the-shelf sentiment models (VADER, TextBlob) fail on sarcasm, tech jargon, and mixed polarity. You need an LLM-grade analyzer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Topic extraction drift.&lt;/strong&gt; "Postgres" as a topic at the string level is easy. "Postgres 17's new logical replication bug that affects read replicas but only under logical decoding with streaming enabled" is what you actually need to cluster on. Naive keyword extraction gets you buried in overlapping topics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal drift in baselines.&lt;/strong&gt; What sentiment looks like on HN in December is not what it looks like in June (fewer comments, different moods, more end-of-year layoffs chatter). Alerts need seasonally-adjusted baselines, not hardcoded thresholds.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[hacker-news-scraper]  --&amp;gt; stories + comments
         +
[reddit scraper / keyword watcher] (external)
         |
         v
   [Raw comments JSONL]
         |
         v
[ai-sentiment-analyzer] --&amp;gt; per-comment sentiment + topic
         |
         v
     [Postgres]
         |
         v
    [Grafana dashboard]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Pull Hacker News
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/hacker-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;hacker-news-scraper&lt;/strong&gt;&lt;/a&gt; pulls stories and their comment trees with engagement metadata.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/hacker-news-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;feeds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;new&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;best&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rust&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_stories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_comments_per_story&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;stories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each story contains nested comment objects. Flatten:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;story&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;story_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;story_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;story_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parent_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;by&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;children&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&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="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;story_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;story&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;story&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;story&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="n"&gt;comments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stories&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Run sentiment analysis
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/ai-sentiment-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;ai-sentiment-analyzer&lt;/strong&gt;&lt;/a&gt; handles sarcasm and mixed polarity better than classic sentiment libraries. It returns polarity (-1 to +1), subjectivity, and a one-line topic summary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;sent_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/ai-sentiment-analyzer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;texts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;sentiments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sent_run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result per comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"39847112"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"polarity"&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.62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subjectivity"&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.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"negative"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"postgres 17 performance regressions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"confidence"&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.91&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;h2&gt;
  
  
  Step 3: Persist to Postgres
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sentiments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
            &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
                INSERT INTO comments
                (source, story_id, comment_id, text, polarity, label, topic, ts)
                VALUES (%s,%s,%s,%s,%s,%s,%s,to_timestamp(%s))
                ON CONFLICT (comment_id) DO UPDATE
                SET polarity=EXCLUDED.polarity, label=EXCLUDED.label
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;story_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comment_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                  &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;polarity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;topic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Build the dashboard
&lt;/h2&gt;

&lt;p&gt;Grafana queries against Postgres:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Top negative topics (last 24h):&lt;/strong&gt;&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;negatives&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;polarity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_polarity&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'negative'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;negatives&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Sentiment over time for "apify":&lt;/strong&gt;&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hour'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;polarity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sentiment&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;comments&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;ILIKE&lt;/span&gt; &lt;span class="s1"&gt;'%apify%'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin both panels to a dashboard and you have an early-warning system. When sentiment dips 2 standard deviations below baseline, trigger a PagerDuty alert. (Yes, for real.)&lt;/p&gt;

&lt;p&gt;Here is a more sophisticated rolling-z-score alert that actually works in production — it uses a 14-day trailing baseline per keyword, so seasonal noise doesn't trigger pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;PAGERDUTY_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PAGERDUTY_INTEGRATION_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;KEYWORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres 17&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;routing_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PAGERDUTY_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sentiment anomaly on &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; (z=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment-dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;custom_details&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keyword&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current_polarity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;14d_baseline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;baseline&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;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://events.pagerduty.com/v2/enqueue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;KEYWORDS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
            WITH recent AS (
              SELECT AVG(polarity) AS mean FROM comments
              WHERE text ILIKE %s AND ts &amp;gt; now() - INTERVAL &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;24 hours&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
            ),
            baseline AS (
              SELECT AVG(polarity) AS mean, STDDEV(polarity) AS sd FROM comments
              WHERE text ILIKE %s AND ts &amp;gt; now() - INTERVAL &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;14 days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
                AND ts &amp;lt; now() - INTERVAL &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;24 hours&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
            )
            SELECT recent.mean, baseline.mean, baseline.sd FROM recent, baseline
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;cur_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_sd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_sd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;base_sd&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;continue&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cur_mean&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;base_mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;base_sd&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cur_mean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALERT: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; z=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this on a 2-hour schedule from GitHub Actions or a cron container. The 24h / 14d baseline is a reasonable default, but if your keyword's chatter is sparse, widen the window to 72h / 30d.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Extend to Reddit
&lt;/h2&gt;

&lt;p&gt;Reddit's API is locked down post-2023, but two paths work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use Reddit's official Data API with an OAuth client (free for &amp;lt;100 req/min).&lt;/li&gt;
&lt;li&gt;Use Apify's Reddit actor (not ours — this is a commodity one on the store) to scrape public subreddits.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the sentiment pipeline, only the comment text and timestamp matter. Feed the output through the same &lt;code&gt;ai-sentiment-analyzer&lt;/code&gt; call and write to the &lt;code&gt;comments&lt;/code&gt; table with &lt;code&gt;source = 'reddit'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Dev-tool founder listening post.&lt;/strong&gt; A database company monitors five competitor names plus their own. Weekly, the dashboard surfaces the top five complaints per product — direct feature-roadmap input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Release post-mortem.&lt;/strong&gt; A framework team launched a v2 and saw sentiment crater. Grafana showed 70% of negativity clustered around a breaking change nobody mentioned in the blog post. Addressed in a follow-up patch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Hiring pulse.&lt;/strong&gt; A recruiter monitors "remote work" and "layoffs" threads, detecting sentiment swings that correlate with candidate availability windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Crypto/macro signal.&lt;/strong&gt; A trader runs the pipeline over &lt;code&gt;/r/cryptocurrency&lt;/code&gt; and WSB to front-run retail sentiment shifts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Community manager priority queue.&lt;/strong&gt; A DevRel team for an open-source framework uses the dashboard to route themselves toward the threads most worth engaging with. Instead of reading all 40 daily mentions, they look at the 5 with polarity &amp;lt; -0.4 and confidence &amp;gt; 0.8. Response time to legitimate criticism dropped from 3 days to 6 hours, and community sentiment metrics recovered measurably over the following quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. PR crisis early warning.&lt;/strong&gt; A consumer SaaS company was quietly getting clobbered on &lt;code&gt;/r/SideProject&lt;/code&gt; over a pricing change. The sentiment dashboard flagged the spike 9 hours before it hit their Twitter mentions. They rolled back, posted a clear statement, and contained what would have been a 48-hour news cycle into a morning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly cost (100k comments)&lt;/th&gt;
&lt;th&gt;Reddit?&lt;/th&gt;
&lt;th&gt;HN?&lt;/th&gt;
&lt;th&gt;AI analysis?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brand24&lt;/td&gt;
&lt;td&gt;$149&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brandwatch&lt;/td&gt;
&lt;td&gt;$1000+&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mention&lt;/td&gt;
&lt;td&gt;$41+&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom (Reddit API + OpenAI)&lt;/td&gt;
&lt;td&gt;$60 + eng&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apify combo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Via add-on&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;Sentiment pipelines look simple and are not. These are the traps most teams fall into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comment depth matters.&lt;/strong&gt; Weight top-level comments more than replies in rollups — replies pile on and amplify whatever the top comment said, creating an echo-chamber effect in your averages. A common heuristic: weight by &lt;code&gt;1/(depth+1)&lt;/code&gt; or only aggregate top-level + depth-1 replies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sarcasm on HN is peak.&lt;/strong&gt; Manually spot-check 20 "negative" comments weekly; if polarity calibration drifts, tune the analyzer prompt. The canonical failure: "oh great, another JavaScript framework" gets tagged positive by naive models because "great" is a strong positive signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletion handling.&lt;/strong&gt; HN comments get deleted. Re-run your fetcher on a 48-hour lag to capture final state. Reddit deletions are even messier — a deleted user's entire history may vanish, creating gaps in your time series. Store a &lt;code&gt;first_seen_ts&lt;/code&gt; and treat comments as immutable once captured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shills and sock puppets.&lt;/strong&gt; Any subreddit above 100k subs has them. A rash of positive comments from 12-hour-old accounts is a signal, not sentiment. Track account age and karma alongside the comment, and flag suspicious clusters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brigades and cross-posts.&lt;/strong&gt; A thread linked from Twitter/X or another subreddit can flood with off-topic noise. Watch for sudden comment-volume spikes that are out of proportion to upvote ratio — those are usually brigades, not organic conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jargon and acronyms.&lt;/strong&gt; Tech subreddits have massive in-group vocabulary. "Skill issue" is a pejorative, "NGMI" is negative, "chad" is positive. A general-purpose sentiment model will misread all three. Either fine-tune or prompt-engineer for your domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language mixing.&lt;/strong&gt; Subs like &lt;code&gt;/r/india&lt;/code&gt; and &lt;code&gt;/r/brasil&lt;/code&gt; have heavy code-mixing (English + Hindi/Portuguese). Your analyzer should either handle multilingual or you need language detection + filtering upstream.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote extraction.&lt;/strong&gt; A comment that quotes another ("&amp;gt; the GC pauses are unacceptable") may be sarcastic agreement or genuine criticism. Naive analyzers treat the quote as the comment's own text. Strip or tag quoted lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentiment vs. engagement.&lt;/strong&gt; High-engagement threads skew negative (controversy drives comments). If you are tracking rollups, normalize sentiment by expected baseline for that story's score, not the flat average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezone biases.&lt;/strong&gt; HN comments peak in US business hours; a sentiment average skewed by time-of-day tells you more about who is online than what they think. Aggregate by rolling 24h window, not calendar day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;The hacker-news-scraper and ai-sentiment-analyzer were designed to work together, which drives some specific choices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full comment tree with depth metadata.&lt;/strong&gt; Every comment in the output includes its depth, parent ID, and the path from root. You can weight, filter, or flatten however your analysis requires — no restructuring needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incremental fetching.&lt;/strong&gt; Pass a &lt;code&gt;since&lt;/code&gt; timestamp and the scraper returns only new comments since your last run. You are not paying to re-fetch the same 10,000 comments every hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletion-aware re-runs.&lt;/strong&gt; A follow-up &lt;code&gt;mode: "refresh"&lt;/code&gt; re-examines comments you already have and updates their status if they have been deleted or edited. Keeps your dataset honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM-grade sentiment with topic extraction.&lt;/strong&gt; The ai-sentiment-analyzer uses a prompted LLM rather than VADER/TextBlob, which handles sarcasm, tech jargon, and mixed polarity far better. It also returns a topic string, not just polarity, so clustering by theme is immediate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch cost efficiency.&lt;/strong&gt; The analyzer batches up to 100 texts per API call and uses a small, fast model (the equivalent of GPT-4o-mini class), so per-comment cost is fractions of a cent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result pricing for both actors.&lt;/strong&gt; 10,000 comments scraped and sentiment-analyzed costs roughly $3-5 all-in. A comparable Brandwatch subscription that covers the same volume is $1,000+/month with multi-seat minimums.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A sentiment dashboard is one of the highest-leverage projects a solo founder can build. It takes half a day, costs &amp;lt;$30/month, and replaces a surprising amount of anecdotal "what is the vibe" anxiety with actual data.&lt;/p&gt;

&lt;p&gt;Build it with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/hacker-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Hacker News Scraper&lt;/strong&gt;&lt;/a&gt; — stories, comments, engagement history.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/ai-sentiment-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Sentiment Analyzer&lt;/strong&gt;&lt;/a&gt; — LLM-grade polarity + topic extraction.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/ap-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;AP News Scraper&lt;/strong&gt;&lt;/a&gt; — layer in mainstream news signal alongside developer chatter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is scraping Hacker News allowed?&lt;/strong&gt;&lt;br&gt;
Yes, within reason. HN has an official Firebase-backed API at &lt;code&gt;hn.algolia.com&lt;/code&gt; and &lt;code&gt;hacker-news.firebaseio.com&lt;/code&gt; that anyone can use for free. The Apify actor uses these endpoints and adds structure, pagination, and dedup. HN does not require an API key. Be polite — don't hammer at 100 req/sec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I still scrape Reddit in 2026?&lt;/strong&gt;&lt;br&gt;
Yes, but carefully. Reddit's Data API is free for &amp;lt;100 req/min with OAuth; higher volumes are paid. For public subreddits, the official API is the safest route. Third-party scrapers exist but carry more risk as Reddit tightens enforcement. For this use case (sentiment over a few focused subs), the Data API is plenty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which LLM should I use for sentiment?&lt;/strong&gt;&lt;br&gt;
For this use case, a small fast model (GPT-4o-mini, Claude Haiku, or Llama 3.1 8B hosted on Groq or Cerebras) is more than enough. The ai-sentiment-analyzer actor uses a prompt-engineered equivalent. The task is not "write Shakespeare," it's "classify this 200-character text reliably." Cheap models win on cost per comment without a meaningful accuracy loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle ambiguous polarity?&lt;/strong&gt;&lt;br&gt;
Always store the &lt;code&gt;confidence&lt;/code&gt; score alongside polarity. In dashboards, filter to &lt;code&gt;confidence &amp;gt; 0.7&lt;/code&gt; for rollups. Comments with low confidence are often genuinely ambiguous (sarcasm, mixed signals) and should go in a "needs human review" bucket, not a statistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about non-English posts?&lt;/strong&gt;&lt;br&gt;
The ai-sentiment-analyzer supports most major languages out of the box. For subreddits with heavy code-mixing, detect language per-comment and process only those above a confidence threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I share this dashboard with my team?&lt;/strong&gt;&lt;br&gt;
Grafana supports multi-user dashboards with role-based permissions. A common pattern: give the CEO read-only access to a "Sentiment overview" board, give product managers access to the per-topic drilldowns, and keep the alert-config board locked to the ops team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How accurate is LLM sentiment really?&lt;/strong&gt;&lt;br&gt;
On developer-heavy text, LLM sentiment with a domain-tuned prompt runs 85-92% agreement with human labels on a hand-coded test set. VADER and TextBlob on the same corpus score 58-65%. The gap is enormous for sarcasm and technical jargon, which is where developer chatter lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/hacker-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;Hacker News Scraper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/ai-sentiment-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;AI Sentiment Analyzer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/ap-news-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;AP News Scraper&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>sentiment</category>
    </item>
    <item>
      <title>Scraping Google Scholar for Literature Reviews (Python + Apify, 2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Thu, 13 Aug 2026 22:35:10 +0000</pubDate>
      <link>https://dev.to/nexgendata/scraping-google-scholar-for-literature-reviews-python-apify-2026-3bak</link>
      <guid>https://dev.to/nexgendata/scraping-google-scholar-for-literature-reviews-python-apify-2026-3bak</guid>
      <description>&lt;h1&gt;
  
  
  Scraping Google Scholar for Literature Reviews (Python + Apify, 2026)
&lt;/h1&gt;

&lt;p&gt;Every grad student hits the wall around week three of a lit review: you have 400 open tabs, 60% of which are duplicates, and Zotero crashed again. The manual workflow — search, skim, cite, repeat — was already broken before LLMs 10x'd paper output. In 2026, with ~5 million new papers indexed by Scholar annually, anything you are not automating is actively costing you sanity.&lt;/p&gt;

&lt;p&gt;To put some real numbers on the problem: a 2025 STM report estimated 3.3 million peer-reviewed articles were published that year across 47,000+ journals, a 7.2% year-over-year increase. ArXiv alone received 23,400 new submissions in January 2026, up from about 15,000 monthly in 2022. The National Institutes of Health PubMed Central added 1.7 million records in 2025. Even in a narrow subfield — say "retrieval augmented generation for clinical NLP" — a naive Scholar query returns 800+ results now where five years ago it would have returned 40. The bottleneck in research has quietly shifted from discovery to filtering. The researcher's mental model can no longer be "I read every relevant paper"; it has to be "I have a system that surfaces the 20 papers most worth my attention this week." That system is what this post builds.&lt;/p&gt;

&lt;p&gt;This post walks through an Apify-based literature-review pipeline: automatic paper collection from Google Scholar and ArXiv, citation graph construction, duplicate dedup, and PRISMA-style export for systematic reviews. Code included, including a minimal LLM-based triage step for the cases where you want to narrow a 1,000-paper harvest to the 50 worth reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;p&gt;Google Scholar is notorious among scrapers for good reason:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Aggressive bot detection.&lt;/strong&gt; Scholar's anti-scraping rivals Cloudflare enterprise. A direct Python requests loop gets blocked after 5-15 queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No public API.&lt;/strong&gt; Unlike PubMed, Scholar has no official API. Semantic Scholar is a partial alternative but lacks some long-tail papers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous deduplication.&lt;/strong&gt; The same paper shows up three ways: a preprint on ArXiv, a conference version, a journal version. Collapsing them correctly is non-trivial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Citation counts drift.&lt;/strong&gt; Citation counts update daily, so pipelines need to be idempotent and store snapshots.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On top of all this, research integrity standards (PRISMA 2020, for example) require reproducible search queries and documented exclusion counts. Your scraper has to log its query history.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Preprint vs. published attribution.&lt;/strong&gt; The same paper may be indexed three times (ArXiv preprint, conference proceedings, journal publication) with different DOIs and different citation counts. Scholar sometimes collapses these with a "[PDF] from..." link and sometimes doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-language coverage.&lt;/strong&gt; Scholar does index non-English papers, but its coverage of Chinese, Japanese, and Portuguese literature is uneven. For systematic reviews in medicine or engineering, excluding those corpora is a real bias source.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Query strings + filters]
          |
          v
 [google-scholar-scraper] --&amp;gt; papers with citations, DOIs, authors
          |
          v
 [arxiv-scraper]          --&amp;gt; preprint full text + metadata
          |
          v
 [dedup + embedding cluster]
          |
          v
 [Citation graph (Neo4j or simple DuckDB)]
          |
          v
 [PRISMA flowchart + CSV export]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Search Scholar
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/google-scholar-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;google-scholar-scraper&lt;/strong&gt;&lt;/a&gt; handles Scholar's bot detection and returns structured papers with citation counts and author h-index data.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/google-scholar-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;queries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;large language model hallucination detection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retrieval augmented generation evaluation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2023&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year_to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_results_per_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_patents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_citations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;papers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each paper:&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;"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;"Evaluating Hallucinations in LLMs: A Meta-Analysis"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authors"&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;"Smith, J."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chen, L."&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"venue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NeurIPS 2024"&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;2024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citation_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"doi"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.48550/arXiv.2404.12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"abstract"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"pdf_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://arxiv.org/pdf/2404.12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scholar_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"xYz123"&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;h2&gt;
  
  
  Step 2: Pull ArXiv full text
&lt;/h2&gt;

&lt;p&gt;Many Scholar results are behind paywalls. If a paper has a preprint on ArXiv, use the &lt;a href="https://apify.com/nexgendata/arxiv-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;arxiv-scraper&lt;/strong&gt;&lt;/a&gt; to fetch the full-text PDF link and structured abstract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;arxiv_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;extract_arxiv_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;papers&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arxiv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/arxiv-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arxiv_ids&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;arxiv_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_pdf_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_latex_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;arxiv_papers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Deduplicate across sources
&lt;/h2&gt;

&lt;p&gt;Scholar and ArXiv overlap heavily. Dedup on DOI first, fall back to title similarity:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_dupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fuzz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_set_ratio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;92&lt;/span&gt;

&lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;papers&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;arxiv_papers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;is_dupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;papers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arxiv_papers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; raw -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unique&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Build a citation graph
&lt;/h2&gt;

&lt;p&gt;For snowball sampling — finding papers that cite (or are cited by) a seed set — run forward and backward from high-citation anchor papers. The scraper's &lt;code&gt;include_citations&lt;/code&gt; flag returns Scholar's "Cited by" links.&lt;/p&gt;

&lt;p&gt;Load into DuckDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;
&lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lit_review.duckdb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE papers (scholar_id VARCHAR, title VARCHAR, year INT, citations INT)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executemany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT INTO papers VALUES (?, ?, ?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scholar_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;citation_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&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 ask questions:&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="c1"&gt;-- Most-cited papers in the corpus&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;citations&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;papers&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;citations&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Papers on the topic from the last 12 months with &amp;gt;50 citations (fast movers)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authors&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;papers&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2025&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;citations&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Export a PRISMA-compliant CSV
&lt;/h2&gt;

&lt;p&gt;Systematic reviews require documented inclusion/exclusion counts. Export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prisma.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;venue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;included&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exclusion_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writerow&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scholar_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;year&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authors&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
                    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;venue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doi&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now fill in inclusion/exclusion manually (or via an LLM screening pass) and you have a PRISMA-ready audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: LLM triage (optional but transformative)
&lt;/h2&gt;

&lt;p&gt;If you have harvested 800 papers and realistically only need 50, an LLM screening pass can save a week of skimming. The pattern: feed each paper's title+abstract to a small model with strict inclusion criteria, get a yes/no/maybe, and have a human review only the maybes. This is the same pattern used in Cochrane-style reviews.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;
&lt;span class="n"&gt;oai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;CRITERIA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
Include a paper if ALL are true:
- Published 2023 or later
- Involves retrieval augmented generation (RAG) as primary method
- Reports quantitative evaluation on a named benchmark
- Is a peer-reviewed paper or widely-cited preprint (&amp;gt;20 citations)
Exclude if it is a survey, workshop abstract, or tutorial.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;CRITERIA&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Title: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
Abstract: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;abstract&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Respond with JSON: {{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; | &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exclude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; | &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maybe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;screened&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;screened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;screened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maybe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;screening error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;included&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;screened&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;maybes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;screened&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;decision&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maybe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Included: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;included&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, Needs human: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maybes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, Excluded: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screened&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;included&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maybes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 2025 study in the Journal of Medical Internet Research showed LLM-assisted screening with human review of maybes achieved 96% concordance with a fully-manual Cochrane review while reducing time-to-screen by 73%. The key is the "maybe" category — you do not trust the model to exclude confidently, only to pre-filter and flag uncertainty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. PhD lit review.&lt;/strong&gt; A computational biology student runs Scholar + ArXiv nightly for 30 queries. Wakes up to 200 new papers/week already deduped and ranked by citation velocity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Patent landscape analysis.&lt;/strong&gt; A biotech analyst uses the &lt;code&gt;include_patents=True&lt;/code&gt; flag to pull both papers and patents for CRISPR+gene therapy, surfacing IP overlaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Grant writing.&lt;/strong&gt; A PI needs "key references" for a NIH R01. Automates top-20 papers per aim, by recency, in 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Research trend forecasting.&lt;/strong&gt; A VC analyst counts yearly papers per topic to identify emerging subfields 12-18 months before they trend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Competitor-lab monitoring.&lt;/strong&gt; A pharma R&amp;amp;D team tracks publication output from 12 competing research groups. Weekly Scholar queries by author surface new papers, and an LLM summarization pass compresses them into a one-page Monday briefing. The team reports catching a competitor's methodology pivot roughly 4 months before it appeared in any industry press.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Teaching updated syllabi.&lt;/strong&gt; A university professor auto-refreshes reading lists for their graduate seminar each semester. The pipeline pulls the top 30 most-cited papers matching course topics from the past 18 months. Students get a current reading list instead of one last updated in 2019.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Cost for 10k papers&lt;/th&gt;
&lt;th&gt;Full text?&lt;/th&gt;
&lt;th&gt;Citation graph?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scopus API&lt;/td&gt;
&lt;td&gt;Institutional only&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web of Science&lt;/td&gt;
&lt;td&gt;$300-1000/user/yr&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic Scholar API&lt;/td&gt;
&lt;td&gt;Free (rate-limited)&lt;/td&gt;
&lt;td&gt;Abstract&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scholarly (Python)&lt;/td&gt;
&lt;td&gt;Free (blocked fast)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apify actors&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$15&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (ArXiv)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For anyone without an academic institutional login, Apify is the most economical path to structured Scholar data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;These are the failure modes that turn a promising pipeline into a week of debugging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit yourself.&lt;/strong&gt; Even with the actor's proxy rotation, set &lt;code&gt;max_results_per_query&lt;/code&gt; sensibly. 500 is fine; 5000 will be slow and approaches Scholar's deep-pagination ceiling, where results past page 100 become unreliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Citation counts are snapshots.&lt;/strong&gt; Re-run weekly if you care about trajectory. A paper with 5 citations in January might have 200 by June if it catches on — that velocity is often the signal you actually want, not the absolute count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Author disambiguation.&lt;/strong&gt; Scholar merges authors with the same name. "J. Smith" could be 8 different researchers across Scholar's index. Join on author+affiliation+ORCID when possible. ORCID is the gold standard for disambiguation and is present in about 45% of post-2019 papers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scholar's "include citations" toggle.&lt;/strong&gt; The checkbox that includes citations and patents in search results can wildly inflate counts. Decide early whether you want scholarly articles only or everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preprint vs. final version citation counts.&lt;/strong&gt; ArXiv preprint citations and journal-version citations are often tracked separately. A paper might show 50 citations as a preprint and 200 as the NeurIPS version. Semantic Scholar merges these; Google Scholar sometimes does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retracted papers.&lt;/strong&gt; Scholar does not always flag retractions. Cross-reference with Retraction Watch's database if you are doing medical or biomedical reviews. A retracted paper cited as supporting evidence is worse than not finding the paper at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Year drift in metadata.&lt;/strong&gt; A paper might have &lt;code&gt;year: 2024&lt;/code&gt; in Scholar and &lt;code&gt;year: 2025&lt;/code&gt; in CrossRef because one uses "submitted" and the other uses "published." When you dedup across sources, trust the DOI-resolved year from CrossRef over Scholar's heuristic year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF URL freshness.&lt;/strong&gt; Scholar caches PDF links. Many point to author homepages that 404 a year later. If you need archival access, fetch the PDF at scrape-time and store it in your own bucket, not rely on the URL later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scholar's "sciting" vs. "citing".&lt;/strong&gt; Citations can be positive or negative. For meta-analysis, consider using scite.ai's signal layer on top of your Scholar data to weight supporting vs. contradicting citations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;Scraping Scholar reliably is a full-time job for someone. We built the google-scholar-scraper actor to absorb that work:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anti-bot hardening.&lt;/strong&gt; We rotate residential proxies by geography, use realistic browser fingerprints, and handle Scholar's rotating captcha challenges automatically. You will get substantially more queries through without blocks than a raw Python script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured author + citation output.&lt;/strong&gt; The actor returns authors as separate objects with h-index where available, not as a single flat string. Citation counts, cited-by links, and related-papers links are all preserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DOI extraction and normalization.&lt;/strong&gt; Scholar embeds DOIs inconsistently — sometimes as links, sometimes in snippets, sometimes not at all. The actor extracts and normalizes them so dedup-by-DOI works cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snapshotting support.&lt;/strong&gt; Every run can be tagged with a query fingerprint, so reproducibility for PRISMA audits is built-in. You can replay any past run's exact input.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native pairing with the ArXiv actor.&lt;/strong&gt; The two actors share DOI conventions, so merging their outputs is a straightforward DuckDB join. No impedance mismatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result.&lt;/strong&gt; 10,000 papers costs roughly $15. Compare that to Scopus institutional access at $20k+/year, and it is the clear choice for independent researchers and teams without a university affiliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Literature review in 2026 is an information-retrieval problem, not a reading problem. Automate the collection, deduplication, and ranking steps, then spend your actual brain cycles on the hard part: reading the 30 papers that matter.&lt;/p&gt;

&lt;p&gt;Three actors to start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/google-scholar-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Google Scholar Scraper&lt;/strong&gt;&lt;/a&gt; — bot-detection-proof Scholar access.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/arxiv-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;ArXiv Scraper&lt;/strong&gt;&lt;/a&gt; — full preprint metadata and PDF links.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/wikipedia-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Wikipedia Scraper&lt;/strong&gt;&lt;/a&gt; — for background context on unfamiliar fields.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is scraping Google Scholar allowed?&lt;/strong&gt;&lt;br&gt;
Scholar's ToS prohibit automated access, and Google does actively rate-limit. That said, academic use of search data has been treated leniently in practice, and the aggregation of public bibliometric data is broadly considered fair use. If you publish research based on scraped data, cite Scholar as your source. For true systematic reviews with publication requirements, supplement Scholar with official databases (PubMed, Scopus) that have APIs and documented ToS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does this compare to Semantic Scholar's API?&lt;/strong&gt;&lt;br&gt;
Semantic Scholar has a clean API and free tier (100 req/5min). Its coverage is strong for CS, medicine, and physics but weaker for humanities and some regional journals. For thorough systematic reviews, use both: Scholar for coverage breadth, Semantic Scholar for citation graphs and clean metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about OpenAlex?&lt;/strong&gt;&lt;br&gt;
OpenAlex is an open bibliometric database covering 240M+ works with a free, unlimited API. It is excellent for citation graphs and author profiles, often better than Scholar for those specific tasks. Scholar still wins for "find me the most-read recent paper on X," because its ranking is informed by actual reader behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I get full-text PDFs reliably?&lt;/strong&gt;&lt;br&gt;
For ArXiv papers, yes. For anything else, it depends on open-access status. The pipeline can fetch PDFs where URLs are public, but paywalled papers need either your institutional login, an interlibrary loan, or (for some reviews) contacting authors directly for a copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I cite papers I find this way in my own writing?&lt;/strong&gt;&lt;br&gt;
Export the deduped set to BibTeX via &lt;code&gt;pandoc-citeproc&lt;/code&gt; or the &lt;code&gt;bibtexparser&lt;/code&gt; Python library. The &lt;code&gt;doi&lt;/code&gt; field in the output is what most reference managers key on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about non-English literature?&lt;/strong&gt;&lt;br&gt;
The actor passes through whatever Scholar returns, which includes non-English papers if your query matches them. For comprehensive non-English coverage, pair with regional databases (CNKI for Chinese, CiNii for Japanese, SciELO for Latin American journals).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle the "forward snowball" (papers that cite my seed papers)?&lt;/strong&gt;&lt;br&gt;
Use the &lt;code&gt;include_citations=True&lt;/code&gt; flag and the "cited by" URL in the Scholar response. Run a second pass with those URLs as seeds. Two iterations of forward snowballing typically saturates a well-defined subfield.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/google-scholar-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;Google Scholar Scraper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/arxiv-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;ArXiv Scraper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/wikipedia-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;Wikipedia Scraper&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>research</category>
    </item>
    <item>
      <title>Bulk Domain WHOIS Lookup — API Alternatives to Whois.com (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Tue, 11 Aug 2026 21:34:44 +0000</pubDate>
      <link>https://dev.to/nexgendata/bulk-domain-whois-lookup-api-alternatives-to-whoiscom-2026-3pij</link>
      <guid>https://dev.to/nexgendata/bulk-domain-whois-lookup-api-alternatives-to-whoiscom-2026-3pij</guid>
      <description>&lt;h1&gt;
  
  
  Bulk Domain WHOIS Lookup — API Alternatives to Whois.com (2026)
&lt;/h1&gt;

&lt;p&gt;If you have ever tried to look up WHOIS info for more than 50 domains in a row, you know the drill: whois.com throws a captcha, the &lt;code&gt;whois&lt;/code&gt; CLI stops resolving halfway through, and GoDaddy's WHOIS widget silently starts returning empty responses after some undocumented throttle. Running proper bulk WHOIS in 2026 is harder than it should be — thanks in part to GDPR redactions, thick vs. thin registrar models, and a fragmented TLD landscape.&lt;/p&gt;

&lt;p&gt;A quick 2026 snapshot to set the stage: there are roughly 359 million registered domains globally (Verisign Domain Name Industry Brief, Q4 2025), spread across 1,500+ TLDs, with &lt;code&gt;.com&lt;/code&gt; still dominant at ~157M and newer TLDs like &lt;code&gt;.ai&lt;/code&gt;, &lt;code&gt;.xyz&lt;/code&gt;, &lt;code&gt;.io&lt;/code&gt;, and &lt;code&gt;.dev&lt;/code&gt; growing 20-40% year-over-year. The WHOIS ecosystem used to be a single TCP-port-43 protocol with reasonably uniform output, but ICANN's 2024 mandate requiring all registries to support RDAP (Registration Data Access Protocol) means you are now living in a hybrid world: some registries serve RDAP JSON, some still serve legacy WHOIS text, and a surprising number serve both with subtly different field names. GDPR redactions, in effect since May 2018, hide registrant personal data by default for EU-registered domains and in practice for most registrars globally. The mental model of "WHOIS is a phonebook for domains" is outdated. The accurate model is "WHOIS is a fragmented, rate-limited, partially-redacted dataset that requires an aggregation layer to use at scale."&lt;/p&gt;

&lt;p&gt;This post compares the main options for bulk WHOIS in 2026, shows how to build a production pipeline in Python, and covers the use cases that actually justify the effort: domain monitoring, brand protection, cybersecurity triage, and M&amp;amp;A diligence. If you are doing any of those at more than handful-of-domains scale, you need an automated pipeline, not a browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;p&gt;WHOIS looks simple — one TCP connection to port 43, one record back. Reality:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every TLD has its own WHOIS server and response format.&lt;/strong&gt; &lt;code&gt;.com&lt;/code&gt; uses Verisign, &lt;code&gt;.io&lt;/code&gt; uses NIC.IO, country-code TLDs each run their own registry. Parsing is a mess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GDPR redactions hide most registrant data.&lt;/strong&gt; Since 2018, EU-registered domains no longer expose owner names or contact emails. Some registrars redact all domains, not just EU ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits vary wildly.&lt;/strong&gt; Verisign thin WHOIS tolerates ~20 queries/sec, but query the same &lt;code&gt;.com&lt;/code&gt; registrar's thick WHOIS and you will see 5 queries/sec before blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RDAP is slowly replacing WHOIS.&lt;/strong&gt; Some registries return only RDAP JSON now. Your parser needs both code paths.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rolling this yourself means maintaining per-TLD parsers, proxy pools, and RDAP fallbacks. Most teams burn a week on it and then the parser breaks again when &lt;code&gt;.ai&lt;/code&gt; changes its format.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Intermittent TLDs.&lt;/strong&gt; Some ccTLDs (&lt;code&gt;.ke&lt;/code&gt;, &lt;code&gt;.pk&lt;/code&gt;, &lt;code&gt;.sa&lt;/code&gt;) are only available via web-form WHOIS with no programmatic endpoint. Others require paid registry access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale cache semantics.&lt;/strong&gt; Registry responses include a varying TTL. If you query a domain right after a transfer, you may get old registrar data for hours. Knowing when to retry vs. accept staleness is a heuristic, not a rule.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Domain list (CSV, 10k rows)]
           |
           v
    [Apify WHOIS actor] -- rotating proxies, RDAP fallback, TLD-aware parsing
           |
           v
    [Structured JSON dataset]
           |
           v
 [DuckDB / Postgres] -- dedupe + expiry alerting
           |
           v
     [Slack / email] -- "10 domains expire in 30 days"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core building block is the &lt;a href="https://apify.com/nexgendata/domain-whois-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;domain-whois-lookup&lt;/strong&gt;&lt;/a&gt; actor, which handles TLD routing, RDAP fallback, rate-limit evasion, and parsing into a normalized schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: One-shot bulk lookup
&lt;/h2&gt;



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

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

&lt;span class="n"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/domain-whois-lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rdap_fallback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Processed &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A typical result looks like:&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;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registrar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MarkMonitor Inc."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registrar_iana_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;292&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"creation_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1995-08-14T04:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expiry_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2028-08-13T04:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"last_updated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-08-14T07:01:23Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nameservers"&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;"a.iana-servers.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b.iana-servers.net"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dnssec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signedDelegation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"clientDeleteProhibited"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clientTransferProhibited"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"registrant_country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"redacted"&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;Notice the &lt;code&gt;redacted: true&lt;/code&gt; flag. You still get registrar, nameservers, and dates — which is 80% of what most use cases need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Watch for expiries
&lt;/h2&gt;

&lt;p&gt;Once results land in your dataset, a 10-line SQL query surfaces domains expiring soon:&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;SELECT&lt;/span&gt; &lt;span class="k"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;registrar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiry_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;date_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiry_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;days_left&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;whois_results&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;expiry_date&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;expiry_date&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire this into a scheduled Apify run (daily), then a cron job that posts the result to Slack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="n"&gt;slack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://hooks.slack.com/services/...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;days_until_expiry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Domains expiring soon:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;expiry_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Join with DNS
&lt;/h2&gt;

&lt;p&gt;WHOIS alone is often not enough. Pair it with DNS records and you get a much richer picture: who owns it, where it resolves, and whether it is parked.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/dns-propagation-checker?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;dns-propagation-checker&lt;/strong&gt;&lt;/a&gt; actor resolves A, AAAA, MX, and TXT across 15+ resolvers. Combined with WHOIS you can answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which competitor domains changed nameservers this week?&lt;/li&gt;
&lt;li&gt;Which newly-registered typosquats already have MX records (meaning they are phishing-ready)?&lt;/li&gt;
&lt;li&gt;Which domains in our portfolio are pointed at the wrong CDN?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a reasonably complete typosquat-detection snippet that fans out permutations of a brand, runs WHOIS on each, and flags any that were registered in the last 30 days. This is the bones of a real brand-protection pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;apify_client&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ApifyClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;

&lt;span class="n"&gt;BRAND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acmecorp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;TLDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.net&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.org&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.co&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.io&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.ai&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.dev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;permutations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# character-swap, leet-speak, and homoglyph basics
&lt;/span&gt;    &lt;span class="n"&gt;swaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;e&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;l&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;alt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;swaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&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;out&lt;/span&gt;

&lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;permutations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BRAND&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;TLDS&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/domain-whois-lookup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rdap_fallback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;new_squats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;creation_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;creation_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+00:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;new_squats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_squats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; newly-registered typosquat candidates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_squats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; registered &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;creation_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; via &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;registrar&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A similar pipeline in Node.js for teams more comfortable in JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ApifyClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apify-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readTextFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;portfolio.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nexgendata/domain-whois-lookup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;domains&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rdap_fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALERT_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expiring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;iterate&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiry_date&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiry_date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400000&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;days&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ALERT_DAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;expiring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;days&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;expiring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiring&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;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;registrar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&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;domain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;registrar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="p"&gt;})));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Brand protection / typosquatting monitoring.&lt;/strong&gt; A SaaS company monitors 300 domain permutations of their brand. Daily WHOIS runs flag newly-registered lookalikes within 24 hours, triggering legal takedowns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. M&amp;amp;A tech diligence.&lt;/strong&gt; An acquirer runs WHOIS on every domain owned by the target. Finds three domains registered to an ex-founder's personal email — caught before close.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Cybersecurity triage.&lt;/strong&gt; An incident responder receives 500 suspicious URLs. Bulk WHOIS reveals 240 were registered in the past 14 days at the same registrar — strong phishing-campaign signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Affiliate / SEO portfolio management.&lt;/strong&gt; An affiliate marketer owns 120 domains. Monthly WHOIS batches feed a spreadsheet of expiries, saving at least one "oh no I forgot to renew" event per year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Drop-catching research.&lt;/strong&gt; A domain investor monitors expiring high-value domains by running WHOIS against a curated 10k watchlist weekly. When a domain moves into the 30-day redemption period, a Slack alert routes it to a review queue. In 2025 the same investor caught a 4-letter &lt;code&gt;.com&lt;/code&gt; that would have gone to auction for low five figures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Phishing kit attribution.&lt;/strong&gt; A security vendor scrapes newly observed phishing URLs from public feeds (URLHaus, OpenPhish), runs WHOIS on the domains, and clusters by registrar + nameserver + creation date. Patterns emerge quickly: 60% of a week's phishing campaigns might trace back to 3 registrars. That kind of clustering is not possible without a WHOIS pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;10k lookups cost&lt;/th&gt;
&lt;th&gt;Historical?&lt;/th&gt;
&lt;th&gt;RDAP?&lt;/th&gt;
&lt;th&gt;Bulk CSV?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WhoisXML API&lt;/td&gt;
&lt;td&gt;~$100&lt;/td&gt;
&lt;td&gt;Paid add-on&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DomainTools Iris&lt;/td&gt;
&lt;td&gt;$395+/mo&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JsonWhoisAPI&lt;/td&gt;
&lt;td&gt;~$80&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whois.com manual&lt;/td&gt;
&lt;td&gt;Free (captcha)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NexGenData actor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$15&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the vast majority of use cases — bulk triage, expiry monitoring, diligence — you do not need $400/mo DomainTools. Pay-per-result at $0.0015/domain is comfortably within hobby-budget range.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;WHOIS is one of those protocols where every shortcut has a hidden cost. These are the ones that catch people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.ai&lt;/code&gt; and &lt;code&gt;.io&lt;/code&gt; change formats.&lt;/strong&gt; Expect occasional parsing misses on exotic ccTLDs. The actor updates parsers monthly, but if you are parsing raw responses yourself, budget for format drift every quarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registrar WHOIS vs. registry WHOIS.&lt;/strong&gt; Thick TLDs (.org, .info) return full data from the registry. Thin TLDs (.com) require a second hop to the registrar. The actor handles this transparently, but raw WHOIS CLIs do not. If you are using the &lt;code&gt;whois&lt;/code&gt; CLI and seeing minimal data for &lt;code&gt;.com&lt;/code&gt;, you are probably reading the Verisign thin response and missing the registrar follow-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queries are cached.&lt;/strong&gt; Most registries serve cached data. Same-second repeated queries will not reflect updates in real time. For change detection, poll every 6-24 hours, not every minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premium WHOIS is its own thing.&lt;/strong&gt; Some registrars charge extra for "WHOIS history" — time-series data of how WHOIS records changed. If you need that (common for cyber threat intel), only WhoisXML or DomainTools offer it at scale, though you can build a historical dataset yourself by snapshotting daily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Punycode and IDN domains.&lt;/strong&gt; A domain like &lt;code&gt;bücher.de&lt;/code&gt; shows up as &lt;code&gt;xn--bcher-kva.de&lt;/code&gt; in WHOIS. If your input list has UTF-8 brand names, convert to punycode before lookup, or the query will fail silently. Python has &lt;code&gt;idna.encode()&lt;/code&gt; for this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Glue records.&lt;/strong&gt; WHOIS returns nameserver hostnames, but not their IPs. If you are tracking infrastructure migration, you also want the nameserver's A/AAAA records, which requires a follow-up DNS query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact email redactions.&lt;/strong&gt; GDPR redacts the registrant email, but sometimes the registrar exposes a forwarding address like &lt;code&gt;contact@domainsbyproxy.com&lt;/code&gt;. These are useless for direct contact but reveal the proxy service, which is an attribution signal in itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registrar name inconsistencies.&lt;/strong&gt; "GoDaddy", "GoDaddy.com, LLC", and "GoDaddy Online Services Cayman Islands Ltd." are all the same company. Normalize on IANA ID (the &lt;code&gt;registrar_iana_id&lt;/code&gt; field), not the string name, if you are clustering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry vs. grace period vs. redemption.&lt;/strong&gt; A domain past its &lt;code&gt;expiry_date&lt;/code&gt; is not immediately available. It enters a 30-day grace, then a 30-day redemption, then a 5-day pending-delete window. Drop-catching pipelines need to model all four phases correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;The domain-whois-lookup actor was built after we watched too many teams reinvent the same broken parser. Specific design choices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dual WHOIS + RDAP by default.&lt;/strong&gt; Every lookup tries RDAP first (faster, structured JSON, consistent schema) and falls back to legacy WHOIS if RDAP is unavailable. You get the best data the registry will give, without having to code two pipelines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1,500+ TLD parsers maintained.&lt;/strong&gt; We monitor TLD response-format changes and push parser updates on a rolling basis. If &lt;code&gt;.ai&lt;/code&gt; changes its format next month, you do not need to do anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic thick-TLD follow-through.&lt;/strong&gt; For &lt;code&gt;.com&lt;/code&gt;/&lt;code&gt;.net&lt;/code&gt;, we follow Verisign's referral to the registrar and merge both responses into a single row. No double-query logic on your side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IANA-ID based registrar normalization.&lt;/strong&gt; The output always includes both the raw string and the canonical IANA ID, so clustering and reporting work out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxy rotation and polite rate-limiting.&lt;/strong&gt; We fingerprint each TLD's tolerance and adjust concurrency automatically. You can pass 10k domains at once and the actor will schedule them appropriately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No API key, no seat fees, pay per result.&lt;/strong&gt; $0.0015 per lookup. 10,000 domains costs $15. There is no monthly commitment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Bulk WHOIS is a classic example of "seems trivial, actually has 15 edge cases." Rather than maintain per-TLD parsers yourself, point Apify at your domain list, schedule it, and wire the dataset into your alerting pipeline. You get coverage across 1,500+ TLDs, RDAP fallback, and normalized JSON out of the box.&lt;/p&gt;

&lt;p&gt;Start here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/domain-whois-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Domain WHOIS Lookup&lt;/strong&gt;&lt;/a&gt; — core WHOIS + RDAP with pay-per-result pricing.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/dns-propagation-checker?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;DNS Propagation Checker&lt;/strong&gt;&lt;/a&gt; — resolve across 15 global DNS servers.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/company-tech-stack-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Tech Stack Detector&lt;/strong&gt;&lt;/a&gt; — fingerprint the websites behind those domains.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is bulk WHOIS legal?&lt;/strong&gt;&lt;br&gt;
Yes. WHOIS is a public protocol mandated by ICANN for most generic TLDs. You cannot use WHOIS data for spam or direct marketing to registrants (that violates most registrar AUPs and GDPR). You can use it for security research, portfolio management, diligence, and trademark enforcement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does GDPR prevent me from getting any useful data?&lt;/strong&gt;&lt;br&gt;
No. GDPR redacts personal registrant info (name, email, address for individuals), but registrar, nameservers, creation date, expiry, and status codes are still public. For most operational use cases (monitoring, expiry, security triage) that is plenty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What if I need the redacted data — say, for a trademark claim?&lt;/strong&gt;&lt;br&gt;
Use the registrar's abuse or legal contact channel with a formal request. Most registrars will disclose registrant info to a court order, UDRP filing, or legitimate IP complaint. The WHOIS API cannot magic it through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RDAP vs. WHOIS — which should I use?&lt;/strong&gt;&lt;br&gt;
RDAP is the future and should be your default. It returns structured JSON, supports HTTPS, includes authenticated access paths for LEA, and is what ICANN is pushing. Legacy WHOIS should be a fallback. The actor handles both automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should I re-lookup a domain?&lt;/strong&gt;&lt;br&gt;
For expiry monitoring: weekly is plenty. For change detection (nameserver flips, registrar transfers): daily. For active incident response: on-demand is fine. There is rarely a reason to poll more than once per 24h per domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I bulk-register domains via this pipeline?&lt;/strong&gt;&lt;br&gt;
No. WHOIS is read-only. Registration happens at a registrar (GoDaddy, Namecheap, Porkbun, Google Domains) via their API. You can use the WHOIS pipeline to find available domains and then register them separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about new gTLDs like &lt;code&gt;.xyz&lt;/code&gt;, &lt;code&gt;.app&lt;/code&gt;, &lt;code&gt;.dev&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
All supported. The newer gTLDs almost all serve RDAP, which actually makes them easier to handle than some legacy ccTLDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/domain-whois-lookup?fpr=2ayu9b" rel="noopener noreferrer"&gt;Domain WHOIS Lookup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/dns-propagation-checker?fpr=2ayu9b" rel="noopener noreferrer"&gt;DNS Propagation Checker&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/company-tech-stack-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;Company Tech Stack Detector&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>whois</category>
    </item>
    <item>
      <title>Building a Real-Time FX Dashboard with Apify + Google Sheets (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Thu, 06 Aug 2026 20:53:01 +0000</pubDate>
      <link>https://dev.to/nexgendata/building-a-real-time-fx-dashboard-with-apify-google-sheets-2026-d8b</link>
      <guid>https://dev.to/nexgendata/building-a-real-time-fx-dashboard-with-apify-google-sheets-2026-d8b</guid>
      <description>&lt;h1&gt;
  
  
  Building a Real-Time FX Dashboard with Apify + Google Sheets (2026)
&lt;/h1&gt;

&lt;p&gt;Every international SaaS founder eventually hits the same wall: "what is our real MRR in USD this week?" You multiply your EUR, GBP, CAD, and INR invoices by whatever Stripe happened to use on the settlement date, and the number drifts 3-5% every month depending on EUR/USD. Multiply that by a currency basket of payouts, vendor invoices, and contractor pay, and a proper FX dashboard stops being optional.&lt;/p&gt;

&lt;p&gt;In 2026, FX volatility is still a meaningful P&amp;amp;L factor — the DXY (US Dollar Index) moved a cumulative 14% over the trailing 12 months, and emerging-market pairs like USD/TRY and USD/ARS have shifted by 40%+ in single quarters. A BIS Triennial Survey (April 2025 preliminary data) pegged daily global FX turnover at $8.3 trillion, with the retail and SMB slice growing roughly 11% year-over-year as more small businesses operate cross-border through platforms like Stripe, Wise, and Payoneer. The mental model for a small international business used to be "we will true up once a quarter with the accountant." That model is now a quiet tax of 2-4% of gross revenue for any business with meaningful non-home-currency flow. You want a live dashboard because it surfaces the moments where hedging, timing a wire, or repricing in a market actually moves the needle.&lt;/p&gt;

&lt;p&gt;Commercial FX APIs (Fixer, OpenExchangeRates, CurrencyLayer) charge $50-300/month for real-time rates once you exceed the hobbyist tier. More expensive options like XE or Bloomberg's FX datafeed run $800-$2400/month and assume you have a Bloomberg Terminal already. In this guide we build the same thing for the cost of a coffee: an Apify-powered pipeline that writes directly into a Google Sheet your CFO, accountant, or dashboard can read. The end state is a spreadsheet that refreshes every 15 minutes during market hours, writes historical snapshots to a second tab for trend analysis, and fires a conditional-formatted cell red when a pair moves more than 1% intraday. You will spend about an hour setting it up and effectively zero minutes maintaining it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;p&gt;Free FX data has three traps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Staleness.&lt;/strong&gt; &lt;code&gt;exchangerate.host&lt;/code&gt; and similar free endpoints cache aggressively, so rates lag market by 30+ minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Midpoint vs. market.&lt;/strong&gt; ECB midpoints are not what your bank actually pays. For realistic P&amp;amp;L you want the rate Yahoo Finance, Google Finance, or XE show — which are retail-skewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits on free tiers.&lt;/strong&gt; 1,000 requests/month sounds like a lot until you poll 30 currency pairs every 15 minutes (that is 86k requests/month).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We will dodge all three by combining two Apify actors with Google Sheets' &lt;code&gt;IMPORTDATA&lt;/code&gt; pattern plus a scheduled refresh.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ticker-symbol mismatches across providers.&lt;/strong&gt; Fixer uses ISO-4217 codes only. Yahoo Finance uses a &lt;code&gt;XXXYYY=X&lt;/code&gt; convention. TradingView uses &lt;code&gt;FX:EURUSD&lt;/code&gt;. If you ever need to cross-reference, plan for a symbol-mapping table from day one, not day 30.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forward rates and spreads.&lt;/strong&gt; Spot rates are what every free API serves. If you actually need forward rates (common for treasury hedging or SaaS with annual contracts), free sources are useless. We flag this below so you know when to escalate to a paid feed.&lt;/li&gt;
&lt;/ol&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[currency-exchange-rates actor] --&amp;gt; FX midpoint rates (150+ ccys)
[yahoo-finance-scraper actor]   --&amp;gt; cross-check retail rates + FX-correlated tickers
          |
          v
    Apify dataset (JSON)
          |
          v
   Google Apps Script pulls via Apify API
          |
          v
   Google Sheet (live dashboard)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Pull real-time rates
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://apify.com/nexgendata/currency-exchange-rates?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;currency-exchange-rates&lt;/strong&gt;&lt;/a&gt; actor fetches rates for 150+ currencies without an API key. Pay-per-result means you only pay for what you poll.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/currency-exchange-rates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_currencies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CAD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_currencies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CAD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHF&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SGD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BRL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MXN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ZAR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_historical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;rates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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;"base"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rate"&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.9184&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-17T13:05:11Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ecb_midpoint"&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;h2&gt;
  
  
  Step 2: Cross-check with Yahoo Finance
&lt;/h2&gt;

&lt;p&gt;Midpoint rates are fine for accounting. For trading signals or vendor-comparison we want whatever real humans see. The &lt;a href="https://apify.com/nexgendata/yahoo-finance-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;yahoo-finance-scraper&lt;/strong&gt;&lt;/a&gt; pulls live FX tickers like &lt;code&gt;EURUSD=X&lt;/code&gt;, &lt;code&gt;GBPUSD=X&lt;/code&gt;, and &lt;code&gt;USDJPY=X&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;fx_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/yahoo-finance-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symbols&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EURUSD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBPUSD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDJPY=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDCAD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUDUSD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDSGD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_history&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;history_range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yahoo also includes bid, ask, and day high/low — useful for setting FX alerts.&lt;/p&gt;

&lt;p&gt;Here is an expanded Python example that combines both actors, computes the spread between midpoint and retail, and flags rates that deviate more than 0.4% (a reasonable bank markup threshold):&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="c1"&gt;# 1. midpoints
&lt;/span&gt;&lt;span class="n"&gt;mid_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/currency-exchange-rates&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;base_currencies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_currencies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EUR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JPY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CAD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHF&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;midpoints&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;base&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rate&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
             &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mid_run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="c1"&gt;# 2. retail tickers
&lt;/span&gt;&lt;span class="n"&gt;retail_run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/yahoo-finance-scraper&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symbols&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EURUSD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GBPUSD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDJPY=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDCAD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUDUSD=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDCHF=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USDINR=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;retail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retail_run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;pair&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;symbol&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=X&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;retail&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regular_market_price&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# 3. compare
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Pair&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Mid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Retail&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Spread&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;midpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# invert if needed since Yahoo uses different conventions
&lt;/span&gt;    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;retail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;retail&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;retail&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;spread_pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="n"&gt;flag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &amp;lt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spread_pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.4&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pair&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;10.4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;10.4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;spread_pct&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;9.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flag&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly the kind of script you would put on a 4-hour cron that DMs you whenever a bank-sized spread appears on a pair you are about to settle. It is one of those little scripts that pays for itself the first time it saves you from wiring at a bad rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Write to Google Sheets
&lt;/h2&gt;

&lt;p&gt;Two viable patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A. Google Apps Script pull (my preferred approach).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add this as an Apps Script attached to the sheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refreshFX&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nexgendata~currency-exchange-rates&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://api.apify.com/v2/acts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/run-sync-get-dataset-items?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;base_currencies&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;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;target_currencies&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;EUR&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;GBP&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;CAD&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;JPY&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;INR&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;AUD&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;CHF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;payload&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;payload&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;rows&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentText&lt;/span&gt;&lt;span class="p"&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActive&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getSheetByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;clearContent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&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;Attach it to a time-driven trigger: every 15 minutes during business hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B. Apify Google Sheets integration.&lt;/strong&gt; The Apify platform has a first-class integration that pushes dataset output directly to a sheet on run completion. No code — just toggle it on in the actor's integrations tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Build the dashboard
&lt;/h2&gt;

&lt;p&gt;In the sheet, compute derived metrics:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cell&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;D2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=VLOOKUP("EUR", FX!A:B, 2, FALSE)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current USD/EUR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;E2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=(D2 - D_yesterday)/D_yesterday&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24h change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;F2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=SPARKLINE(FX_HISTORY!B2:B30)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30-day trend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;G2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=revenue_EUR * D2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Revenue in USD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Conditional-format the 24h change cells red/green. You now have a one-glance view of FX exposure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. SaaS MRR in reporting currency.&lt;/strong&gt; An indie founder bills in 6 currencies. A nightly Apify run writes rates into the sheet, and a formula converts the Stripe export to USD MRR automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Crypto + FX correlation dashboard.&lt;/strong&gt; A trader pulls EUR/USD alongside BTC-USD via Yahoo Finance and eyeballs correlation breakouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Contractor payment timing.&lt;/strong&gt; An agency pays 30 contractors monthly in their local currency. The dashboard recommends which week to initiate wires based on 30-day rate trends, saving 1-2% per cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. International e-commerce pricing.&lt;/strong&gt; A Shopify store localizes prices quarterly. The FX dashboard feeds a pricing-review meeting — when EUR/USD moves more than 3%, prices are rebalanced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Freelancer invoicing in volatile currencies.&lt;/strong&gt; A software consultancy that bills in EUR and TRY uses the dashboard to decide when to invoice. When USD/TRY opens more than 2% above its 5-day moving average, invoices go out that morning. When below, they hold until end-of-week. Over 2025 the effect was a measured 4.1% revenue uplift versus invoicing on a fixed day-of-month schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Corporate travel treasury.&lt;/strong&gt; A 40-person startup with a US HQ and remote teams in Mexico and Portugal uses the dashboard to time its bi-monthly payroll-funding transfers. The CFO committed to a simple rule: fund ahead only when spot is within 0.75% of the 30-day low. Annual FX friction on $1.2M of non-USD payroll dropped from an implicit 2.3% to 1.1%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Monthly cost (30 pairs, 15-min refresh)&lt;/th&gt;
&lt;th&gt;API key?&lt;/th&gt;
&lt;th&gt;Historical?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixer Professional&lt;/td&gt;
&lt;td&gt;$49.99&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenExchangeRates&lt;/td&gt;
&lt;td&gt;$97&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CurrencyLayer Plus&lt;/td&gt;
&lt;td&gt;$79.99&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XE Rates API&lt;/td&gt;
&lt;td&gt;$799&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Apify actors&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$4-8 pay-per-result&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For bedroom-grade usage up through moderate business volume, pay-per-result is dramatically cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;These are the sharp edges that will bite you the first week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekend pricing.&lt;/strong&gt; FX markets close Friday 5pm ET and reopen Sunday 5pm ET. Weekend "rates" are the Friday close — do not display them as live without a clear "market closed" badge, or your CFO will think they are seeing the latest price and plan a wire against a stale number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apps Script quotas.&lt;/strong&gt; Free Google accounts cap &lt;code&gt;UrlFetchApp&lt;/code&gt; at 20k calls/day, plenty for FX but plan accordingly. Google Workspace business accounts are capped at 100k/day. If you refresh every 5 minutes and hit 3 actors per refresh, you are still under 30k/day for a single sheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps.&lt;/strong&gt; Always store the source timestamp, not &lt;code&gt;new Date()&lt;/code&gt;. If the scrape took 4 minutes, you want the actual market time. A subtle variant of this bug: storing only the Unix epoch with no timezone. Sheets formats epoch times in the sheet's timezone by default, which is fine until you share the dashboard with someone in another region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Holiday calendars.&lt;/strong&gt; Major pairs close on different days. Thanksgiving is a half-day for USD pairs but normal for GBPJPY. Golden Week compresses JPY liquidity. If you are building alerts, consider a holiday-calendar lookup or your "price unchanged for 24 hours" alert will scream every major holiday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stablecoin confusion.&lt;/strong&gt; Some feeds include USDC, USDT, and BUSD alongside fiat. These are close to USD but not identical. During stress events (Terra/Luna 2022, SVB weekend 2023) they can deviate 3-8% for hours. If your pipeline treats them as USD proxies, you will miss real risk signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate directionality.&lt;/strong&gt; Yahoo quotes some pairs inverted versus how you think about them (USDJPY vs JPYUSD). Always verify the direction and normalize to a canonical "1 unit of BASE = x units of TARGET" form before storing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sheet cell limit.&lt;/strong&gt; Google Sheets has a 10 million cell cap per workbook. If you log every 15 minutes across 30 pairs for a year, you are at ~1 million cells. Fine, but design your schema so you are not stuck migrating later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cached IMPORTDATA.&lt;/strong&gt; &lt;code&gt;IMPORTDATA&lt;/code&gt; and &lt;code&gt;IMPORTXML&lt;/code&gt; are aggressively cached by Google — up to 1 hour. If you need sub-hourly freshness, use Apps Script &lt;code&gt;UrlFetchApp&lt;/code&gt; instead, which respects HTTP cache headers you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;The two actors powering this pipeline are purpose-built for "pull into a spreadsheet" workflows, which drives a few specific design decisions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No API keys, ever.&lt;/strong&gt; The currency-exchange-rates actor aggregates from multiple public sources (ECB, national central banks, and a handful of bank-rate scrapers) and never requires you to register for anything. Start time from "I want to try this" to "first row in my sheet" is under 90 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamp-per-rate, not per-batch.&lt;/strong&gt; Every row in the output includes the timestamp from the source it came from. If ECB updated 30 seconds ago and Bank of Canada updated 3 hours ago, you see that asymmetry directly. Most commercial APIs smooth this over and pretend everything is "current."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in symbol normalization.&lt;/strong&gt; The actor accepts both ISO-4217 codes (&lt;code&gt;USD&lt;/code&gt;, &lt;code&gt;EUR&lt;/code&gt;) and Yahoo-style tickers (&lt;code&gt;EURUSD=X&lt;/code&gt;) and normalizes them internally. No adapter code needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apify's native Sheets integration.&lt;/strong&gt; Skip the Apps Script entirely and use Apify's built-in Google Sheets integration for the most common "append to a sheet on each run" pattern. Zero code if your needs are simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result economics.&lt;/strong&gt; A full 150-currency pull costs roughly $0.004. You can run this every 5 minutes during market hours for under $10/month. That compares to Fixer's $50 Professional tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A solid FX dashboard is a 90-minute project with Apify + Google Sheets. You avoid another SaaS subscription, you own the data, and the marginal cost scales with your actual polling — not a flat $50/month tier.&lt;/p&gt;

&lt;p&gt;Spin up these three actors to go further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/currency-exchange-rates?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Currency Exchange Rates&lt;/strong&gt;&lt;/a&gt; — 150+ currencies, no API key, pay per result.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/yahoo-finance-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Yahoo Finance Scraper&lt;/strong&gt;&lt;/a&gt; — FX tickers, stock prices, historical data.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/finviz-stock-screener?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Finviz Stock Screener&lt;/strong&gt;&lt;/a&gt; — layer in equity signals correlated to FX.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How fresh are the rates, really?&lt;/strong&gt;&lt;br&gt;
The ECB midpoint source updates once per business day at approximately 16:00 CET. Yahoo Finance FX tickers update every 1-2 minutes during market hours. If you need tick-level data (sub-second), you need a paid broker feed, not any of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is this compliant for accounting purposes?&lt;/strong&gt;&lt;br&gt;
For internal reporting and decision-making, yes. For statutory filings your accountant should use a documented reference rate (often the central bank's end-of-period rate). Most accountants are fine with ECB or Bank of Canada rates, which the actor emits with source attribution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use this for forex trading signals?&lt;/strong&gt;&lt;br&gt;
You can build signals off of it, but understand the latency floor. For anything faster than 5-minute swing trading, use a proper broker API (OANDA, Interactive Brokers, FXCM). This pipeline is for reporting, treasury, and business decisions, not HFT.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about exotic currencies like KZT, UGX, or XOF?&lt;/strong&gt;&lt;br&gt;
The actor covers 150+ currencies including most African and Central Asian currencies. Exotic pairs tend to have wider mid-retail spreads (the bank markup is real money), so always cross-check against a local source before relying on them for large transactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just use Google Finance's &lt;code&gt;GOOGLEFINANCE()&lt;/code&gt; function?&lt;/strong&gt;&lt;br&gt;
You can for basic rates, but &lt;code&gt;GOOGLEFINANCE&lt;/code&gt; does not give you historical snapshots in a clean format, no bid/ask, no central-bank source attribution, and no programmatic access from other tools. For a single pair on one sheet it is fine — for a real dashboard the Apify approach wins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle multi-currency invoices automatically?&lt;/strong&gt;&lt;br&gt;
Use a sheet with an &lt;code&gt;INVOICES&lt;/code&gt; tab pulling from Stripe via CSV export or their API, and a formula that looks up the rate from the &lt;code&gt;FX&lt;/code&gt; tab by the invoice's issue date. For audit trail, store both the rate used and its source timestamp alongside the invoice row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/currency-exchange-rates?fpr=2ayu9b" rel="noopener noreferrer"&gt;Currency Exchange Rates&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/yahoo-finance-scraper?fpr=2ayu9b" rel="noopener noreferrer"&gt;Yahoo Finance Scraper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/finviz-stock-screener?fpr=2ayu9b" rel="noopener noreferrer"&gt;Finviz Stock Screener&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>fx</category>
    </item>
    <item>
      <title>How to Monitor Competitor Shopify Stores at Scale (2026)</title>
      <dc:creator>NexGenData</dc:creator>
      <pubDate>Tue, 04 Aug 2026 19:11:43 +0000</pubDate>
      <link>https://dev.to/nexgendata/how-to-monitor-competitor-shopify-stores-at-scale-2026-l4n</link>
      <guid>https://dev.to/nexgendata/how-to-monitor-competitor-shopify-stores-at-scale-2026-l4n</guid>
      <description>&lt;h1&gt;
  
  
  How to Monitor Competitor Shopify Stores at Scale (2026)
&lt;/h1&gt;

&lt;p&gt;If you sell on Shopify, your competitors are a goldmine of data. Every new product launch, sold-out SKU, and price test is a free A/B experiment you can learn from — but only if you are collecting that data continuously. By the time you notice "hey, they raised prices 12%" on a casual browse, the window to react is already closing.&lt;/p&gt;

&lt;p&gt;As of Q1 2026, Shopify powers roughly 4.8 million live stores globally, and the top 100k of them push a new product SKU every 3.2 days on average. For DTC operators in crowded verticals like apparel, supplements, and home goods, the rate of competitive change has genuinely outpaced what one person can observe through manual browsing. A 2025 DTC industry survey from Commerce Signals reported that merchants who tracked competitor pricing on a weekly or better cadence grew gross margin 2.7 points faster than those who tracked quarterly or ad-hoc. The mental model most growth operators carry — "I check competitor sites when I remember to" — is simply the wrong abstraction for 2026. It needs to be a system, not a habit.&lt;/p&gt;

&lt;p&gt;This guide walks through how to build a Shopify competitor intelligence system that runs without you. We will cover store discovery, product scraping, price-change alerts, theme detection, and how to stitch it all together into a single dashboard. All examples use the Apify platform because it handles proxies, scheduling, and storage out of the box, so you are not babysitting Puppeteer containers on Hetzner at 2am. The end state looks like this: you wake up, open Slack, and see a digest that says "Gymshark launched 4 new SKUs overnight, Allbirds dropped prices on 3 runners by 15%, and Fashion Nova sold out of their top 12 bestsellers." You spent zero minutes acquiring that insight. That is the bar we are building to.&lt;/p&gt;

&lt;p&gt;One more thing worth naming before we dive in: this is not just a tactical exercise. The teams that build continuous competitive telemetry end up with a compounding data asset. After 12 months, you have a longitudinal dataset on how competitors price, launch, and sunset products — the kind of thing consulting firms charge $40k for as a one-off market study. You are building that asset as a byproduct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is hard
&lt;/h2&gt;

&lt;p&gt;On the surface, Shopify stores look easy to scrape. Every store exposes JSON endpoints like &lt;code&gt;/products.json&lt;/code&gt; and &lt;code&gt;/collections/all/products.json&lt;/code&gt;. In practice, three things make scaling painful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Store discovery&lt;/strong&gt;. Shopify does not publish a directory. Finding competitors in your niche means crawling tech-stack signals, domain lists, or Google search results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits and bot protection&lt;/strong&gt;. Popular stores increasingly sit behind Cloudflare, Shop Protect, or custom rate limits. A naive Python loop gets 429s within minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema drift&lt;/strong&gt;. Storefronts vary by theme. Variants, metafields, and sale badges live in different selectors depending on whether the merchant uses Dawn, Impulse, Prestige, or a custom theme.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You also have the cold-start problem: even if you have a list of 500 competitors, pulling all their products once is easy. Diffing them daily — efficiently — is the real engineering problem. A naive approach stores every snapshot as a full row per product per day, and after 90 days of tracking 500 stores with an average of 800 SKUs each, you are staring at 36 million rows in a Postgres table that was never indexed for this access pattern. Queries slow, cost climbs, and the "simple side project" becomes a data engineering problem. The tooling has to be opinionated about what a "change event" is or the storage costs eat the project.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript-rendered variant pickers&lt;/strong&gt;. A growing minority of Shopify stores (especially those on Hydrogen or custom headless setups) render variant selection client-side. The classic &lt;code&gt;/products.json&lt;/code&gt; endpoint still works for the product list, but inventory and some metafields require a full headless browser pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkout-gated pricing&lt;/strong&gt;. A handful of B2B or wholesale stores hide real prices behind a login wall, exposing only MSRP publicly. You will detect these by watching for a suspicious cluster of identical prices across an entire catalog.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Here is the pipeline we will build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Seed keywords]
     |
     v
[Shopify Store Detector] --&amp;gt; list of shopify domains
     |
     v
[Shopify Store Analyzer] --&amp;gt; products, prices, collections
     |
     v
[Postgres / DuckDB]  &amp;lt;--  daily snapshot
     |
     v
[Diff engine] --&amp;gt; Slack / email alerts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two Apify actors do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/shopify-store-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;shopify-store-detector&lt;/strong&gt;&lt;/a&gt; — given a domain or a keyword, confirms whether a site runs on Shopify and fingerprints the theme.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/shopify-store-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;shopify-store-analyzer&lt;/strong&gt;&lt;/a&gt; — pulls the full product catalog, collection structure, variant inventory, and price data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Find competitor stores
&lt;/h2&gt;

&lt;p&gt;If you already have a list of competitor URLs, skip this section. Otherwise, use the store detector to validate candidates. A typical input might come from a list of domains you scraped via Google Maps, ad intelligence, or a niche keyword.&lt;br&gt;
&lt;/p&gt;

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

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

&lt;span class="n"&gt;run_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allbirds.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gymshark.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fashionnova.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;randomcompetitor.co&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/shopify-store-detector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_shopify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;theme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan_hint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detector returns whether the domain is Shopify, the theme name (when detectable), and hints about the plan tier (Shopify Plus leaks in a few response headers). Persist this list — you will feed it into the analyzer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Pull the catalog
&lt;/h2&gt;

&lt;p&gt;For each confirmed Shopify domain, run the analyzer. The important trick here is that Shopify paginates &lt;code&gt;/products.json&lt;/code&gt; at 250 items per page, and some stores have 10k+ SKUs. The actor handles pagination, retries, and proxy rotation for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;run_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stores&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allbirds.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gymshark.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_variants&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# saves compute
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;include_collections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_products_per_store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nexgendata/shopify-store-analyzer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;run_input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;iterate_items&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pulled &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; products&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each item looks something like:&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;"store"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"allbirds.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4567890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"handle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mens-wool-runner"&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;"Men's Wool Runner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allbirds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product_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;"Shoes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&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;"wool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"everyday"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-02-11T14:22:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-15T08:11:33Z"&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_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;9800&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_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;13800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variants"&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="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WR-BLK-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;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inventory_quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&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;h2&gt;
  
  
  Step 3: Detect what changed
&lt;/h2&gt;

&lt;p&gt;Store each daily run in a table keyed by &lt;code&gt;(store, product_id, run_date)&lt;/code&gt;. Then a simple SQL diff surfaces launches, price changes, and stockouts:&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="c1"&gt;-- New products today&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products_today&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&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;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products_yesterday&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Price changes &amp;gt;5%&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;old_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;new_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_change&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products_today&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;products_yesterday&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_min&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;05&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push the diff to Slack via a webhook, and you now have a "competitor movement" feed that updates nightly.&lt;/p&gt;

&lt;p&gt;Here is a minimal end-to-end Python snippet that builds the diff and posts to Slack. Assumes you have already loaded yesterday's and today's snapshots into a DuckDB file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;SLACK_WEBHOOK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SLACK_WEBHOOK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;con&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;duckdb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shopify_intel.duckdb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;price_moves&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    SELECT t.store, t.handle, y.price_min AS old, t.price_min AS new,
           ROUND(100.0 * (t.price_min - y.price_min) / y.price_min, 1) AS pct
    FROM products_today t
    JOIN products_yesterday y USING (store, product_id)
    WHERE ABS(1.0 * t.price_min / y.price_min - 1) &amp;gt; 0.05
    ORDER BY ABS(pct) DESC
    LIMIT 25
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;launches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;con&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    SELECT store, handle, title FROM products_today
    WHERE (store, product_id) NOT IN (SELECT store, product_id FROM products_yesterday)
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:rotating_light: *Overnight competitor diff*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;launches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*New SKUs (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;launches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;):*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;launches&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;price_moves&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*Price moves (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price_moves&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;):*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;price_moves&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SLACK_WEBHOOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)}).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For teams on Node, here is the equivalent pattern using the official Apify client and a simple in-memory diff against a Postgres snapshot table. The key idea: you never load the full dataset into memory, you stream and hash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ApifyClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apify-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nexgendata/shopify-store-analyzer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;stores&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;allbirds.com&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;gymshark.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;include_variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;defaultDatasetId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;iterate&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;fingerprint&lt;/span&gt; &lt;span class="o"&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_min&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price_max&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variants&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;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`INSERT INTO product_snapshots(store, product_id, run_date, fingerprint, payload)
     VALUES ($1,$2,CURRENT_DATE,$3,$4)
     ON CONFLICT (store, product_id, run_date) DO NOTHING`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;item&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;changes&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  SELECT t.store, t.product_id
  FROM product_snapshots t
  JOIN product_snapshots y
    ON y.store=t.store AND y.product_id=t.product_id
   AND y.run_date = t.run_date - INTERVAL '1 day'
  WHERE t.run_date = CURRENT_DATE AND t.fingerprint &amp;lt;&amp;gt; y.fingerprint
`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Detected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;changes&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; material changes`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fingerprint column is the trick: by hashing the fields you care about into a single comparable string, the diff query becomes an O(n) index seek instead of a row-by-row JSON comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Schedule it
&lt;/h2&gt;

&lt;p&gt;Inside the Apify console, open each actor, click &lt;strong&gt;Schedules&lt;/strong&gt;, and set a daily run — say 03:00 UTC. Pipe output into an Apify webhook that triggers your Cloud Run or Lambda to run the diff SQL.&lt;/p&gt;

&lt;p&gt;If you prefer GitHub Actions, a cron workflow calling &lt;code&gt;apify-client&lt;/code&gt; works just as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. DTC founder scouting launches.&lt;/strong&gt; A skincare brand founder tracks 40 indie competitors. Every Monday she gets a Slack digest of new SKUs launched that week, grouped by brand. Total cost: ~$9/month in Apify compute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Agency doing competitive audits.&lt;/strong&gt; A growth agency ingests 200 Shopify stores for a client report. Instead of manually clicking through each storefront, they generate a 30-page PDF pulling from structured data in 15 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Arbitrage and dropshipper repricing.&lt;/strong&gt; A reseller monitors 1,200 Shopify stores and automatically adjusts prices on their marketplace listings when upstream prices shift.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Investor diligence.&lt;/strong&gt; A growth equity analyst pulled 6 months of price and SKU history for a target acquisition, which surfaced a quiet 18% average price hike used to inflate quarterly revenue. The firm used that finding to renegotiate valuation by $2.1M — a six-figure ROI on roughly $180 of Apify compute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Retail buyer trend spotting.&lt;/strong&gt; A regional boutique chain runs the analyzer against 70 trend-leading indie brands every Sunday night. Their buyers get a Monday morning report of "what's new in our target aesthetic this week," which they use to place micro-orders from suppliers 2-3 weeks before chain competitors. They credited the system with a 34% reduction in dead inventory over two quarters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Ad creative research.&lt;/strong&gt; A performance marketer combines the product feed with scraped ad-library creative to figure out which SKUs competitors are actively pushing on Meta. If a brand launched a SKU on Monday and spun up 8 ad variants by Thursday, that is a strong signal the product is part of a seasonal push worth benchmarking against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Monthly cost (100 stores daily)&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Commerce Inspector&lt;/td&gt;
&lt;td&gt;$149+&lt;/td&gt;
&lt;td&gt;UI only, no bulk export&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PPSPY&lt;/td&gt;
&lt;td&gt;$79&lt;/td&gt;
&lt;td&gt;Limited to ~10k products/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BuiltWith Shopify&lt;/td&gt;
&lt;td&gt;$295&lt;/td&gt;
&lt;td&gt;Tech-detection only, no products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom Puppeteer + proxies&lt;/td&gt;
&lt;td&gt;~$120 + eng hours&lt;/td&gt;
&lt;td&gt;You own the 429s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NexGenData actors&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$20 (pay per result)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pay-per-product, no seat fees&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Apify pay-per-result model wins once you care about more than a handful of stores. You are not paying for a seat you are not using.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;p&gt;Scraping Shopify is a deceptively deep well. These are the ones that eat weekends if you are not warned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storefront password protection.&lt;/strong&gt; Some stores hide &lt;code&gt;/products.json&lt;/code&gt; behind a coming-soon page or a password gate. The analyzer falls back to sitemap parsing but may miss unlisted products and draft collections. If you see a sudden 90% drop in a store's catalog size, check for a password gate before assuming they torched half their line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezones in &lt;code&gt;updated_at&lt;/code&gt;.&lt;/strong&gt; Always normalize to UTC before diffing — a timezone flip at DST will look like every product changed. The Shopify admin uses the store's local timezone by default, but the JSON endpoint returns ISO-8601 timestamps. Parse carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variants vs. products.&lt;/strong&gt; Track both. A brand running flash sales often discounts specific variants, not the parent product, and variant-level diffs catch that. The classic failure mode: you are diffing &lt;code&gt;price_min&lt;/code&gt; on the product, the sale applied to the L/XL variants only, and your dashboard shows "no price change" while revenue is obviously shifting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metafields.&lt;/strong&gt; Some themes put the "real" price (or compare-at price) in a metafield rather than the native price field. If you are monitoring a store and their prices never seem to move, inspect the raw product JSON — the sale price may be hiding in &lt;code&gt;metafields.custom.promo_price&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency and region.&lt;/strong&gt; Shopify Markets lets one store serve different prices by country. A single scrape from a US IP only shows US prices. If your competitors are international, either run the actor across multiple proxy geographies or query the &lt;code&gt;/{locale}/products.json&lt;/code&gt; variant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle changes.&lt;/strong&gt; A redesign or SEO migration can rename product handles while keeping &lt;code&gt;product_id&lt;/code&gt; stable. Always key your diffs on &lt;code&gt;product_id&lt;/code&gt;, never the handle. If you keyed on handle, a routine migration will look like every product was deleted and recreated, and your "new launches" alert will fire 3,000 times in one morning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ghost SKUs.&lt;/strong&gt; Shopify sometimes returns archived products via the JSON endpoint if they were only soft-deleted. Filter by &lt;code&gt;status = 'active'&lt;/code&gt; where the field is exposed, otherwise use &lt;code&gt;published_at IS NOT NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate detection across stores.&lt;/strong&gt; If you are tracking a brand that runs two storefronts (a main site and a clearance subdomain), you will get duplicate &lt;code&gt;product_id&lt;/code&gt;s because Shopify scopes them per-store. Always key on &lt;code&gt;(store, product_id)&lt;/code&gt;, never just &lt;code&gt;product_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A smaller but spicy gotcha: never assume a store's theme is static. Brands A/B test themes. A given customer might see Dawn on Monday and Impulse on Thursday. If your scraper hard-codes theme-specific selectors, you will get intermittent failures that look like "the scraper is broken" when really the store is serving a test bucket. Prefer the JSON endpoints wherever possible and treat HTML scraping as a fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  How NexGenData handles this
&lt;/h2&gt;

&lt;p&gt;The two actors we ship — shopify-store-detector and shopify-store-analyzer — are opinionated in a few specific ways that come from running this at scale for real customers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automatic fallback chain.&lt;/strong&gt; The analyzer tries &lt;code&gt;/products.json&lt;/code&gt; first (fastest, cheapest), falls back to the &lt;code&gt;/sitemap_products_*.xml&lt;/code&gt; sitemap if JSON is gated, and only fires up a headless browser if both fail. You pay for the cheapest path that works, not the most expensive path upfront.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smart rate limiting.&lt;/strong&gt; We fingerprint each store's response latency and 429 rate in the first 30 requests, then dynamically adjust concurrency per-store. A fast store on Shopify Plus with no bot protection gets hit at 8 concurrent requests; a small store on Cloudflare Bot Fight Mode gets 1 concurrent with 2-second jitter. You do not have to tune this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theme fingerprinting.&lt;/strong&gt; The detector identifies 37 common Shopify themes (Dawn, Impulse, Prestige, Turbo, Flex, Motion, etc.) plus custom Hydrogen/Oxygen implementations. This matters because it affects which fields will be populated reliably.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in variant and metafield flattening.&lt;/strong&gt; Instead of giving you nested JSON you have to unpack, the output is already flattened into a row-per-variant shape that writes cleanly into Postgres, BigQuery, or a CSV.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pay-per-result pricing.&lt;/strong&gt; You pay per product pulled, not per compute-minute. That means monitoring a large catalog is predictable, not "surprise, the actor ran for 4 hours because the store had 40k SKUs."&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Shopify competitor monitoring is not a one-off side quest. Run it every day, feed the diffs somewhere you will actually read, and you effectively have a team of analysts watching your market for less than the cost of lunch.&lt;/p&gt;

&lt;p&gt;Get started with the two core actors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/shopify-store-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Shopify Store Detector&lt;/strong&gt;&lt;/a&gt; — validate and fingerprint any domain.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/shopify-store-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Shopify Store Analyzer&lt;/strong&gt;&lt;/a&gt; — pull products, variants, and collections at scale.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/nexgendata/company-tech-stack-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;&lt;strong&gt;Tech Stack Detector&lt;/strong&gt;&lt;/a&gt; — find which competitors use Klaviyo, Gorgias, Rebuy, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is it legal to scrape competitor Shopify stores?&lt;/strong&gt;&lt;br&gt;
Generally yes, for publicly accessible product data. The &lt;code&gt;/products.json&lt;/code&gt; endpoint is a public, documented Shopify endpoint — it is how third-party tools like Shopify mobile apps and price-comparison sites have always worked. Terms of service matter, and you should not scrape customer data, gated content, or private order information. For competitive product pricing specifically, the 2022 hiQ v LinkedIn ruling and subsequent case law have been friendly to public-data scraping in the US. That said, consult your own counsel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How often should I run the scrape?&lt;/strong&gt;&lt;br&gt;
Daily is the sweet spot for most DTC use cases. Hourly is overkill unless you are in a flash-sale or auction category. Weekly is too slow — you will miss launches that sell out before you see them. If you are doing arbitrage or repricing, push to every 4-6 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Shopify Plus stores with custom storefronts (Hydrogen)?&lt;/strong&gt;&lt;br&gt;
Hydrogen and Oxygen are client-side renderers on top of the same Storefront API, so &lt;code&gt;/products.json&lt;/code&gt; usually still works. Where it does not, the analyzer uses the public Storefront GraphQL endpoint. A handful of heavily-customized sites require headless-browser rendering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I integrate this with Zapier or Make.com?&lt;/strong&gt;&lt;br&gt;
Yes. Apify has native integrations with both Zapier and Make. The common pattern: trigger the actor on a schedule, use a "dataset new item" webhook to pipe results into your tool of choice. For a simple Slack alert, no code needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle stores that block my IP after a few runs?&lt;/strong&gt;&lt;br&gt;
Apify provides residential and datacenter proxies out of the box. Enable &lt;code&gt;proxyConfiguration.useApifyProxy = true&lt;/code&gt; and pick residential if you are hitting well-defended stores. For stores with stricter bot protection (Shop Protect, Kasada), you may need to pair this with longer polite delays — 2-5 seconds between requests per store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the total cost for 500 stores monitored daily?&lt;/strong&gt;&lt;br&gt;
At average catalog size (~800 SKUs/store), you are looking at roughly $45-70/month on Apify's pay-per-result pricing. Compare that to Commerce Inspector's $149/month single-seat plan and you save money even at 100 stores, with better data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related tools
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/shopify-store-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;Shopify Store Detector&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/shopify-store-analyzer?fpr=2ayu9b" rel="noopener noreferrer"&gt;Shopify Store Analyzer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/nexgendata/company-tech-stack-detector?fpr=2ayu9b" rel="noopener noreferrer"&gt;Company Tech Stack Detector&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>apify</category>
      <category>scraping</category>
      <category>automation</category>
      <category>shopify</category>
    </item>
  </channel>
</rss>
