<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://assa83.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://assa83.github.io/" rel="alternate" type="text/html" /><updated>2026-10-17T13:41:02+00:00</updated><id>https://assa83.github.io/feed.xml</id><title type="html">Quelli di Carne Genuina — scraping &amp;amp; data tutorials</title><subtitle>Tested tutorials for ad libraries (Google, Meta, TikTok, LinkedIn), Similarweb, Google Trends, Google Play, Google Flights and Hotels, Australian grocery prices and YouTube transcripts with Python, JavaScript, n8n and AI agents.</subtitle><author><name>Quelli di Carne Genuina</name></author><entry><title type="html">Facebook Ad Library API alternative: export competitor ads to CSV with Python</title><link href="https://assa83.github.io/facebook-ad-library-api-python-csv/" rel="alternate" type="text/html" title="Facebook Ad Library API alternative: export competitor ads to CSV with Python" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://assa83.github.io/facebook-ad-library-api-python-csv</id><content type="html" xml:base="https://assa83.github.io/facebook-ad-library-api-python-csv/"><![CDATA[<p>Anyone who does paid social sooner or later wants the same thing: “show me every ad this competitor is running on Facebook and Instagram, in a spreadsheet.”</p>

<p>The <a href="https://www.facebook.com/ads/library/">Meta Ad Library</a> has the data, publicly. Getting it out is the hard part:</p>

<ol>
  <li><strong>The official Ad Library API is narrow.</strong> It returns political and issue ads (plus ads delivered in the EU), and you need a Meta developer app and identity verification before the first call. Ordinary commercial ads from a brand outside the EU, the ones most marketers care about, are not there.</li>
  <li><strong>The website is not an export tool.</strong> You scroll, ads load ten at a time, and there is no “download CSV” button. Copying copy, CTAs and landing pages by hand for a hundred ads is an afternoon gone.</li>
  <li><strong>Scraping it yourself means fighting rate limits.</strong> Facebook rate-limits datacenter IPs on the Ad Library, so a quick script turns into a proxy project.</li>
</ol>

<p>This tutorial uses the <a href="https://apify.com/lagenuina_agency/meta-ad-library-scraper">Meta Ad Library Scraper actor on Apify</a> as the API and keeps the Python to one short file that writes a CSV you can open in Excel or Google Sheets.</p>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>Python 3.9+</li>
  <li>A free Apify account and your API token, from <a href="https://console.apify.com/settings/integrations">Settings → Integrations</a></li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">APIFY_TOKEN</span><span class="o">=</span>your_token_here
pip <span class="nb">install</span> <span class="s2">"apify-client&gt;=1.7,&lt;2"</span>
</code></pre></div></div>

<p>No Facebook account, cookies or Meta developer app are involved.</p>

<h2 id="find-the-page-id">Find the page ID</h2>

<p>The scraper accepts a numeric page ID, an <code class="language-plaintext highlighter-rouge">@username</code> or a facebook.com page link. To get the ID, open the brand in the Ad Library: it is the <code class="language-plaintext highlighter-rouge">view_all_page_id=</code> number in the URL. Nike’s is <code class="language-plaintext highlighter-rouge">15087023444</code>. If you pass a plain brand name instead, it is searched as a keyword across all advertisers.</p>

<h2 id="the-script">The script</h2>

<p>This is <code class="language-plaintext highlighter-rouge">python/example.py</code> from the example repo <a href="https://github.com/assa83/facebook-ad-library-api">assa83/facebook-ad-library-api</a>. I ran it today, unchanged.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"""Export the Facebook &amp; Instagram ads of a page (or a keyword) from the Meta Ad Library to CSV.

Usage: python example.py 15087023444 --country US --active
       python example.py "running shoes" --keyword --country US
"""</span>
<span class="kn">import</span> <span class="nn">argparse</span>
<span class="kn">import</span> <span class="nn">csv</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">sys</span>

<span class="kn">from</span> <span class="nn">apify_client</span> <span class="kn">import</span> <span class="n">ApifyClient</span>

<span class="n">ACTOR</span> <span class="o">=</span> <span class="s">"lagenuina_agency/meta-ad-library-scraper"</span>
<span class="n">TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"APIFY_TOKEN"</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">TOKEN</span><span class="p">:</span>
    <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="s">"Set APIFY_TOKEN first: https://console.apify.com/settings/integrations"</span><span class="p">)</span>

<span class="n">FIELDS</span> <span class="o">=</span> <span class="p">[</span><span class="s">"ad_archive_id"</span><span class="p">,</span> <span class="s">"page_name"</span><span class="p">,</span> <span class="s">"is_active"</span><span class="p">,</span> <span class="s">"start_date_iso"</span><span class="p">,</span> <span class="s">"end_date_iso"</span><span class="p">,</span> <span class="s">"publisher_platform"</span><span class="p">,</span>
          <span class="s">"display_format"</span><span class="p">,</span> <span class="s">"ad_text"</span><span class="p">,</span> <span class="s">"title"</span><span class="p">,</span> <span class="s">"cta_text"</span><span class="p">,</span> <span class="s">"link_url"</span><span class="p">,</span> <span class="s">"image_urls"</span><span class="p">,</span> <span class="s">"video_urls"</span><span class="p">,</span> <span class="s">"spend"</span><span class="p">,</span> <span class="s">"ad_library_url"</span><span class="p">]</span>


<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">p</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"target"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"page id, @username, facebook.com link, or a keyword with --keyword"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--keyword"</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="s">"store_true"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"search the target as a keyword across all advertisers"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--country"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">"ALL"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"two-letter code or ALL"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--active"</span><span class="p">,</span> <span class="n">action</span><span class="o">=</span><span class="s">"store_true"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"only ads running now"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--limit"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span>
    <span class="n">a</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>

    <span class="n">run_input</span> <span class="o">=</span> <span class="p">{</span><span class="s">"country"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">country</span><span class="p">,</span> <span class="s">"activeStatus"</span><span class="p">:</span> <span class="s">"active"</span> <span class="k">if</span> <span class="n">a</span><span class="p">.</span><span class="n">active</span> <span class="k">else</span> <span class="s">"all"</span><span class="p">,</span> <span class="s">"maxAds"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">limit</span><span class="p">}</span>
    <span class="n">run_input</span><span class="p">[</span><span class="s">"keywords"</span> <span class="k">if</span> <span class="n">a</span><span class="p">.</span><span class="n">keyword</span> <span class="k">else</span> <span class="s">"pageIDs"</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="n">a</span><span class="p">.</span><span class="n">target</span><span class="p">]</span>
    <span class="n">client</span> <span class="o">=</span> <span class="n">ApifyClient</span><span class="p">(</span><span class="n">TOKEN</span><span class="p">)</span>
    <span class="n">run</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">actor</span><span class="p">(</span><span class="n">ACTOR</span><span class="p">).</span><span class="n">call</span><span class="p">(</span><span class="n">run_input</span><span class="o">=</span><span class="n">run_input</span><span class="p">)</span>

    <span class="n">safe</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">c</span> <span class="k">if</span> <span class="n">c</span><span class="p">.</span><span class="n">isalnum</span><span class="p">()</span> <span class="k">else</span> <span class="s">"_"</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">a</span><span class="p">.</span><span class="n">target</span><span class="p">)[:</span><span class="mi">40</span><span class="p">]</span>
    <span class="n">out</span> <span class="o">=</span> <span class="sa">f</span><span class="s">"meta_ads_</span><span class="si">{</span><span class="n">safe</span><span class="si">}</span><span class="s">.csv"</span>
    <span class="n">n</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">out</span><span class="p">,</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">w</span> <span class="o">=</span> <span class="n">csv</span><span class="p">.</span><span class="n">DictWriter</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="n">fieldnames</span><span class="o">=</span><span class="n">FIELDS</span><span class="p">,</span> <span class="n">extrasaction</span><span class="o">=</span><span class="s">"ignore"</span><span class="p">)</span>
        <span class="n">w</span><span class="p">.</span><span class="n">writeheader</span><span class="p">()</span>
        <span class="k">for</span> <span class="n">ad</span> <span class="ow">in</span> <span class="n">client</span><span class="p">.</span><span class="n">dataset</span><span class="p">(</span><span class="n">run</span><span class="p">[</span><span class="s">"defaultDatasetId"</span><span class="p">]).</span><span class="n">iterate_items</span><span class="p">():</span>
            <span class="n">row</span> <span class="o">=</span> <span class="p">{</span><span class="n">k</span><span class="p">:</span> <span class="p">(</span><span class="s">" | "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">v</span><span class="p">)</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">v</span><span class="p">,</span> <span class="nb">list</span><span class="p">)</span> <span class="k">else</span> <span class="n">v</span><span class="p">)</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="n">ad</span><span class="p">.</span><span class="n">items</span><span class="p">()</span> <span class="k">if</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">FIELDS</span><span class="p">}</span>
            <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">(</span><span class="n">row</span><span class="p">)</span>
            <span class="n">n</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">n</span><span class="si">}</span><span class="s"> ads -&gt; </span><span class="si">{</span><span class="n">out</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<p>How it works:</p>

<ol>
  <li><strong>Builds the input.</strong> <code class="language-plaintext highlighter-rouge">pageIDs</code> for one advertiser, or <code class="language-plaintext highlighter-rouge">keywords</code> to search every advertiser like the Ad Library search box. <code class="language-plaintext highlighter-rouge">activeStatus: "active"</code> matches what the website shows by default; <code class="language-plaintext highlighter-rouge">"all"</code> is the full archive, often ten times larger.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">maxAds</code> caps the run</strong>, and with it the cost. Leave it high for a full export.</li>
  <li><strong>Flattens lists</strong> (platforms, image and video URLs) into <code class="language-plaintext highlighter-rouge">a | b</code> strings so each ad is one CSV row.</li>
</ol>

<h2 id="real-output">Real output</h2>

<p>Run on 17 October 2026, Nike’s active US ads, capped at 10:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python example.py 15087023444 <span class="nt">--country</span> US <span class="nt">--active</span> <span class="nt">--limit</span> 10
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFO  Scraping 1 target(s), country=US, status=active, type=all, media=all, max 10 ads
INFO  Done: 10 ad(s). Ad Library pages=1, GraphQL pages=0, proxy rotations=0.
Status: SUCCEEDED
10 ads -&gt; meta_ads_15087023444.csv
</code></pre></div></div>

<p>The run took about 6 seconds. A few columns of <code class="language-plaintext highlighter-rouge">meta_ads_15087023444.csv</code> (image URLs shortened):</p>

<pre><code class="language-csv">ad_archive_id,page_name,is_active,start_date_iso,publisher_platform,display_format,ad_text,cta_text,link_url
1067080952624010,Nike,True,2026-09-01T07:00:00.000Z,FACEBOOK | INSTAGRAM,DPA,Ball’s in your court. Get the gear that never misses.,Shop now,https://www.nike.com/
968300552951603,Nike,True,2026-09-01T07:00:00.000Z,FACEBOOK | INSTAGRAM,DPA,Get the gear not afraid to put in the work.,Shop now,https://www.nike.com/
2816322322094811,Nike,True,2026-10-15T07:00:00.000Z,FACEBOOK | INSTAGRAM,DPA,Run through summer with gear that can take the heat.,Shop now,https://www.nike.com/
</code></pre>

<p>Every row also has <code class="language-plaintext highlighter-rouge">image_urls</code> (full-size creative on Facebook’s CDN), <code class="language-plaintext highlighter-rouge">video_urls</code>, <code class="language-plaintext highlighter-rouge">title</code>, <code class="language-plaintext highlighter-rouge">end_date_iso</code> and an <code class="language-plaintext highlighter-rouge">ad_library_url</code> you can open to check the ad by hand.</p>

<h2 id="from-csv-to-insight-in-ten-lines">From CSV to insight in ten lines</h2>

<p>A CSV is only useful if it answers a question. This snippet, run on the file above, shows when the ads started, which formats the brand uses and which messages it repeats:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">csv</span>
<span class="kn">from</span> <span class="nn">collections</span> <span class="kn">import</span> <span class="n">Counter</span>

<span class="n">ads</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">csv</span><span class="p">.</span><span class="n">DictReader</span><span class="p">(</span><span class="nb">open</span><span class="p">(</span><span class="s">"meta_ads_15087023444.csv"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)))</span>
<span class="k">print</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">ads</span><span class="p">),</span> <span class="s">"ads"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="s">"by start month:"</span><span class="p">,</span> <span class="n">Counter</span><span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="s">"start_date_iso"</span><span class="p">][:</span><span class="mi">7</span><span class="p">]</span> <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">ads</span><span class="p">).</span><span class="n">most_common</span><span class="p">())</span>
<span class="k">print</span><span class="p">(</span><span class="s">"by format:"</span><span class="p">,</span> <span class="n">Counter</span><span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="s">"display_format"</span><span class="p">]</span> <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">ads</span><span class="p">).</span><span class="n">most_common</span><span class="p">())</span>
<span class="k">print</span><span class="p">(</span><span class="s">"top messages:"</span><span class="p">)</span>
<span class="k">for</span> <span class="n">text</span><span class="p">,</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">Counter</span><span class="p">(</span><span class="n">a</span><span class="p">[</span><span class="s">"ad_text"</span><span class="p">]</span> <span class="k">for</span> <span class="n">a</span> <span class="ow">in</span> <span class="n">ads</span><span class="p">).</span><span class="n">most_common</span><span class="p">(</span><span class="mi">3</span><span class="p">):</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"  </span><span class="si">{</span><span class="n">n</span><span class="si">}</span><span class="s">x  </span><span class="si">{</span><span class="n">text</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10 ads
by start month: [('2026-09', 6), ('2026-10', 3), ('2025-06', 1)]
by format: [('DPA', 10)]
top messages:
  3x  Get the gear that goes hard on and off the field.
  3x  Ball’s in your court. Get the gear that never misses.
  3x  Get the gear not afraid to put in the work.
</code></pre></div></div>

<p>Even on ten ads the pattern is visible: Nike’s US catalog ads rotate three short lines of copy across many creatives, and a new batch went live in mid-October. On a full export (leave out <code class="language-plaintext highlighter-rouge">--active</code> to get the whole archive, and raise <code class="language-plaintext highlighter-rouge">--limit</code>) the same counts tell you how often a competitor refreshes creative and which angles survive for months.</p>

<h2 id="variants">Variants</h2>

<p><strong>Search a keyword instead of a page.</strong> <code class="language-plaintext highlighter-rouge">python example.py "protein bars" --keyword --country US --active --limit 5</code> returns ads from every advertiser that mention the phrase. In my run the first five were all Nature Valley catalog ads, which is itself a finding.</p>

<p><strong>n8n, every Monday.</strong> The repo includes an <a href="https://github.com/assa83/facebook-ad-library-api/tree/main/n8n">n8n workflow</a> that runs the actor on a competitor’s page each Monday at 08:00, flattens the fields and appends them to Google Sheets. Import the JSON, add a Header Auth credential (<code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;token&gt;</code>), set your page IDs and sheet.</p>

<p><strong>AI agents (MCP).</strong> Give the actor to Claude, Cursor or any MCP client:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"meta-ad-library-scraper"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://mcp.apify.com/?actors=lagenuina_agency/meta-ad-library-scraper"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then ask “what are Nike’s active Instagram ads in the US promoting this week?” and the agent runs the export for you.</p>

<h2 id="what-it-costs">What it costs</h2>

<p>$0.20 per 1,000 ads on every Apify plan, residential proxy included, no start fee. The 15 ads in my two test runs cost a fraction of a cent. Set <code class="language-plaintext highlighter-rouge">maxAds</code> or a maximum charge per run to cap spending. Check the <a href="https://apify.com/lagenuina_agency/meta-ad-library-scraper">actor page</a> for current prices.</p>

<h2 id="limits-worth-knowing">Limits worth knowing</h2>

<ul>
  <li><strong>No spend or reach for commercial ads.</strong> Meta publishes spend, impressions and reach only for political and issue ads, so those columns are empty for brands like Nike. That is a Meta limit, not a scraper one.</li>
  <li><strong>Catalog ads show templates.</strong> Dynamic product ads (<code class="language-plaintext highlighter-rouge">DPA</code>, <code class="language-plaintext highlighter-rouge">DCO</code>) store copy as placeholders such as ``; the Ad Library does not render the products. You still get the images, CTA and landing page.</li>
  <li><strong>Large archives take time.</strong> Facebook serves ten ads per request, roughly six minutes for 1,000 ads of one page.</li>
  <li><strong>Media links expire.</strong> Image and video URLs are signed CDN links valid for some days. Download what you want to keep.</li>
  <li><strong>Brand names are keywords.</strong> A plain name like “nike” searches ad text across all advertisers. Use the page ID for one exact advertiser.</li>
  <li>The data is public transparency data, but you are responsible for how you use it. Do not process personal data without a legal basis.</li>
</ul>

<h2 id="wrap-up">Wrap-up</h2>

<p>If the official Ad Library API does not cover the ads you need, or the identity verification is a blocker, a hosted scraper plus forty lines of Python gets a competitor’s Facebook and Instagram ads into a spreadsheet in seconds. Clone the <a href="https://github.com/assa83/facebook-ad-library-api">example repo</a>, set <code class="language-plaintext highlighter-rouge">APIFY_TOKEN</code> and point it at the page ID you care about.</p>

<hr />

<p><em>Disclosure: I built this actor. It is published on Apify by Quelli di Carne Genuina, and I earn from its usage.</em></p>]]></content><author><name>Quelli di Carne Genuina</name></author><category term="python," /><category term="facebookads," /><category term="marketing," /><category term="webscraping" /><summary type="html"><![CDATA[Meta's official Ad Library API only covers political and issue ads. Here is a tested Python script that exports any page's Facebook and Instagram ads to CSV, no developer app or ID verification.]]></summary></entry><entry><title type="html">Google Flights API in Python: track fares and get price alerts</title><link href="https://assa83.github.io/google-flights-api-python-price-alerts/" rel="alternate" type="text/html" title="Google Flights API in Python: track fares and get price alerts" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://assa83.github.io/google-flights-api-python-price-alerts</id><content type="html" xml:base="https://assa83.github.io/google-flights-api-python-price-alerts/"><![CDATA[<p>There is no official Google Flights API. Google shut down QPX Express, the last public flight search API it offered, in 2018. What is left:</p>

<ul>
  <li><strong>Airline and GDS APIs</strong> (Amadeus, Duffel, Sabre and the like) are built for booking. They need an account, sometimes a contract, and their prices are not always the ones you see on Google Flights.</li>
  <li><strong>Price-alert apps</strong>, including Google’s own “Track prices” toggle, send you an email. They do not give you the numbers to put in a spreadsheet, a dashboard or a script.</li>
  <li><strong>Scraping Google Flights yourself</strong> means reverse-engineering a JavaScript-heavy page that changes often.</li>
</ul>

<p>If what you want is “check LHR → JFK every morning, keep a history and ping me when it is cheap”, a hosted scraper and a short Python script are enough. This tutorial uses the <a href="https://apify.com/lagenuina_agency/google-flights-scraper">Google Flights Scraper actor on Apify</a> and the official <code class="language-plaintext highlighter-rouge">apify-client</code> package.</p>

<p>By the end you will have <code class="language-plaintext highlighter-rouge">fare_tracker.py</code>, which on each run:</p>

<ol>
  <li>finds the cheapest fare on your route N days ahead,</li>
  <li>finds the cheapest departure day in the following week,</li>
  <li>appends one row to <code class="language-plaintext highlighter-rouge">fares_history.csv</code>,</li>
  <li>prints an <code class="language-plaintext highlighter-rouge">ALERT</code> line when the fare is at or below your target.</li>
</ol>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>Python 3.9+</li>
  <li>A free Apify account and your API token, from <a href="https://console.apify.com/settings/integrations">Settings → Integrations</a></li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">APIFY_TOKEN</span><span class="o">=</span>your_token_here
pip <span class="nb">install</span> <span class="s2">"apify-client&gt;=1.7,&lt;2"</span>
</code></pre></div></div>

<h2 id="the-script">The script</h2>

<p>I wrote this for the post and ran it today against the live actor. The example repo <a href="https://github.com/assa83/google-flights-api">assa83/google-flights-api</a> has a sibling script that prints every fare instead of logging the cheapest.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"""Track Google Flights fares for one route and alert when the price drops.

Usage: python fare_tracker.py LHR JFK --days-ahead 30 --max-price 450
Run it once a day (cron, GitHub Actions, n8n): every run appends to fares_history.csv.
"""</span>
<span class="kn">import</span> <span class="nn">argparse</span>
<span class="kn">import</span> <span class="nn">csv</span>
<span class="kn">import</span> <span class="nn">datetime</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">sys</span>

<span class="kn">from</span> <span class="nn">apify_client</span> <span class="kn">import</span> <span class="n">ApifyClient</span>

<span class="n">ACTOR</span> <span class="o">=</span> <span class="s">"lagenuina_agency/google-flights-scraper"</span>
<span class="n">TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"APIFY_TOKEN"</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">TOKEN</span><span class="p">:</span>
    <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="s">"Set APIFY_TOKEN first: https://console.apify.com/settings/integrations"</span><span class="p">)</span>
<span class="n">HISTORY</span> <span class="o">=</span> <span class="s">"fares_history.csv"</span>


<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">p</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"origin"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"IATA airport code, e.g. LHR"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"destination"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"IATA airport code, e.g. JFK"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--days-ahead"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--max-price"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">float</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"alert when the cheapest fare is at or below this"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--calendar-days"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">7</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"also find the cheapest day in the next N days"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--currency"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">"USD"</span><span class="p">)</span>
    <span class="n">a</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>

    <span class="n">client</span> <span class="o">=</span> <span class="n">ApifyClient</span><span class="p">(</span><span class="n">TOKEN</span><span class="p">)</span>
    <span class="n">run</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">actor</span><span class="p">(</span><span class="n">ACTOR</span><span class="p">).</span><span class="n">call</span><span class="p">(</span><span class="n">run_input</span><span class="o">=</span><span class="p">{</span>
        <span class="s">"origin"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">origin</span><span class="p">,</span> <span class="s">"destination"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">destination</span><span class="p">,</span>
        <span class="s">"departureDate"</span><span class="p">:</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">days_ahead</span><span class="si">}</span><span class="s"> days"</span><span class="p">,</span>  <span class="c1"># relative: a daily run always looks N days ahead
</span>        <span class="s">"currency"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">currency</span><span class="p">,</span> <span class="s">"sortBy"</span><span class="p">:</span> <span class="s">"price"</span><span class="p">,</span> <span class="s">"maxResults"</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
        <span class="s">"calendarDays"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">calendar_days</span><span class="p">,</span>
    <span class="p">})</span>
    <span class="n">items</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">client</span><span class="p">.</span><span class="n">dataset</span><span class="p">(</span><span class="n">run</span><span class="p">[</span><span class="s">"defaultDatasetId"</span><span class="p">]).</span><span class="n">iterate_items</span><span class="p">())</span>
    <span class="k">for</span> <span class="n">e</span> <span class="ow">in</span> <span class="p">(</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">i</span><span class="p">[</span><span class="s">"type"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"error"</span><span class="p">):</span>
        <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="sa">f</span><span class="s">"actor error: </span><span class="si">{</span><span class="n">e</span><span class="p">[</span><span class="s">'error'</span><span class="p">]</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>

    <span class="n">flights</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">i</span><span class="p">[</span><span class="s">"type"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"flight"</span> <span class="ow">and</span> <span class="n">i</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"price"</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">]</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">flights</span><span class="p">:</span>
        <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="s">"no fares found"</span><span class="p">)</span>
    <span class="n">best</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">flights</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">f</span><span class="p">:</span> <span class="n">f</span><span class="p">[</span><span class="s">"price"</span><span class="p">])</span>
    <span class="n">level</span> <span class="o">=</span> <span class="p">(</span><span class="n">best</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"priceInsights"</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}).</span><span class="n">get</span><span class="p">(</span><span class="s">"priceLevel"</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
    <span class="n">days</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">i</span><span class="p">[</span><span class="s">"type"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"calendarDay"</span> <span class="ow">and</span> <span class="n">i</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"lowestPrice"</span><span class="p">)</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">]</span>
    <span class="n">cheapest_day</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">days</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">d</span><span class="p">:</span> <span class="n">d</span><span class="p">[</span><span class="s">"lowestPrice"</span><span class="p">])</span> <span class="k">if</span> <span class="n">days</span> <span class="k">else</span> <span class="bp">None</span>

    <span class="n">new_file</span> <span class="o">=</span> <span class="ow">not</span> <span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">exists</span><span class="p">(</span><span class="n">HISTORY</span><span class="p">)</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">HISTORY</span><span class="p">,</span> <span class="s">"a"</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">w</span> <span class="o">=</span> <span class="n">csv</span><span class="p">.</span><span class="n">writer</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">new_file</span><span class="p">:</span>
            <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="s">"checked_at"</span><span class="p">,</span> <span class="s">"route"</span><span class="p">,</span> <span class="s">"departure_date"</span><span class="p">,</span> <span class="s">"price"</span><span class="p">,</span> <span class="s">"currency"</span><span class="p">,</span> <span class="s">"airlines"</span><span class="p">,</span> <span class="s">"stops"</span><span class="p">,</span> <span class="s">"price_level"</span><span class="p">])</span>
        <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="n">datetime</span><span class="p">.</span><span class="n">datetime</span><span class="p">.</span><span class="n">now</span><span class="p">().</span><span class="n">isoformat</span><span class="p">(</span><span class="n">timespec</span><span class="o">=</span><span class="s">"minutes"</span><span class="p">),</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">origin</span><span class="si">}</span><span class="s">-</span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">destination</span><span class="si">}</span><span class="s">"</span><span class="p">,</span>
                    <span class="n">best</span><span class="p">[</span><span class="s">"departureDate"</span><span class="p">],</span> <span class="n">best</span><span class="p">[</span><span class="s">"price"</span><span class="p">],</span> <span class="n">best</span><span class="p">[</span><span class="s">"currency"</span><span class="p">],</span> <span class="s">" / "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">best</span><span class="p">[</span><span class="s">"airlines"</span><span class="p">]),</span>
                    <span class="n">best</span><span class="p">[</span><span class="s">"stops"</span><span class="p">],</span> <span class="n">level</span><span class="p">])</span>

    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Cheapest </span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">origin</span><span class="si">}</span><span class="s">-&gt;</span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">destination</span><span class="si">}</span><span class="s"> on </span><span class="si">{</span><span class="n">best</span><span class="p">[</span><span class="s">'departureDate'</span><span class="p">]</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">best</span><span class="p">[</span><span class="s">'price'</span><span class="p">]</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">best</span><span class="p">[</span><span class="s">'currency'</span><span class="p">]</span><span class="si">}</span><span class="s"> "</span>
          <span class="sa">f</span><span class="s">"(</span><span class="si">{</span><span class="s">', '</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">best</span><span class="p">[</span><span class="s">'airlines'</span><span class="p">])</span><span class="si">}</span><span class="s">, </span><span class="si">{</span><span class="n">best</span><span class="p">[</span><span class="s">'stops'</span><span class="p">]</span><span class="si">}</span><span class="s"> stop(s)), Google says '</span><span class="si">{</span><span class="n">level</span><span class="si">}</span><span class="s">'"</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">cheapest_day</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"Cheapest day in the next </span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">calendar_days</span><span class="si">}</span><span class="s">: </span><span class="si">{</span><span class="n">cheapest_day</span><span class="p">[</span><span class="s">'departureDate'</span><span class="p">]</span><span class="si">}</span><span class="s"> "</span>
              <span class="sa">f</span><span class="s">"at </span><span class="si">{</span><span class="n">cheapest_day</span><span class="p">[</span><span class="s">'lowestPrice'</span><span class="p">]</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">cheapest_day</span><span class="p">[</span><span class="s">'currency'</span><span class="p">]</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">a</span><span class="p">.</span><span class="n">max_price</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="ow">and</span> <span class="n">best</span><span class="p">[</span><span class="s">"price"</span><span class="p">]</span> <span class="o">&lt;=</span> <span class="n">a</span><span class="p">.</span><span class="n">max_price</span><span class="p">:</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"ALERT: </span><span class="si">{</span><span class="n">best</span><span class="p">[</span><span class="s">'price'</span><span class="p">]</span><span class="si">}</span><span class="s"> </span><span class="si">{</span><span class="n">best</span><span class="p">[</span><span class="s">'currency'</span><span class="p">]</span><span class="si">}</span><span class="s"> is at or below your </span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">max_price</span><span class="si">:</span><span class="n">g</span><span class="si">}</span><span class="s"> target"</span><span class="p">)</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<p>The input fields used:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">origin</code>, <code class="language-plaintext highlighter-rouge">destination</code></td>
      <td>IATA airport codes</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">departureDate</code></td>
      <td><code class="language-plaintext highlighter-rouge">YYYY-MM-DD</code> or a relative value like <code class="language-plaintext highlighter-rouge">"30 days"</code>, so a scheduled run keeps looking the same distance ahead</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sortBy: "price"</code>, <code class="language-plaintext highlighter-rouge">maxResults</code></td>
      <td>Cheapest itineraries first, keep 5</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">calendarDays</code></td>
      <td>Also return the lowest fare for each of the next N departure days</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">currency</code></td>
      <td>Any ISO code; add <code class="language-plaintext highlighter-rouge">market</code> (e.g. <code class="language-plaintext highlighter-rouge">GB</code>) to see fares as offered in another country</td>
    </tr>
  </tbody>
</table>

<p>The output mixes row types: <code class="language-plaintext highlighter-rouge">flight</code> rows (itineraries), <code class="language-plaintext highlighter-rouge">calendarDay</code> rows (one per date) and <code class="language-plaintext highlighter-rouge">error</code> rows. The script splits them by <code class="language-plaintext highlighter-rouge">type</code>.</p>

<h2 id="real-output">Real output</h2>

<p>Run on 17 October 2026, London Heathrow to New York JFK, 30 days ahead, with a $550 target:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python fare_tracker.py LHR JFK <span class="nt">--days-ahead</span> 30 <span class="nt">--max-price</span> 550 <span class="nt">--calendar-days</span> 7
</code></pre></div></div>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFO  LHR→JFK 2026-11-16: 5 itineraries
INFO  Done: 12 row(s), 0 route error(s).
Status: SUCCEEDED
Cheapest LHR-&gt;JFK on 2026-11-16: 505 USD (JetBlue, 0 stop(s)), Google says 'typical'
Cheapest day in the next 7: 2026-11-17 at 404 USD
ALERT: 505 USD is at or below your 550 target
</code></pre></div></div>

<p>About ten seconds per run. After two runs, <code class="language-plaintext highlighter-rouge">fares_history.csv</code> looks like this:</p>

<pre><code class="language-csv">checked_at,route,departure_date,price,currency,airlines,stops,price_level
2026-10-17T15:35,LHR-JFK,2026-11-16,505,USD,JetBlue,0,typical
2026-10-17T15:39,LHR-JFK,2026-11-16,505,USD,JetBlue,0,typical
</code></pre>

<p>Two things in that output are worth more than the price itself:</p>

<ul>
  <li><strong>Flying one day later saves $101.</strong> The calendar found $404 on 17 November against $505 on the 16th. Most alert apps only watch the date you typed.</li>
  <li><strong>Google’s own verdict comes with each fare.</strong> Every <code class="language-plaintext highlighter-rouge">flight</code> row has a <code class="language-plaintext highlighter-rouge">priceInsights</code> block. Shortened:</li>
</ul>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"price"</span><span class="p">:</span><span class="w"> </span><span class="mi">505</span><span class="p">,</span><span class="w">
  </span><span class="nl">"airlines"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"JetBlue"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"flightNumbers"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"B6 2220"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"departureTime"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-11-16T08:05"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"durationMinutes"</span><span class="p">:</span><span class="w"> </span><span class="mi">489</span><span class="p">,</span><span class="w">
  </span><span class="nl">"stops"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
  </span><span class="nl">"co2EmissionsKg"</span><span class="p">:</span><span class="w"> </span><span class="mi">549</span><span class="p">,</span><span class="w">
  </span><span class="nl">"priceInsights"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"lowestPrice"</span><span class="p">:</span><span class="w"> </span><span class="mi">505</span><span class="p">,</span><span class="w">
    </span><span class="nl">"typicalPriceLow"</span><span class="p">:</span><span class="w"> </span><span class="mi">400</span><span class="p">,</span><span class="w">
    </span><span class="nl">"typicalPriceHigh"</span><span class="p">:</span><span class="w"> </span><span class="mi">570</span><span class="p">,</span><span class="w">
    </span><span class="nl">"priceLevel"</span><span class="p">:</span><span class="w"> </span><span class="s2">"typical"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"priceHistory"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w"> </span><span class="nl">"date"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-08-16"</span><span class="p">,</span><span class="w"> </span><span class="nl">"price"</span><span class="p">:</span><span class="w"> </span><span class="mi">365</span><span class="w"> </span><span class="p">},</span><span class="w"> </span><span class="s2">"..."</span><span class="p">]</span><span class="w">
  </span><span class="p">},</span><span class="w">
  </span><span class="nl">"bookingUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://www.google.com/travel/flights/booking?tfs=..."</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">priceHistory</code> holds about 60 days of Google’s tracked prices for the search. Here it shows the same flight at $365 in mid-August, so “typical” today is still well above where it started. A sensible alert rule uses this: fire when the price is <code class="language-plaintext highlighter-rouge">low</code>, or below <code class="language-plaintext highlighter-rouge">typicalPriceLow</code>, instead of a number you guessed.</p>

<h2 id="running-it-every-day">Running it every day</h2>

<p>The script appends, so scheduling it is all you need to build a history:</p>

<ul>
  <li><strong>cron</strong>: <code class="language-plaintext highlighter-rouge">0 7 * * * cd /path/to/tracker &amp;&amp; APIFY_TOKEN=... python fare_tracker.py LHR JFK --max-price 450</code></li>
  <li><strong>GitHub Actions</strong>: a <code class="language-plaintext highlighter-rouge">schedule:</code> workflow that runs the script and commits <code class="language-plaintext highlighter-rouge">fares_history.csv</code> back to the repo. Store the token as a repository secret.</li>
  <li><strong>Turn the ALERT line into a message</strong>: pipe the output through <code class="language-plaintext highlighter-rouge">grep ALERT</code> and into your email or Slack webhook of choice.</li>
</ul>

<h2 id="variants">Variants</h2>

<p><strong>n8n, no code.</strong> The example repo has an <a href="https://github.com/assa83/google-flights-api/tree/main/n8n">n8n workflow</a>: every day at 07:00 it searches the route 30 days ahead, a Code node keeps only fares at or below <code class="language-plaintext highlighter-rouge">MAX_PRICE</code>, and you attach a Slack or Gmail node. Import the JSON, add a Header Auth credential (<code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;token&gt;</code>) and set your route.</p>

<p><strong>Many routes at once.</strong> The actor takes a <code class="language-plaintext highlighter-rouge">routes</code> list (<code class="language-plaintext highlighter-rouge">[{"origin": "LHR", "destination": "JFK", "departureDate": "2026-12-10"}, ...]</code>), so one run can watch every route you care about.</p>

<p><strong>AI agents (MCP).</strong> Give the actor to Claude, Cursor or any MCP client:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"google-flights-scraper"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://mcp.apify.com/?actors=lagenuina_agency/google-flights-scraper"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then ask “what is the cheapest day to fly Milan to New York in the first week of December?” and the agent runs the calendar search.</p>

<h2 id="what-it-costs">What it costs</h2>

<p>The actor charges per row: one itinerary or one calendar day. The run above returned 12 rows (5 flights and 7 calendar days) and cost a fraction of a cent. Until 26 October 2026 the price is $0.16 per 1,000 rows on the Apify Free plan ($0.08 on Gold); from 27 October it is $0.80 on Free and $0.56 on Gold. At the new price a daily check like this one is about 4,400 rows a year, roughly $3.50 on the Free plan. Check the <a href="https://apify.com/lagenuina_agency/google-flights-scraper">actor page</a> for current prices.</p>

<h2 id="limits-worth-knowing">Limits worth knowing</h2>

<ul>
  <li><strong>What Google shows, not every fare.</strong> Each search returns the itineraries on Google’s results page, typically 8 to 60, not the full “View more flights” list.</li>
  <li><strong>No multi-city trips</strong>, no “Explore anywhere” and no per-seller prices (Expedia, Kiwi and so on).</li>
  <li><strong>Round trips</strong> return outbound itineraries with the round-trip total. Turn on <code class="language-plaintext highlighter-rouge">resolveReturnFlights</code> to get the matching return flights.</li>
  <li><strong>Prices depend on <code class="language-plaintext highlighter-rouge">market</code> and <code class="language-plaintext highlighter-rouge">currency</code>.</strong> The same flight can cost differently when searched from the US or the UK. Keep both fixed in a tracker, or the history is not comparable.</li>
  <li><strong>The calendar costs one row per day.</strong> A 60-day sweep is 60 rows; use <code class="language-plaintext highlighter-rouge">calendarOnly</code> if you do not need itineraries.</li>
  <li>The data is what Google Flights displays publicly. The scraper is not affiliated with Google, and you should book on the airline’s or agent’s site, not on a scraped price.</li>
</ul>

<h2 id="wrap-up">Wrap-up</h2>

<p>Without a public Google Flights API, a small daily script on top of a hosted scraper gets you the part that matters: a fare history you own and an alert that fires on your rules. Copy <code class="language-plaintext highlighter-rouge">fare_tracker.py</code>, set <code class="language-plaintext highlighter-rouge">APIFY_TOKEN</code>, pick a route and schedule it.</p>

<hr />

<p><em>Disclosure: I built this actor. It is published on Apify by Quelli di Carne Genuina, and I earn from its usage.</em></p>]]></content><author><name>Quelli di Carne Genuina</name></author><category term="python," /><category term="travel," /><category term="api," /><category term="automation" /><summary type="html"><![CDATA[Google has no public Flights API since QPX Express shut down. Here is a tested Python script that checks a route every day, logs the cheapest fare to CSV and alerts you when it drops below your target.]]></summary></entry><entry><title type="html">Google Trends API in Python: a working pytrends alternative (2026)</title><link href="https://assa83.github.io/google-trends-api-python/" rel="alternate" type="text/html" title="Google Trends API in Python: a working pytrends alternative (2026)" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://assa83.github.io/google-trends-api-python</id><content type="html" xml:base="https://assa83.github.io/google-trends-api-python/"><![CDATA[<p>Google has never shipped a general-purpose public API for Google Trends. For years the Python answer was <a href="https://github.com/GeneralMills/pytrends">pytrends</a>, an unofficial library that called the same endpoints the Trends website uses.</p>

<p>Two things changed that:</p>

<ol>
  <li><strong>The pytrends repository is archived on GitHub.</strong> It is read-only, so no new fixes will land when Google changes something on its side.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">429 Too Many Requests</code> is the error everyone meets.</strong> Trends throttles automated traffic hard. A few requests from one IP and you start getting 429s, and then you are writing retry loops, sleeping between calls and rotating proxies instead of analysing data.</li>
</ol>

<p>If you just need the numbers (interest over time, rising queries, interest by region), a simpler option is to let a hosted scraper deal with sessions and rate limits and call it from Python. This tutorial does that with the <a href="https://apify.com/lagenuina_agency/google-trends-scraper">Google Trends Scraper actor on Apify</a> and the official <code class="language-plaintext highlighter-rouge">apify-client</code> package.</p>

<p>By the end you will have a script that writes two CSV files you can open in Excel or import into Google Sheets:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">trends_YYYY-MM-DD.csv</code>: one row per keyword and date (<code class="language-plaintext highlighter-rouge">searchTerm, geo, date, value, isPartial</code>)</li>
  <li><code class="language-plaintext highlighter-rouge">rising_YYYY-MM-DD.csv</code>: the rising related queries for each keyword (<code class="language-plaintext highlighter-rouge">searchTerm, query, growth</code>)</li>
</ul>

<p>The full code is in the repo <a href="https://github.com/assa83/google-trends-api-python">assa83/google-trends-api-python</a>, with JavaScript and n8n versions too.</p>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>Python 3.9+</li>
  <li>A free Apify account and your API token, from <a href="https://console.apify.com/settings/integrations">Settings → Integrations</a></li>
</ul>

<p>Keep the token out of your code. Put it in an environment variable:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">APIFY_TOKEN</span><span class="o">=</span>your_token_here
pip <span class="nb">install</span> <span class="s2">"apify-client&gt;=1.7,&lt;2"</span>
</code></pre></div></div>

<p>The example targets the 1.x client, where <code class="language-plaintext highlighter-rouge">.call()</code> returns a plain dict. If you use apify-client 2.x, check the client changelog for the return type.</p>

<h2 id="the-script">The script</h2>

<p>Save this as <code class="language-plaintext highlighter-rouge">example.py</code>. It is the same file as <code class="language-plaintext highlighter-rouge">python/example.py</code> in the repo, and I ran it today against the live actor.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"""Weekly Google Trends export to CSV (Google Sheets ready).

Usage: python example.py "chatgpt" "gemini" --geo US
"""</span>
<span class="kn">import</span> <span class="nn">argparse</span>
<span class="kn">import</span> <span class="nn">csv</span>
<span class="kn">import</span> <span class="nn">datetime</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">sys</span>

<span class="kn">from</span> <span class="nn">apify_client</span> <span class="kn">import</span> <span class="n">ApifyClient</span>

<span class="n">ACTOR</span> <span class="o">=</span> <span class="s">"lagenuina_agency/google-trends-scraper"</span>
<span class="n">TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"APIFY_TOKEN"</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">TOKEN</span><span class="p">:</span>
    <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="s">"Set APIFY_TOKEN first: https://console.apify.com/settings/integrations"</span><span class="p">)</span>


<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">p</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"terms"</span><span class="p">,</span> <span class="n">nargs</span><span class="o">=</span><span class="s">"+"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"keywords to track"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--geo"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"country/region code, empty = worldwide"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--time-range"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">"today 3-m"</span><span class="p">)</span>
    <span class="n">a</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>

    <span class="n">client</span> <span class="o">=</span> <span class="n">ApifyClient</span><span class="p">(</span><span class="n">TOKEN</span><span class="p">)</span>
    <span class="n">run</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">actor</span><span class="p">(</span><span class="n">ACTOR</span><span class="p">).</span><span class="n">call</span><span class="p">(</span><span class="n">run_input</span><span class="o">=</span><span class="p">{</span>
        <span class="s">"searchTerms"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">terms</span><span class="p">,</span>
        <span class="s">"geo"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">geo</span><span class="p">,</span>
        <span class="s">"timeRange"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">time_range</span><span class="p">,</span>
        <span class="s">"includeInterestByRegion"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
        <span class="s">"includeRelated"</span><span class="p">:</span> <span class="bp">True</span><span class="p">,</span>
    <span class="p">})</span>
    <span class="n">items</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">dataset</span><span class="p">(</span><span class="n">run</span><span class="p">[</span><span class="s">"defaultDatasetId"</span><span class="p">]).</span><span class="n">list_items</span><span class="p">().</span><span class="n">items</span>

    <span class="n">today</span> <span class="o">=</span> <span class="n">datetime</span><span class="p">.</span><span class="n">date</span><span class="p">.</span><span class="n">today</span><span class="p">().</span><span class="n">isoformat</span><span class="p">()</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="sa">f</span><span class="s">"trends_</span><span class="si">{</span><span class="n">today</span><span class="si">}</span><span class="s">.csv"</span><span class="p">,</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">w</span> <span class="o">=</span> <span class="n">csv</span><span class="p">.</span><span class="n">writer</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
        <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="s">"searchTerm"</span><span class="p">,</span> <span class="s">"geo"</span><span class="p">,</span> <span class="s">"date"</span><span class="p">,</span> <span class="s">"value"</span><span class="p">,</span> <span class="s">"isPartial"</span><span class="p">])</span>
        <span class="k">for</span> <span class="n">it</span> <span class="ow">in</span> <span class="n">items</span><span class="p">:</span>
            <span class="k">for</span> <span class="n">point</span> <span class="ow">in</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"timeline"</span><span class="p">,</span> <span class="p">[]):</span>
                <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="n">it</span><span class="p">[</span><span class="s">"searchTerm"</span><span class="p">],</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"geo"</span><span class="p">,</span> <span class="s">""</span><span class="p">),</span> <span class="n">point</span><span class="p">[</span><span class="s">"date"</span><span class="p">][:</span><span class="mi">10</span><span class="p">],</span> <span class="n">point</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"value"</span><span class="p">),</span> <span class="n">point</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"isPartial"</span><span class="p">,</span> <span class="bp">False</span><span class="p">)])</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="sa">f</span><span class="s">"rising_</span><span class="si">{</span><span class="n">today</span><span class="si">}</span><span class="s">.csv"</span><span class="p">,</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">w</span> <span class="o">=</span> <span class="n">csv</span><span class="p">.</span><span class="n">writer</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
        <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="s">"searchTerm"</span><span class="p">,</span> <span class="s">"query"</span><span class="p">,</span> <span class="s">"growth"</span><span class="p">])</span>
        <span class="k">for</span> <span class="n">it</span> <span class="ow">in</span> <span class="n">items</span><span class="p">:</span>
            <span class="k">for</span> <span class="n">q</span> <span class="ow">in</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"relatedQueries_rising"</span><span class="p">,</span> <span class="p">[]):</span>
                <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="n">it</span><span class="p">[</span><span class="s">"searchTerm"</span><span class="p">],</span> <span class="n">q</span><span class="p">[</span><span class="s">"query"</span><span class="p">],</span> <span class="n">q</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"formattedValue"</span><span class="p">,</span> <span class="n">q</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"value"</span><span class="p">))])</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">items</span><span class="p">)</span><span class="si">}</span><span class="s"> keywords -&gt; trends_</span><span class="si">{</span><span class="n">today</span><span class="si">}</span><span class="s">.csv, rising_</span><span class="si">{</span><span class="n">today</span><span class="si">}</span><span class="s">.csv"</span><span class="p">)</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<p>What it does, step by step:</p>

<ol>
  <li><strong>Reads the token</strong> from <code class="language-plaintext highlighter-rouge">APIFY_TOKEN</code> and stops with a clear message if it is missing.</li>
  <li><strong>Starts the actor</strong> with <code class="language-plaintext highlighter-rouge">client.actor(...).call(run_input=...)</code>. <code class="language-plaintext highlighter-rouge">call()</code> blocks until the run finishes, so there is no polling code to write.</li>
  <li><strong>Reads the results</strong> from the run’s default dataset. Each item is one keyword with its <code class="language-plaintext highlighter-rouge">timeline</code>, related queries and metadata.</li>
  <li><strong>Flattens</strong> the nested JSON into two CSV files.</li>
</ol>

<p>The input fields used here:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">searchTerms</code></td>
      <td>List of keywords, one result per keyword</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">geo</code></td>
      <td>Country or region code such as <code class="language-plaintext highlighter-rouge">US</code>, <code class="language-plaintext highlighter-rouge">IT</code>, <code class="language-plaintext highlighter-rouge">GB</code>; empty string means worldwide</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">timeRange</code></td>
      <td>Same syntax as the Trends website, e.g. <code class="language-plaintext highlighter-rouge">today 3-m</code>, <code class="language-plaintext highlighter-rouge">today 12-m</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">includeInterestByRegion</code></td>
      <td>Turned off here to keep the output small</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">includeRelated</code></td>
      <td>Adds top and rising related queries</td>
    </tr>
  </tbody>
</table>

<p>The <a href="https://apify.com/lagenuina_agency/google-trends-scraper">actor page</a> (Input tab) documents the rest, including a Trending Now mode.</p>

<h2 id="running-it">Running it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python example.py <span class="s2">"chatgpt"</span> <span class="s2">"claude"</span>
</code></pre></div></div>

<p>The run takes a few seconds per keyword. The console log ends like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFO  Scraping 2 keyword job(s)
INFO  Done: chatgpt
INFO  Done: claude
Status: SUCCEEDED, Message: 2 result(s)
2 keywords -&gt; trends_2026-10-17.csv, rising_2026-10-17.csv
</code></pre></div></div>

<h2 id="real-output">Real output</h2>

<p>This is an excerpt from a run on 17 October 2026, worldwide, over a one-month window. Over a short window Trends returns daily points. <code class="language-plaintext highlighter-rouge">trends_2026-10-17.csv</code>:</p>

<pre><code class="language-csv">searchTerm,geo,date,value,isPartial
chatgpt,Worldwide,2026-09-17,74,False
chatgpt,Worldwide,2026-09-18,64,False
chatgpt,Worldwide,2026-09-19,55,False
...
chatgpt,Worldwide,2026-10-15,99,False
chatgpt,Worldwide,2026-10-16,84,False
chatgpt,Worldwide,2026-10-17,74,True
claude,Worldwide,2026-09-17,66,False
claude,Worldwide,2026-09-18,58,False
...
claude,Worldwide,2026-10-16,90,False
claude,Worldwide,2026-10-17,73,True
</code></pre>

<p>And <code class="language-plaintext highlighter-rouge">rising_2026-10-17.csv</code>:</p>

<pre><code class="language-csv">searchTerm,query,growth
chatgpt,chatgpt dots,"+1,200%"
claude,opus 5.5,Breakout
claude,claude opus 5.5,Breakout
claude,claude startup program,"+2,650%"
claude,claude for startups,+750%
claude,claude mods,+650%
</code></pre>

<p>Some notes on reading this data:</p>

<ul>
  <li><strong>Values are relative, not search volumes.</strong> Like the Trends website, 100 is the peak inside the requested keyword set, time range and region. Change any of those and the numbers are rescaled.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">isPartial: True</code></strong> marks the latest point, which is still being collected. Drop it before you compare it with earlier points, or it will look like a sudden fall.</li>
  <li><strong>“Breakout”</strong> in rising queries means growth over 5,000%, so Trends shows a label instead of a percentage.</li>
</ul>

<p>If you want the raw JSON rather than CSV, each dataset item looks like this (shortened, from a run for <code class="language-plaintext highlighter-rouge">chatgpt</code> in Italy over 12 months):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"keyword"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"searchTerm"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chatgpt"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"geo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"IT"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"timeRange"</span><span class="p">:</span><span class="w"> </span><span class="s2">"today 12-m"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"data_granularity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"week"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"timeline"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"date"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-10-11T00:00:00.000Z"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="mi">88</span><span class="p">,</span><span class="w"> </span><span class="nl">"isPartial"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"relatedQueries_top"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chatgpt gratis"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="p">,</span><span class="w"> </span><span class="nl">"formattedValue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"100"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"relatedQueries_rising"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w"> </span><span class="nl">"query"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chatgpt atlas"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="mi">4250</span><span class="p">,</span><span class="w"> </span><span class="nl">"formattedValue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"+4,250%"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"trendsUrl"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://trends.google.com/trends/explore?q=chatgpt&amp;date=today+12-m&amp;hl=en-US&amp;geo=IT"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">trendsUrl</code> field is useful for spot checks: open it and compare the chart with your CSV.</p>

<h2 id="loading-it-into-pandas">Loading it into pandas</h2>

<p>The CSV is already in long format, so pivoting it for a chart is short:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">pandas</span> <span class="k">as</span> <span class="n">pd</span>

<span class="n">df</span> <span class="o">=</span> <span class="n">pd</span><span class="p">.</span><span class="n">read_csv</span><span class="p">(</span><span class="s">"trends_2026-10-17.csv"</span><span class="p">,</span> <span class="n">parse_dates</span><span class="o">=</span><span class="p">[</span><span class="s">"date"</span><span class="p">])</span>
<span class="n">df</span> <span class="o">=</span> <span class="n">df</span><span class="p">[</span><span class="o">~</span><span class="n">df</span><span class="p">[</span><span class="s">"isPartial"</span><span class="p">]]</span>  <span class="c1"># drop the incomplete last point
</span><span class="n">wide</span> <span class="o">=</span> <span class="n">df</span><span class="p">.</span><span class="n">pivot</span><span class="p">(</span><span class="n">index</span><span class="o">=</span><span class="s">"date"</span><span class="p">,</span> <span class="n">columns</span><span class="o">=</span><span class="s">"searchTerm"</span><span class="p">,</span> <span class="n">values</span><span class="o">=</span><span class="s">"value"</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">wide</span><span class="p">.</span><span class="n">tail</span><span class="p">())</span>
</code></pre></div></div>

<h2 id="scheduling-it">Scheduling it</h2>

<p>The script names its files by date, so running it on a schedule builds a history without overwriting anything:</p>

<ul>
  <li><strong>cron</strong> on your own machine: <code class="language-plaintext highlighter-rouge">0 8 * * 1 cd /path/to/script &amp;&amp; python example.py "brand a" "brand b" --geo US</code></li>
  <li><strong>Apify Schedules</strong>: schedule the actor itself in the Apify Console and read the dataset later.</li>
  <li><strong>n8n</strong>: the repo includes a workflow that appends the rows to Google Sheets every Monday. I wrote a separate post on setting it up.</li>
</ul>

<h2 id="what-it-costs">What it costs</h2>

<p>The actor uses pay-per-result pricing with no start fee. One keyword with all its data counts as one result. The price per 1,000 results depends on your Apify plan: <strong>$2.40 on the Free plan, going down to $0.24 on Gold</strong>. Tracking 10 keywords every week is about 520 results a year. Check the <a href="https://apify.com/lagenuina_agency/google-trends-scraper">actor page</a> for current prices before you budget.</p>

<h2 id="using-it-from-ai-agents-mcp">Using it from AI agents (MCP)</h2>

<p>Apify runs an MCP server, so you can give the actor to Claude, Cursor, VS Code or any other MCP client as a tool, without writing Python:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"google-trends-scraper"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://mcp.apify.com/?actors=lagenuina_agency/google-trends-scraper"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Sign in with Apify OAuth when the client asks, or send <code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;APIFY_TOKEN&gt;</code>. Then you can ask questions such as “compare interest in X and Y in Germany over the last 12 months and list the rising queries” and the agent calls the actor for you.</p>

<h2 id="limits-worth-knowing">Limits worth knowing</h2>

<ul>
  <li>This is still Google Trends data, with the same sampling and normalisation as the website. Two runs for the same query can differ slightly.</li>
  <li>In the run above each keyword came back as its own job, with its own 0–100 scale (both peak near 99). So <code class="language-plaintext highlighter-rouge">chatgpt 74</code> next to <code class="language-plaintext highlighter-rouge">claude 73</code> does not mean equal search volume. Compare shapes and changes over time, not absolute values across keywords.</li>
  <li>The scraper reads only publicly available data. It is not affiliated with or endorsed by Google. Use it in line with the applicable terms and laws.</li>
</ul>

<h2 id="wrap-up">Wrap-up</h2>

<p>If pytrends stopped working for you, or you are tired of handling 429s, moving the scraping to a hosted actor and keeping your Python to about 40 lines of CSV code is a practical trade-off. Clone the <a href="https://github.com/assa83/google-trends-api-python">example repo</a>, set <code class="language-plaintext highlighter-rouge">APIFY_TOKEN</code> and run it.</p>

<hr />

<p><em>Disclosure: I built this actor. It is published on Apify by Quelli di Carne Genuina, and I earn from its usage.</em></p>]]></content><author><name>Quelli di Carne Genuina</name></author><category term="python," /><category term="googletrends," /><category term="webscraping," /><category term="api" /><summary type="html"><![CDATA[pytrends is archived and 429 errors are common. Here is a small, tested Python script that exports Google Trends interest over time and rising queries to CSV.]]></summary></entry><entry><title type="html">Weekly Google Trends report in Google Sheets with n8n (no code)</title><link href="https://assa83.github.io/google-trends-to-google-sheets-n8n/" rel="alternate" type="text/html" title="Weekly Google Trends report in Google Sheets with n8n (no code)" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://assa83.github.io/google-trends-to-google-sheets-n8n</id><content type="html" xml:base="https://assa83.github.io/google-trends-to-google-sheets-n8n/"><![CDATA[<p>Checking Google Trends by hand works for a one-off question. It falls apart when you want to follow the same ten keywords every week: you forget, the date range shifts, and the screenshots don’t line up.</p>

<p>This post sets up a small n8n workflow that does it for you. Every Monday at 08:00 it:</p>

<ol>
  <li>Fetches Google Trends interest over time for your keywords</li>
  <li>Flattens the result into one row per keyword and date</li>
  <li>Appends those rows to a Google Sheet</li>
</ol>

<p>You import a ready-made workflow, so you don’t write code. The data comes from the <a href="https://apify.com/lagenuina_agency/google-trends-scraper">Google Trends Scraper actor on Apify</a>, which n8n calls with a plain HTTP Request node.</p>

<p><strong>Be aware before you start:</strong> the workflow file is valid n8n JSON and its Apify call uses the same endpoint and input that I tested from Python. I have <strong>not</strong> run this exact workflow inside an n8n instance. Node versions differ between n8n releases, so treat it as a starting point and do one manual run before you activate it.</p>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>An n8n instance (n8n Cloud or self-hosted)</li>
  <li>A Google account with access to Google Sheets</li>
  <li>A free Apify account and your API token from <a href="https://console.apify.com/settings/integrations">Settings → Integrations</a></li>
  <li>The workflow file: <a href="https://github.com/assa83/google-trends-api-python/blob/main/n8n/workflow.json"><code class="language-plaintext highlighter-rouge">n8n/workflow.json</code></a> from the repo <a href="https://github.com/assa83/google-trends-api-python">assa83/google-trends-api-python</a></li>
</ul>

<h2 id="how-the-workflow-is-built">How the workflow is built</h2>

<p>Four nodes in a straight line:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Schedule Trigger ──&gt; Run Apify actor ──&gt; Flatten timeline ──&gt; Append to Google Sheets
(Mon 08:00)          (HTTP Request)      (Code)               (Google Sheets)
</code></pre></div></div>

<ul>
  <li><strong>Schedule Trigger</strong>: weekly, on day 1 (Monday), at hour 8.</li>
  <li><strong>Run Apify actor</strong>: a <code class="language-plaintext highlighter-rouge">POST</code> to Apify’s <code class="language-plaintext highlighter-rouge">run-sync-get-dataset-items</code> endpoint. This endpoint starts the actor, waits for it to finish and returns the dataset items in the response, so there is no polling. The node timeout is set to 300,000 ms (5 minutes), the most this endpoint waits.</li>
  <li><strong>Flatten timeline</strong>: a short JavaScript snippet that turns each keyword’s nested <code class="language-plaintext highlighter-rouge">timeline</code> array into separate rows.</li>
  <li><strong>Append to Google Sheets</strong>: adds those rows to the sheet, mapping fields to columns automatically.</li>
</ul>

<h2 id="step-1-import-the-workflow">Step 1: Import the workflow</h2>

<ol>
  <li>Download <code class="language-plaintext highlighter-rouge">workflow.json</code> from the repo.</li>
  <li>In n8n, open <strong>Workflows</strong>, then use <strong>Import from File</strong> and pick the file. You can also open an empty workflow and paste the JSON onto the canvas.</li>
  <li>You should see the four nodes connected. Two of them will show credential warnings. That is expected, you set them up next.</li>
</ol>

<h2 id="step-2-create-the-apify-credential">Step 2: Create the Apify credential</h2>

<p>The HTTP Request node expects a <strong>Header Auth</strong> credential named <code class="language-plaintext highlighter-rouge">Apify API token</code>.</p>

<ol>
  <li>Open the <strong>Run Apify actor</strong> node.</li>
  <li>Under <em>Authentication</em> it is already set to <em>Generic Credential Type</em> → <em>Header Auth</em>. In the credential dropdown, choose <strong>Create new credential</strong>.</li>
  <li>Fill it in:
    <ul>
      <li><strong>Credential name</strong>: <code class="language-plaintext highlighter-rouge">Apify API token</code></li>
      <li><strong>Name</strong>: <code class="language-plaintext highlighter-rouge">Authorization</code></li>
      <li><strong>Value</strong>: <code class="language-plaintext highlighter-rouge">Bearer </code> followed by your token (with the space after <code class="language-plaintext highlighter-rouge">Bearer</code>)</li>
    </ul>
  </li>
  <li>Save and make sure the node now uses this credential.</li>
</ol>

<p>The token lives in n8n’s encrypted credential store, not in the workflow JSON. If you export or share the workflow later, the token does not go with it.</p>

<h2 id="step-3-set-your-keywords">Step 3: Set your keywords</h2>

<p>Still in <strong>Run Apify actor</strong>, edit the JSON body:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"searchTerms"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"chatgpt"</span><span class="p">,</span><span class="w"> </span><span class="s2">"gemini"</span><span class="p">],</span><span class="w">
  </span><span class="nl">"geo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"US"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"timeRange"</span><span class="p">:</span><span class="w"> </span><span class="s2">"today 3-m"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"includeInterestByRegion"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">searchTerms</code>: your keywords. Each one comes back as one result.</li>
  <li><code class="language-plaintext highlighter-rouge">geo</code>: a country or region code (<code class="language-plaintext highlighter-rouge">US</code>, <code class="language-plaintext highlighter-rouge">GB</code>, <code class="language-plaintext highlighter-rouge">IT</code>, …). Use <code class="language-plaintext highlighter-rouge">""</code> for worldwide.</li>
  <li><code class="language-plaintext highlighter-rouge">timeRange</code>: the same syntax as the Trends website, such as <code class="language-plaintext highlighter-rouge">today 3-m</code> or <code class="language-plaintext highlighter-rouge">today 12-m</code>.</li>
</ul>

<p>The other input fields, including related queries and Trending Now, are on the <a href="https://apify.com/lagenuina_agency/google-trends-scraper">actor page</a> (Input tab).</p>

<p>Keep the keyword list modest: the synchronous endpoint waits up to 300 seconds. If a long list times out, switch to Apify’s asynchronous <code class="language-plaintext highlighter-rouge">/runs</code> endpoint with a webhook.</p>

<h2 id="step-4-connect-google-sheets">Step 4: Connect Google Sheets</h2>

<ol>
  <li>
    <p>Create a new Google Sheet. In row 1 of the first tab, write these headers exactly:</p>

    <table>
      <thead>
        <tr>
          <th>A</th>
          <th>B</th>
          <th>C</th>
          <th>D</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>searchTerm</td>
          <td>geo</td>
          <td>date</td>
          <td>value</td>
        </tr>
      </tbody>
    </table>

    <p>The node uses automatic mapping, which matches incoming field names to column headers. Without the headers it has nothing to map to.</p>
  </li>
  <li>Copy the sheet ID from its URL. It is the long string between <code class="language-plaintext highlighter-rouge">/d/</code> and <code class="language-plaintext highlighter-rouge">/edit</code>.</li>
  <li>Open <strong>Append to Google Sheets</strong>:
    <ul>
      <li>Create or select a <strong>Google Sheets</strong> credential (n8n walks you through the Google sign-in).</li>
      <li>Replace <code class="language-plaintext highlighter-rouge">YOUR_GOOGLE_SHEET_ID</code> with your sheet ID.</li>
      <li>Check that <em>Sheet</em> matches your tab name. The workflow uses <code class="language-plaintext highlighter-rouge">Sheet1</code>, so change it if your tab has another name, for example in a localised Google account.</li>
    </ul>
  </li>
</ol>

<h2 id="step-5-test-once-by-hand">Step 5: Test once, by hand</h2>

<p>Before scheduling anything, click <strong>Execute workflow</strong> (or <strong>Test workflow</strong>, depending on your n8n version) and check each node:</p>

<ul>
  <li><strong>Run Apify actor</strong> should output one item per keyword, each with <code class="language-plaintext highlighter-rouge">searchTerm</code>, <code class="language-plaintext highlighter-rouge">geo</code> and a <code class="language-plaintext highlighter-rouge">timeline</code> array.</li>
  <li>
    <p><strong>Flatten timeline</strong> should output many small items such as:</p>

    <div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w"> </span><span class="nl">"searchTerm"</span><span class="p">:</span><span class="w"> </span><span class="s2">"chatgpt"</span><span class="p">,</span><span class="w"> </span><span class="nl">"geo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"US"</span><span class="p">,</span><span class="w"> </span><span class="nl">"date"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2026-10-16"</span><span class="p">,</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="mi">84</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></code></pre></div>    </div>
  </li>
  <li><strong>Append to Google Sheets</strong> should report the rows it wrote, and they should be in your sheet.</li>
</ul>

<p>If the HTTP node returns <code class="language-plaintext highlighter-rouge">401</code>, the header value is wrong. Check that it reads <code class="language-plaintext highlighter-rouge">Bearer &lt;token&gt;</code> with one space and no quotes. If Sheets complains about columns, check the header row from Step 4.</p>

<h2 id="step-6-activate-the-schedule">Step 6: Activate the schedule</h2>

<p>Flip the workflow to <strong>Active</strong>. The trigger fires every Monday at 08:00 in your n8n instance’s timezone. Check that under the workflow or instance settings if your server runs in UTC and you don’t.</p>

<p>To change the day or hour, open <strong>Schedule Trigger</strong> and edit the weekly rule.</p>

<h2 id="one-thing-to-decide-overlapping-weeks">One thing to decide: overlapping weeks</h2>

<p>Each Monday the workflow fetches the <strong>last three months</strong>, not just the last week. Appending blindly means most rows show up again every week. You have three reasonable options:</p>

<ol>
  <li><strong>Keep it as a snapshot log.</strong> Add a column with the run date and treat each week’s block as a separate snapshot. This is useful because Google Trends rescales values as new data arrives, so a snapshot shows what the numbers looked like at that moment.</li>
  <li>
    <p><strong>Keep only recent days.</strong> Change the Code node to keep only the last 7 days, so each run adds just the new week. For example:</p>

    <div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// One row per keyword and day, only the last 7 days.</span>
<span class="kd">const</span> <span class="nx">since</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Date</span><span class="p">(</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span> <span class="o">-</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">864</span><span class="nx">e5</span><span class="p">).</span><span class="nx">toISOString</span><span class="p">().</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">rows</span> <span class="o">=</span> <span class="p">[];</span>
<span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="p">{</span> <span class="nx">json</span> <span class="p">}</span> <span class="k">of</span> <span class="nx">$input</span><span class="p">.</span><span class="nx">all</span><span class="p">())</span> <span class="p">{</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">p</span> <span class="k">of</span> <span class="nx">json</span><span class="p">.</span><span class="nx">timeline</span> <span class="o">??</span> <span class="p">[])</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">date</span> <span class="o">=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">date</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="nx">date</span> <span class="o">&gt;=</span> <span class="nx">since</span><span class="p">)</span> <span class="nx">rows</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span> <span class="na">json</span><span class="p">:</span> <span class="p">{</span> <span class="na">searchTerm</span><span class="p">:</span> <span class="nx">json</span><span class="p">.</span><span class="nx">searchTerm</span><span class="p">,</span> <span class="na">geo</span><span class="p">:</span> <span class="nx">json</span><span class="p">.</span><span class="nx">geo</span><span class="p">,</span> <span class="nx">date</span><span class="p">,</span> <span class="na">value</span><span class="p">:</span> <span class="nx">p</span><span class="p">.</span><span class="nx">value</span> <span class="p">}</span> <span class="p">});</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">rows</span><span class="p">;</span>
</code></pre></div>    </div>

    <p>This snippet is a variant of the one in the repo and I have not run it in n8n, so check its output in a manual test first.</p>
  </li>
  <li><strong>Deduplicate in Sheets.</strong> Leave the workflow as is and use <em>Data → Data cleanup → Remove duplicates</em> now and then, or build your report on a <code class="language-plaintext highlighter-rouge">UNIQUE()</code> range.</li>
</ol>

<p>Also remember that the latest point is usually <strong>partial</strong> (still being collected). The original API response marks it with <code class="language-plaintext highlighter-rouge">isPartial: true</code>. The Code node in the workflow does not copy that field, so add <code class="language-plaintext highlighter-rouge">isPartial: p.isPartial</code> to the row object if you want to filter it out in Sheets.</p>

<h2 id="reading-the-numbers">Reading the numbers</h2>

<p>Google Trends values are <strong>relative</strong>, from 0 to 100, where 100 is the peak for that query, region and time range. They are not search volumes. In my tests each keyword came back on its own 0–100 scale, so compare how each line moves over time rather than the absolute values between two keywords.</p>

<p>For a quick report, build a pivot table (<code class="language-plaintext highlighter-rouge">date</code> as rows, <code class="language-plaintext highlighter-rouge">searchTerm</code> as columns) and add a line chart on it.</p>

<h2 id="what-it-costs">What it costs</h2>

<p>n8n costs whatever your plan or server costs. On the Apify side the actor uses pay-per-result pricing with no start fee. One keyword with all its data is one result. The price per 1,000 results goes from <strong>$2.40 on the Free plan down to $0.24 on Gold</strong>. Ten keywords every Monday is 520 results a year. Check the <a href="https://apify.com/lagenuina_agency/google-trends-scraper">actor page</a> for current prices.</p>

<h2 id="prefer-code">Prefer code?</h2>

<p>The same repo has a Python script that writes the data to dated CSV files (<code class="language-plaintext highlighter-rouge">trends_YYYY-MM-DD.csv</code> and <code class="language-plaintext highlighter-rouge">rising_YYYY-MM-DD.csv</code>) and a Node.js example. I covered the Python version in a separate post. If you work with AI agents, you can also expose the actor as an MCP tool at <code class="language-plaintext highlighter-rouge">https://mcp.apify.com/?actors=lagenuina_agency/google-trends-scraper</code>.</p>

<p>The scraper reads only publicly available data and is not affiliated with or endorsed by Google. Use it in line with the applicable terms and laws.</p>

<hr />

<p><em>Disclosure: I built this actor. It is published on Apify by Quelli di Carne Genuina, and I earn from its usage.</em></p>]]></content><author><name>Quelli di Carne Genuina</name></author><category term="n8n," /><category term="googletrends," /><category term="automation," /><category term="nocode" /><summary type="html"><![CDATA[Import one n8n workflow and get Google Trends interest for your keywords appended to a Google Sheet every Monday morning.]]></summary></entry><entry><title type="html">YouTube transcript API in Python that works on servers (no IP blocks)</title><link href="https://assa83.github.io/youtube-transcript-api-python/" rel="alternate" type="text/html" title="YouTube transcript API in Python that works on servers (no IP blocks)" /><published>2026-10-17T00:00:00+00:00</published><updated>2026-10-17T00:00:00+00:00</updated><id>https://assa83.github.io/youtube-transcript-api-python</id><content type="html" xml:base="https://assa83.github.io/youtube-transcript-api-python/"><![CDATA[<p>If you have ever built something on top of YouTube transcripts (a summariser, a RAG index, a search over a podcast archive), you probably started with the open-source <a href="https://github.com/jdepoix/youtube-transcript-api"><code class="language-plaintext highlighter-rouge">youtube-transcript-api</code></a> package. It is a good library, and on your laptop it works on the first try.</p>

<p>Then you deploy it. On AWS, Google Cloud, Azure, Render, Railway or a GitHub Actions runner, the same call fails with <code class="language-plaintext highlighter-rouge">RequestBlocked</code> or <code class="language-plaintext highlighter-rouge">IpBlocked</code>. YouTube blocks most cloud and datacenter IP ranges for caption requests, and the library’s own README says so: on a server you need rotating residential proxies.</p>

<p>So the real problem is not parsing captions. It is getting a request through from a server IP, every day, without running your own proxy pool.</p>

<p>This tutorial moves that part to a hosted scraper, the <a href="https://apify.com/lagenuina_agency/youtube-transcript-scraper">YouTube Transcript Scraper actor on Apify</a>, and keeps your Python small. The code below runs anywhere <code class="language-plaintext highlighter-rouge">pip install</code> works, because the request to YouTube does not leave from your server.</p>

<h2 id="what-you-need">What you need</h2>

<ul>
  <li>Python 3.9+</li>
  <li>A free Apify account and your API token, from <a href="https://console.apify.com/settings/integrations">Settings → Integrations</a></li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">APIFY_TOKEN</span><span class="o">=</span>your_token_here
pip <span class="nb">install</span> <span class="s2">"apify-client&gt;=1.7,&lt;2"</span>
</code></pre></div></div>

<p>The examples target the 1.x client, where <code class="language-plaintext highlighter-rouge">.call()</code> returns a plain dict.</p>

<h2 id="the-short-version-one-function">The short version: one function</h2>

<p>If you only need the text of one video inside your app, this is all it takes:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">os</span>
<span class="kn">from</span> <span class="nn">apify_client</span> <span class="kn">import</span> <span class="n">ApifyClient</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">ApifyClient</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">[</span><span class="s">"APIFY_TOKEN"</span><span class="p">])</span>


<span class="k">def</span> <span class="nf">get_transcript</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">languages</span><span class="o">=</span><span class="p">(</span><span class="s">"en"</span><span class="p">,)):</span>
    <span class="n">run</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">actor</span><span class="p">(</span><span class="s">"lagenuina_agency/youtube-transcript-scraper"</span><span class="p">).</span><span class="n">call</span><span class="p">(</span>
        <span class="n">run_input</span><span class="o">=</span><span class="p">{</span><span class="s">"youtube_url"</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="s">"languages"</span><span class="p">:</span> <span class="nb">list</span><span class="p">(</span><span class="n">languages</span><span class="p">)}</span>
    <span class="p">)</span>
    <span class="n">item</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">dataset</span><span class="p">(</span><span class="n">run</span><span class="p">[</span><span class="s">"defaultDatasetId"</span><span class="p">]).</span><span class="n">list_items</span><span class="p">().</span><span class="n">items</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">item</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"success"</span><span class="p">):</span>
        <span class="k">raise</span> <span class="nb">RuntimeError</span><span class="p">(</span><span class="n">item</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"error_message"</span><span class="p">,</span> <span class="s">"no transcript"</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">item</span><span class="p">[</span><span class="s">"non_timestamped"</span><span class="p">]</span>


<span class="k">print</span><span class="p">(</span><span class="n">get_transcript</span><span class="p">(</span><span class="s">"https://youtu.be/jNQXAC9IVRw"</span><span class="p">)[:</span><span class="mi">80</span><span class="p">])</span>
</code></pre></div></div>

<p>Output:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>All right, so here we are, in front of the elephants the cool thing about these
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">languages</code> is an ordered preference list: the first language YouTube has wins, and manual captions beat auto-generated ones in the same language.</p>

<h2 id="the-full-script-many-videos-to-txt-srt-and-a-csv">The full script: many videos to .txt, .srt and a CSV</h2>

<p>For batches (a list of URLs, a playlist or a whole channel), this is the script from the example repo <a href="https://github.com/assa83/youtube-transcript-scraper-api">assa83/youtube-transcript-scraper-api</a>. I ran it today, unchanged.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s">"""Download YouTube transcripts (videos, playlists or a channel) to text files + one CSV.

Usage:
  python example.py https://www.youtube.com/watch?v=dQw4w9WgXcQ
  python example.py https://www.youtube.com/playlist?list=PL... --max 20
  python example.py --channel @TED --max 10 --lang en es
"""</span>
<span class="kn">import</span> <span class="nn">argparse</span>
<span class="kn">import</span> <span class="nn">csv</span>
<span class="kn">import</span> <span class="nn">os</span>
<span class="kn">import</span> <span class="nn">re</span>
<span class="kn">import</span> <span class="nn">sys</span>

<span class="kn">from</span> <span class="nn">apify_client</span> <span class="kn">import</span> <span class="n">ApifyClient</span>

<span class="n">ACTOR</span> <span class="o">=</span> <span class="s">"lagenuina_agency/youtube-transcript-scraper"</span>
<span class="n">TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="p">.</span><span class="n">environ</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"APIFY_TOKEN"</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">TOKEN</span><span class="p">:</span>
    <span class="n">sys</span><span class="p">.</span><span class="nb">exit</span><span class="p">(</span><span class="s">"Set APIFY_TOKEN first: https://console.apify.com/settings/integrations"</span><span class="p">)</span>


<span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
    <span class="n">p</span> <span class="o">=</span> <span class="n">argparse</span><span class="p">.</span><span class="n">ArgumentParser</span><span class="p">()</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"urls"</span><span class="p">,</span> <span class="n">nargs</span><span class="o">=</span><span class="s">"*"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"video / Shorts / playlist URLs or video IDs"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--channel"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"@handle or channel URL: transcribe its newest videos"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--max"</span><span class="p">,</span> <span class="nb">type</span><span class="o">=</span><span class="nb">int</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"max videos per channel or playlist"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--lang"</span><span class="p">,</span> <span class="n">nargs</span><span class="o">=</span><span class="s">"+"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="p">[</span><span class="s">"en"</span><span class="p">],</span> <span class="n">help</span><span class="o">=</span><span class="s">"preferred languages, in order"</span><span class="p">)</span>
    <span class="n">p</span><span class="p">.</span><span class="n">add_argument</span><span class="p">(</span><span class="s">"--out"</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s">"transcripts"</span><span class="p">,</span> <span class="n">help</span><span class="o">=</span><span class="s">"output folder"</span><span class="p">)</span>
    <span class="n">a</span> <span class="o">=</span> <span class="n">p</span><span class="p">.</span><span class="n">parse_args</span><span class="p">()</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">a</span><span class="p">.</span><span class="n">urls</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">a</span><span class="p">.</span><span class="n">channel</span><span class="p">:</span>
        <span class="n">p</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="s">"pass at least one URL or --channel"</span><span class="p">)</span>

    <span class="n">run_input</span> <span class="o">=</span> <span class="p">{</span><span class="s">"youtube_url"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">urls</span><span class="p">,</span> <span class="s">"languages"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="n">lang</span><span class="p">,</span> <span class="s">"max_videos"</span><span class="p">:</span> <span class="n">a</span><span class="p">.</span><span class="nb">max</span><span class="p">,</span> <span class="s">"output_formats"</span><span class="p">:</span> <span class="p">[</span><span class="s">"srt"</span><span class="p">]}</span>
    <span class="k">if</span> <span class="n">a</span><span class="p">.</span><span class="n">channel</span><span class="p">:</span>
        <span class="n">run_input</span><span class="p">.</span><span class="n">update</span><span class="p">(</span><span class="n">channel</span><span class="o">=</span><span class="n">a</span><span class="p">.</span><span class="n">channel</span><span class="p">,</span> <span class="n">channel_transcripts</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">run</span> <span class="o">=</span> <span class="n">ApifyClient</span><span class="p">(</span><span class="n">TOKEN</span><span class="p">).</span><span class="n">actor</span><span class="p">(</span><span class="n">ACTOR</span><span class="p">).</span><span class="n">call</span><span class="p">(</span><span class="n">run_input</span><span class="o">=</span><span class="n">run_input</span><span class="p">)</span>
    <span class="n">items</span> <span class="o">=</span> <span class="n">ApifyClient</span><span class="p">(</span><span class="n">TOKEN</span><span class="p">).</span><span class="n">dataset</span><span class="p">(</span><span class="n">run</span><span class="p">[</span><span class="s">"defaultDatasetId"</span><span class="p">]).</span><span class="n">list_items</span><span class="p">().</span><span class="n">items</span>

    <span class="n">os</span><span class="p">.</span><span class="n">makedirs</span><span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">out</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">out</span><span class="p">,</span> <span class="s">"transcripts.csv"</span><span class="p">),</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s">""</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
        <span class="n">w</span> <span class="o">=</span> <span class="n">csv</span><span class="p">.</span><span class="n">writer</span><span class="p">(</span><span class="n">f</span><span class="p">)</span>
        <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="s">"video_id"</span><span class="p">,</span> <span class="s">"title"</span><span class="p">,</span> <span class="s">"channel"</span><span class="p">,</span> <span class="s">"language"</span><span class="p">,</span> <span class="s">"source_type"</span><span class="p">,</span> <span class="s">"words"</span><span class="p">,</span> <span class="s">"url"</span><span class="p">,</span> <span class="s">"error"</span><span class="p">])</span>
        <span class="k">for</span> <span class="n">it</span> <span class="ow">in</span> <span class="n">items</span><span class="p">:</span>
            <span class="n">w</span><span class="p">.</span><span class="n">writerow</span><span class="p">([</span><span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"video_id"</span><span class="p">),</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"title"</span><span class="p">),</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"channel_name"</span><span class="p">),</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"language_code"</span><span class="p">),</span>
                        <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"source_type"</span><span class="p">),</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"word_count"</span><span class="p">),</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"url"</span><span class="p">),</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"error_message"</span><span class="p">,</span> <span class="s">""</span><span class="p">)])</span>
            <span class="k">if</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"success"</span><span class="p">):</span>
                <span class="n">name</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="n">sub</span><span class="p">(</span><span class="sa">r</span><span class="s">"[^\w-]+"</span><span class="p">,</span> <span class="s">"_"</span><span class="p">,</span> <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">it</span><span class="p">[</span><span class="s">'video_id'</span><span class="p">]</span><span class="si">}</span><span class="s">_</span><span class="si">{</span><span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'title'</span><span class="p">)</span> <span class="ow">or</span> <span class="s">''</span><span class="si">}</span><span class="s">"</span><span class="p">)[:</span><span class="mi">80</span><span class="p">]</span>
                <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">out</span><span class="p">,</span> <span class="n">name</span> <span class="o">+</span> <span class="s">".txt"</span><span class="p">),</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">t</span><span class="p">:</span>
                    <span class="n">t</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">'title'</span><span class="p">)</span><span class="si">}</span><span class="se">\n</span><span class="si">{</span><span class="n">it</span><span class="p">[</span><span class="s">'url'</span><span class="p">]</span><span class="si">}</span><span class="se">\n\n</span><span class="si">{</span><span class="n">it</span><span class="p">[</span><span class="s">'non_timestamped'</span><span class="p">]</span><span class="si">}</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
                <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">a</span><span class="p">.</span><span class="n">out</span><span class="p">,</span> <span class="n">name</span> <span class="o">+</span> <span class="s">".srt"</span><span class="p">),</span> <span class="s">"w"</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p">)</span> <span class="k">as</span> <span class="n">t</span><span class="p">:</span>
                    <span class="n">t</span><span class="p">.</span><span class="n">write</span><span class="p">(</span><span class="n">it</span><span class="p">[</span><span class="s">"srt"</span><span class="p">])</span>
    <span class="n">ok</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="mi">1</span> <span class="k">for</span> <span class="n">it</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">it</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"success"</span><span class="p">))</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">ok</span><span class="si">}</span><span class="s"> transcript(s), </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">items</span><span class="p">)</span> <span class="o">-</span> <span class="n">ok</span><span class="si">}</span><span class="s"> without captions/failed -&gt; </span><span class="si">{</span><span class="n">a</span><span class="p">.</span><span class="n">out</span><span class="si">}</span><span class="s">/"</span><span class="p">)</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="s">"__main__"</span><span class="p">:</span>
    <span class="n">main</span><span class="p">()</span>
</code></pre></div></div>

<p>The input fields that matter:</p>

<table>
  <thead>
    <tr>
      <th>Field</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">youtube_url</code></td>
      <td>One URL or a list: videos, Shorts, <code class="language-plaintext highlighter-rouge">youtu.be</code> links, playlists or bare video IDs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">channel</code> + <code class="language-plaintext highlighter-rouge">channel_transcripts</code></td>
      <td>A <code class="language-plaintext highlighter-rouge">@handle</code> or channel URL; with <code class="language-plaintext highlighter-rouge">channel_transcripts: true</code> its newest videos are transcribed</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">max_videos</code></td>
      <td>How many videos per channel or playlist</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">languages</code></td>
      <td>Ordered language preference</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">output_formats</code></td>
      <td>Extra formats: <code class="language-plaintext highlighter-rouge">srt</code>, <code class="language-plaintext highlighter-rouge">vtt</code>, <code class="language-plaintext highlighter-rouge">text</code></td>
    </tr>
  </tbody>
</table>

<h2 id="real-output">Real output</h2>

<p>Run on 17 October 2026:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python example.py https://www.youtube.com/watch?v<span class="o">=</span>jNQXAC9IVRw https://www.youtube.com/watch?v<span class="o">=</span>dQw4w9WgXcQ
</code></pre></div></div>

<p>The run log shows exactly the problem this post is about, handled for you:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INFO  2 video(s) to process (languages: en)
INFO  YouTube is blocking this IP range, switching to residential proxy.
INFO  [Status message]: 2 transcript(s)
Status: SUCCEEDED, Message: 2 transcript(s)
2 transcript(s), 0 without captions/failed -&gt; transcripts/
</code></pre></div></div>

<p>The whole run took about 12 seconds. <code class="language-plaintext highlighter-rouge">transcripts/transcripts.csv</code>:</p>

<pre><code class="language-csv">video_id,title,channel,language,source_type,words,url,error
dQw4w9WgXcQ,Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster),Rick Astley,en,Manual,487,https://www.youtube.com/watch?v=dQw4w9WgXcQ,
jNQXAC9IVRw,Me at the zoo,jawed,en,Manual,39,https://www.youtube.com/watch?v=jNQXAC9IVRw,
</code></pre>

<p>And the start of the <code class="language-plaintext highlighter-rouge">.srt</code> file for the second video:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1
00:00:01,360 --&gt; 00:00:03,040
[♪♪♪]

2
00:00:18,640 --&gt; 00:00:21,880
♪ We're no strangers to love ♪
</code></pre></div></div>

<p>Each dataset item also carries <code class="language-plaintext highlighter-rouge">timestamped</code> segments (<code class="language-plaintext highlighter-rouge">text</code>, <code class="language-plaintext highlighter-rouge">start</code>, <code class="language-plaintext highlighter-rouge">duration</code>) if you want to cite the exact second a phrase was said, which is handy for RAG answers that link back to the video.</p>

<h2 id="variants">Variants</h2>

<p><strong>A whole channel.</strong> <code class="language-plaintext highlighter-rouge">python example.py --channel @TED --max 10 --lang en es</code> transcribes the ten newest videos of a channel. Listing the channel is free; you pay only for transcripts.</p>

<p><strong>n8n, no code.</strong> The example repo has an <a href="https://github.com/assa83/youtube-transcript-scraper-api/tree/main/n8n">n8n workflow</a> that runs every morning, transcribes the newest 5 videos of a channel and appends title, URL, language, word count and transcript to Google Sheets. Import the JSON, add a Header Auth credential with <code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;token&gt;</code>, set your channel and sheet.</p>

<p><strong>AI agents (MCP).</strong> Give the actor to Claude, Cursor or any MCP client as a tool:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"youtube-transcript-scraper"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://mcp.apify.com/?actors=lagenuina_agency/youtube-transcript-scraper"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then ask “summarise the last three videos of @channel” and the agent fetches the transcripts itself.</p>

<h2 id="what-it-costs">What it costs</h2>

<p>Pay per transcript, proxies included, no start fee: $2.20 per 1,000 transcripts on the Apify Free plan, $1.60 on Gold. A three-hour podcast costs the same as a Short. Videos without captions and failed videos are free. The two-video run above cost less than a cent. Check the <a href="https://apify.com/lagenuina_agency/youtube-transcript-scraper">actor page</a> for current prices.</p>

<h2 id="limits-worth-knowing">Limits worth knowing</h2>

<ul>
  <li><strong>Captions only.</strong> The actor reads the captions YouTube already has (manual or auto-generated). A video with no captions returns an error row, not a transcript. There is no speech-to-text.</li>
  <li><strong>Translation is best effort.</strong> YouTube refuses most automated translation requests; when it does, you get the original language plus a <code class="language-plaintext highlighter-rouge">translation_error</code> field.</li>
  <li><strong>No signed-in content.</strong> Private, members-only and age-restricted videos fail, because the actor never uses an account.</li>
  <li><strong>Live streams in progress</strong> and premieres that have not aired have no transcript yet.</li>
  <li><strong>Latency.</strong> A hosted run adds a few seconds of start-up compared with a direct library call. For a nightly batch that does not matter; for a user waiting on a button, fetch ahead of time or cache.</li>
</ul>

<h2 id="wrap-up">Wrap-up</h2>

<p>If <code class="language-plaintext highlighter-rouge">youtube-transcript-api</code> works locally and breaks in production, the fix is not more retry code: it is sending the request from an IP YouTube accepts. Clone the <a href="https://github.com/assa83/youtube-transcript-scraper-api">example repo</a>, set <code class="language-plaintext highlighter-rouge">APIFY_TOKEN</code> and run it from the same server that was getting blocked.</p>

<hr />

<p><em>Disclosure: I built this actor. It is published on Apify by Quelli di Carne Genuina, and I earn from its usage.</em></p>]]></content><author><name>Quelli di Carne Genuina</name></author><category term="python," /><category term="youtube," /><category term="api," /><category term="ai" /><summary type="html"><![CDATA[youtube-transcript-api works on your laptop and gets blocked on AWS, GCP or Render. Here is a tested Python script that fetches YouTube transcripts from any server, as text and SRT.]]></summary></entry></feed>