<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Atlas Data]]></title><description><![CDATA[Atlas Data]]></description><link>https://atlasdata.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 10:21:00 GMT</lastBuildDate><atom:link href="https://atlasdata.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Compare salaries and hiring across countries with Glassdoor job data and Python]]></title><description><![CDATA["What does a data analyst earn in London compared to New York?" sounds like a question with an easy answer. In practice salary data is scattered across…]]></description><link>https://atlasdata.hashnode.dev/compare-salaries-and-hiring-across-countries-with-glassdoor-job-data-and-python</link><guid isPermaLink="true">https://atlasdata.hashnode.dev/compare-salaries-and-hiring-across-countries-with-glassdoor-job-data-and-python</guid><category><![CDATA[Python]]></category><category><![CDATA[webscraping ]]></category><category><![CDATA[datascience]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Atlas Data]]></dc:creator><pubDate>Tue, 27 Oct 2026 22:10:01 GMT</pubDate><content:encoded><![CDATA[<p>"What does a data analyst earn in London compared to New York?" sounds like a question with an easy answer. In practice salary data is scattered across reports with different dates, definitions and samples. Live job postings are a decent alternative: they show what employers are advertising right now, and Glassdoor shows a salary range for most of its listings.</p>
<p>In this tutorial we'll write one Python script (~70 lines) that:</p>
<ol>
<li>runs the same Glassdoor search in four cities in four countries, in parallel,</li>
<li>saves every posting to a CSV,</li>
<li>uses pandas to compare yearly pay ranges, how many postings list a salary at all, and which companies are hiring.</li>
</ol>
<p>The example is "data analyst" in New York, London, Berlin and Bengaluru. Swap in any role and any locations Glassdoor covers.</p>
<blockquote>
<p><strong>Disclosure:</strong> the scraper used here is an Apify Actor published by Atlas Data, the author of this post: <a href="https://apify.com/atlasdata/glassdoor-jobs-scraper-reliable">atlasdata/glassdoor-jobs-scraper-reliable</a>. It is pay-per-result ($0.30 per 1,000 jobs plus a small per-run start fee), and the script sets a cost cap on every run. You could also collect the data another way; the pandas part applies either way.</p>
</blockquote>
<h2>Setup</h2>
<pre><code class="language-bash">pip install apify-client pandas   # tested with apify-client 3.4 and pandas 3.1
export APIFY_TOKEN=...             # https://console.apify.com/settings/integrations
</code></pre>
<p>Glassdoor blocks datacenter IPs, so the Actor connects through Apify residential proxies. They are included in the per-job price, so there is nothing to configure.</p>
<h2>What one result looks like</h2>
<p>The input mirrors Glassdoor's own search: <code>keywords</code> (each keyword is a separate search), <code>location</code> (a city, state or country as Glassdoor knows it), <code>datePosted</code> (<code>any</code>, <code>1</code>, <code>3</code>, <code>7</code>, <code>14</code>, <code>30</code> days), <code>jobType</code>, <code>remoteOnly</code>, <code>easyApplyOnly</code>, <code>minRating</code>, <code>minSalary</code>, <code>seniority</code>, <code>radius</code>, <code>sort</code> and <code>maxResults</code>. <code>includeDetails</code> (on by default) adds the full description and the company profile. We don't need those here, so we turn it off.</p>
<p>Each posting is a flat record. These are the fields we'll use, with values from the test run below:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><code>title</code>, <code>companyName</code>, <code>companyRating</code></td>
<td><code>Regulatory Data Analyst</code>, <code>Federal Reserve Bank of New York</code>, <code>3.9</code></td>
</tr>
<tr>
<td><code>salaryMin</code>, <code>salaryMedian</code>, <code>salaryMax</code></td>
<td><code>79300</code>, <code>82150</code>, <code>85000</code></td>
</tr>
<tr>
<td><code>salaryPeriod</code>, <code>salaryCurrency</code></td>
<td><code>year</code>, <code>USD</code></td>
</tr>
<tr>
<td><code>salarySource</code></td>
<td><code>employer</code> (posted by the employer) or <code>glassdoor-estimate</code></td>
</tr>
<tr>
<td><code>salaryAnnualMin</code>, <code>salaryAnnualMax</code></td>
<td>the range converted to a yearly amount</td>
</tr>
<tr>
<td><code>postedDate</code></td>
<td><code>2026-10-21</code></td>
</tr>
<tr>
<td><code>searchLocation</code></td>
<td>the location Glassdoor actually used: <code>New York, NY (US)</code></td>
</tr>
</tbody></table>
<p>Two of these matter a lot for a fair comparison:</p>
<ul>
<li><strong><code>salarySource</code></strong>. Some postings carry the employer's own range. For others Glassdoor shows its own estimate (the 10th to 90th percentile for similar jobs). Both are useful, but they are not the same thing, so we count them separately.</li>
<li><strong><code>salaryAnnualMin</code> / <code>salaryAnnualMax</code></strong>. Hourly and monthly ranges are converted to a yearly amount, so a London contract paid by the hour and a Berlin internship paid by the month can sit in the same column.</li>
</ul>
<h2>1. The searches</h2>
<pre><code class="language-python">KEYWORD = "data analyst"
LOCATIONS = ["New York, NY", "London", "Berlin", "Bengaluru"]
COMMON = {"datePosted": "30", "maxResults": 30, "includeDetails": False}
</code></pre>
<p><code>datePosted: "30"</code> keeps the comparison to postings from the last month. 30 jobs per city is one page of Glassdoor results. That's enough to test the script; raise <code>maxResults</code> for a real analysis (see the cost section at the end).</p>
<p>Locations are free text and go through Glassdoor's location search. Check <code>searchLocation</code> in the results (or the run's <code>OUTPUT</code> record) to see what was matched. In our run all four resolved as expected: <code>New York, NY (US)</code>, <code>London, England (UK)</code>, <code>Berlin (Germany)</code> and <code>Bengaluru (India)</code>. For ambiguous names, add the state or country ("Springfield, IL").</p>
<h2>2. Run the four searches in parallel</h2>
<pre><code class="language-python">def fetch(location: str) -&gt; list[dict]:
    run = client.actor("atlasdata/glassdoor-jobs-scraper-reliable").call(
        run_input={**COMMON, "keywords": [KEYWORD], "location": location},
        max_total_charge_usd=Decimal("0.05"),
        logger=None,
    )
    if run is None or run.status != "SUCCEEDED":
        raise RuntimeError(f"{location}: run failed: {run and run.status}")
    items = list(client.dataset(run.default_dataset_id).iterate_items())
    for item in items:
        item["market"] = location  # the location we asked for
    return items
</code></pre>
<p>One run per location, because <code>location</code> is a single value per run. The runs are independent, so a <code>ThreadPoolExecutor</code> starts all four at once. In the run shown below each one finished in 3 to 7 seconds.</p>
<p><code>max_total_charge_usd</code> is a hard cap on what a single run can cost. The Actor stops cleanly when it reaches it.</p>
<h2>3. Clean the salaries before comparing them</h2>
<p>Before looking at medians, look at the rows. Our first run turned up two things that would have skewed the numbers:</p>
<ul>
<li><strong>Internships.</strong> Four of the Berlin postings were internships paid 2,470 EUR a month, which becomes 29,640 a year. That's correct, but it isn't a data analyst salary.</li>
<li><strong>A typo in a posting.</strong> One Berlin employer posted "70 – 90" a year for a senior role. It almost certainly means 70k to 90k, but the data says 70 to 90.</li>
</ul>
<p>Both are easy to filter:</p>
<pre><code class="language-python">NOT_COMPARABLE = r"\bintern(?:ship)?\b|werkstudent|working student"

paid = df.dropna(subset=["salaryAnnualMin", "salaryAnnualMax"])
paid = paid[~paid["title"].str.contains(NOT_COMPARABLE, case=False)]  # internships
paid = paid[paid["salaryAnnualMin"] &gt;= 1000]  # "70-90 a year" is a typo for 70k-90k
paid = paid.assign(mid=(paid["salaryAnnualMin"] + paid["salaryAnnualMax"]) / 2)
</code></pre>
<p>The <code>\b</code> word boundaries matter: a plain <code>intern</code> would also drop "International" and "Internal Audit" roles. The <code>&gt;= 1000</code> check is deliberately loose because it has to work for USD, GBP, EUR and INR at the same time.</p>
<p>We use the midpoint of each range as one number per posting. It's a simplification (a 50k–110k range and an 80k range get the same midpoint), so the summary also shows the 25th and 75th percentiles.</p>
<h2>4. One row per market</h2>
<pre><code class="language-python">rows.append({
    "market": market,
    "jobs": len(g),
    "salaries": len(p),
    "employer": int((p["salarySource"] == "employer").sum()),
    "currency": p["salaryCurrency"].mode().iat[0] if len(p) else None,
    "p25": p["mid"].quantile(0.25),
    "median": p["mid"].median(),
    "p75": p["mid"].quantile(0.75),
    "rating": g["companyRating"].mean(),
})
</code></pre>
<p><code>salaries</code> is the number of postings that survived the cleaning step, and <code>employer</code> is how many of those were posted by the employer rather than estimated by Glassdoor. Always print these next to the median. A median from 5 postings shouldn't be read the same way as one from 30.</p>
<p>The amounts stay in local currency. If you want one currency, convert with an exchange rate from a date you choose. Keep in mind that salaries in different countries also differ in taxes, benefits and cost of living, so a converted number is still not a like-for-like comparison.</p>
<h2>Running it</h2>
<p>Real output from our test run (October 2026):</p>
<pre><code class="language-text">$ python glassdoor_salaries.py
120 jobs saved to glassdoor_jobs.csv

Yearly pay for "data analyst" (midpoint of each range, local currency):
              jobs  salaries  employer currency     p25  median     p75 rating
market
New York, NY    30        30        29      USD  83,809 102,542 117,750    3.7
London          30        28        15      GBP  45,169  48,325  57,547    3.8
Berlin          30         5         5      EUR  70,000  74,500  80,000    3.7
Bengaluru       30        22         0      INR 614,250 686,500 823,625    3.7

Top hiring companies:
  New York, NY: Intuit (2), Capgemini (2), Horizon Media, Inc. (2)
  London: Elexon (2), TELUS Digital (1), Leyland SDM (1)
  Berlin: Institutional Shareholder Services (4), Zalando (3), MMM Consulting GmbH (1)
  Bengaluru: Société Générale (2), Thales (1), Myntra (1)
</code></pre>
<p>With only 30 postings per city this is a test of the method, not a salary report. Still, the coverage columns are interesting on their own:</p>
<ul>
<li><strong>New York:</strong> 29 of 30 postings had a range posted by the employer. New York requires employers to include pay ranges in job ads, and it shows.</li>
<li><strong>London:</strong> 28 of 30 had a salary, about half from employers and half Glassdoor estimates.</li>
<li><strong>Berlin:</strong> only 10 of 30 postings had any salary. After removing the internships and the typo, 5 were left. Don't read anything into that median.</li>
<li><strong>Bengaluru:</strong> 22 of 30 had a salary, and every one of them was a Glassdoor estimate. That is "what Glassdoor estimates similar jobs pay", not what these employers offered.</li>
</ul>
<p>"Top hiring companies" is also thin at this sample size: most companies appear once or twice. With a few hundred postings per city it becomes more meaningful, and you can group by <code>companyName</code> to see each company's rating and salary range together.</p>
<h2>Full script</h2>
<pre><code class="language-python">"""Compare advertised salaries and top hiring companies across countries with Glassdoor job data."""
import os
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal

import pandas as pd
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

KEYWORD = "data analyst"
LOCATIONS = ["New York, NY", "London", "Berlin", "Bengaluru"]
COMMON = {"datePosted": "30", "maxResults": 30, "includeDetails": False}
NOT_COMPARABLE = r"\bintern(?:ship)?\b|werkstudent|working student"


def fetch(location: str) -&gt; list[dict]:
    run = client.actor("atlasdata/glassdoor-jobs-scraper-reliable").call(
        run_input={**COMMON, "keywords": [KEYWORD], "location": location},
        max_total_charge_usd=Decimal("0.05"),
        logger=None,
    )
    if run is None or run.status != "SUCCEEDED":
        raise RuntimeError(f"{location}: run failed: {run and run.status}")
    items = list(client.dataset(run.default_dataset_id).iterate_items())
    for item in items:
        item["market"] = location  # the location we asked for
    return items


def summarize(df: pd.DataFrame) -&gt; pd.DataFrame:
    paid = df.dropna(subset=["salaryAnnualMin", "salaryAnnualMax"])
    paid = paid[~paid["title"].str.contains(NOT_COMPARABLE, case=False)]  # internships
    paid = paid[paid["salaryAnnualMin"] &gt;= 1000]  # "70-90 a year" is a typo for 70k-90k
    paid = paid.assign(mid=(paid["salaryAnnualMin"] + paid["salaryAnnualMax"]) / 2)

    rows = []
    for market, g in df.groupby("market", sort=False):
        p = paid[paid["market"] == market]
        rows.append({
            "market": market,
            "jobs": len(g),
            "salaries": len(p),
            "employer": int((p["salarySource"] == "employer").sum()),
            "currency": p["salaryCurrency"].mode().iat[0] if len(p) else None,
            "p25": p["mid"].quantile(0.25),
            "median": p["mid"].median(),
            "p75": p["mid"].quantile(0.75),
            "rating": g["companyRating"].mean(),
        })
    return pd.DataFrame(rows).set_index("market")


def main() -&gt; None:
    with ThreadPoolExecutor(max_workers=len(LOCATIONS)) as pool:
        batches = list(pool.map(fetch, LOCATIONS))
    df = pd.DataFrame([job for batch in batches for job in batch])
    df.to_csv("glassdoor_jobs.csv", index=False)
    print(f"{len(df)} jobs saved to glassdoor_jobs.csv\n")

    pd.options.display.float_format = "{:,.0f}".format
    summary = summarize(df)
    summary["rating"] = summary["rating"].round(1).astype(str)
    print(f'Yearly pay for "{KEYWORD}" (midpoint of each range, local currency):')
    print(summary.to_string(), "\n")

    print("Top hiring companies:")
    for market, g in df.groupby("market", sort=False):
        top = g["companyName"].value_counts().head(3)
        print(f"  {market}: " + ", ".join(f"{name} ({n})" for name, n in top.items()))


if __name__ == "__main__":
    main()
</code></pre>
<h2>What it costs</h2>
<p>The run above saved 120 jobs in four runs: 120 × $0.0003 plus four run starts at $0.0005 is <strong>$0.038</strong>. Description and company details are included at the same price if you turn <code>includeDetails</code> back on. For a sturdier comparison, 300 postings per city across the same four cities would be 1,200 jobs, or about $0.36. Duplicates within a run are not charged.</p>
<h2>Ideas for extending it</h2>
<ul>
<li><strong>Employer-posted only:</strong> filter on <code>salarySource == "employer"</code> and compare that median with the Glassdoor estimates for the same market.</li>
<li><strong>More roles:</strong> put several titles in <code>keywords</code> (each one is its own search) and pivot by <code>searchQuery</code> × <code>market</code>.</li>
<li><strong>Company profiles:</strong> with <code>includeDetails: True</code> you get <code>companySize</code>, <code>companyRevenue</code>, <code>companyWebsite</code> and <code>companyHeadquarters</code>, which turns "who is hiring" into a lead list.</li>
<li><strong>Track it over time:</strong> run it weekly with <code>datePosted: "7"</code> and append to the CSV or a database. The Google Jobs tracker post below shows a SQLite pattern for first-seen / last-seen dates.</li>
</ul>
<p>Glassdoor is one job board. <a href="https://apify.com/atlasdata/google-jobs-scraper-reliable">Google Jobs Scraper</a>, our sister Actor, covers the Google Jobs panel, which pulls postings from LinkedIn, company career sites and many other boards. We used it in <a href="https://dev.to/atlasdata/build-a-small-job-market-tracker-with-google-jobs-python-and-sqlite-380k">Build a small job-market tracker with Google Jobs, Python and SQLite</a>.</p>
<p>To ask an AI assistant instead ("what do data analyst jobs in London pay right now?"), the Actor also works as an MCP tool through the hosted Apify MCP server. The client configs for Claude, Cursor and VS Code are in <a href="https://github.com/AtlasDataDenmark/atlas-data-mcp">atlas-data-mcp</a>; add <code>atlasdata/glassdoor-jobs-scraper-reliable</code> to the <code>tools=</code> list in the endpoint URL. The Actor's <a href="https://apify.com/atlasdata/glassdoor-jobs-scraper-reliable">Store page</a> documents every input and output field.</p>
]]></content:encoded></item><item><title><![CDATA[Monitor Google Trends on a schedule (and stop fighting 429s)]]></title><description><![CDATA[If you've tried polling Google Trends from a script, you know how it usually goes. pytrends works for a few requests, then you start getting 429 Too Many…]]></description><link>https://atlasdata.hashnode.dev/monitor-google-trends-on-a-schedule-and-stop-fighting-429s</link><guid isPermaLink="true">https://atlasdata.hashnode.dev/monitor-google-trends-on-a-schedule-and-stop-fighting-429s</guid><category><![CDATA[Python]]></category><category><![CDATA[SEO]]></category><category><![CDATA[automation]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Atlas Data]]></dc:creator><pubDate>Tue, 27 Oct 2026 15:06:18 GMT</pubDate><content:encoded><![CDATA[<p>If you've tried polling Google Trends from a script, you know how it usually goes. <code>pytrends</code> works for a few requests, then you start getting <code>429 Too Many Requests</code>, and the cron job writes empty files for a week before anyone notices.</p>
<p>This post sets up a Google Trends monitor that:</p>
<ul>
<li>runs every morning without a machine of yours being on,</li>
<li>fetches <strong>interest over time</strong> and <strong>related queries</strong> for a list of terms,</li>
<li>flags terms that are up against their weekly average, plus queries Google marks <strong>Breakout</strong>,</li>
<li>and tells you explicitly when something didn't work, instead of returning nothing.</li>
</ul>
<blockquote>
<p><strong>Disclosure:</strong> the scraper is an Apify Actor published by Atlas Data, the author of this post: <a href="https://apify.com/atlasdata/google-trends-scraper-reliable">atlasdata/google-trends-scraper-reliable</a>. It is pay-per-result, and the task below caps the cost of every run. Docs and examples in other languages are in the <a href="https://github.com/AtlasDataDenmark/google-trends-scraper">GitHub repo</a>.</p>
</blockquote>
<h2>Why Trends scrapers get blocked</h2>
<p>Google Trends has no official API. The web app calls internal endpoints that are rate-limited per IP address and per cookie session, and the limits are tight: a handful of quick requests from one IP is often enough to trigger 429s, and datacenter IPs are throttled sooner. The usual DIY fixes are <code>time.sleep()</code> and retries. They help, but on a schedule they still fail eventually, because the IP is the same every run.</p>
<p>The Actor does what you would end up building yourself:</p>
<ul>
<li><strong>A fresh cookie session per attempt</strong>, so a throttled session isn't reused.</li>
<li><strong>Exponential backoff with a new IP</strong> on every retry. <code>maxRetries</code> defaults to 8 (range 0–20).</li>
<li><strong>Automatic proxy fallback</strong> (<code>proxyFallback</code>, on by default). If the current connection keeps getting blocked, it moves from your proxy to Apify datacenter, then to Apify residential if your plan includes it, then to a direct connection.</li>
<li><strong>A per-term status report</strong> in the run's <code>OUTPUT</code> record (<code>SUCCEEDED</code> / <code>PARTIAL</code> / <code>NO_DATA</code> / <code>FAILED</code>), so an empty result is never ambiguous.</li>
</ul>
<p>None of this is magic. Google can still withhold some data. For example, related <em>topics</em> are currently not returned to any automated session, and city-level regions are often missing. When that happens, the summary says so.</p>
<h2>Setup</h2>
<pre><code class="language-bash">pip install apify-client   # tested with apify-client 3.4
export APIFY_TOKEN=...      # https://console.apify.com/settings/integrations
</code></pre>
<h2>1. Define what to watch</h2>
<pre><code class="language-python">ACTOR = "atlasdata/google-trends-scraper-reliable"
TASK_NAME = "trends-watch-ai-tools"
TRENDS_INPUT = {
    "searchTerms": ["chatgpt", "claude ai", "gemini ai", "perplexity"],
    "geo": "US",
    "timeRange": "now 7-d",
    "includeInterestOverTime": True,
    "includeInterestByRegion": False,
    "includeRelatedQueries": True,
    "includeRelatedTopics": False,
    "outputFormat": "flat",
    "maxRetries": 8,  # retries with fresh sessions/proxies when Google returns 429
}
</code></pre>
<p>The inputs worth knowing:</p>
<ul>
<li><code>geo</code>: empty for worldwide, a country (<code>"US"</code>) or a sub-region (<code>"US-CA"</code>).</li>
<li><code>timeRange</code>: <code>now 1-H</code>, <code>now 4-H</code>, <code>now 1-d</code>, <code>now 7-d</code> (hourly points), <code>today 1-m</code>, <code>today 3-m</code>, <code>today 12-m</code>, <code>today 5-y</code>, <code>all</code>, or <code>custom</code> with <code>startDate</code>/<code>endDate</code>.</li>
<li><code>compareTerms</code>: by default every term is scaled 0–100 <strong>on its own</strong>. Set it to <code>true</code> to query terms together (up to 5 per group), like the compare view on the website. Do that if you want to say "A is searched more than B". Leave it off if you only care about each term's own trend, as we do here.</li>
<li><code>outputFormat: "flat"</code> returns one row per data point. Each row has a <code>recordType</code> of <code>interestOverTime</code>, <code>interestByRegion</code>, <code>relatedQuery</code> or <code>relatedTopic</code>, which is easy to filter in Python.</li>
</ul>
<h2>2. Save it as a task and schedule it</h2>
<p>An Apify <strong>task</strong> is a saved input for an Actor. A <strong>schedule</strong> runs it on a cron expression in Apify's cloud. Both are created with the API client:</p>
<pre><code class="language-python">def setup() -&gt; None:
    task = client.tasks().create(
        actor_id=ACTOR,
        name=TASK_NAME,
        task_input=TRENDS_INPUT,
        max_total_charge_usd=Decimal("0.25"),  # cost cap for every run of this task
    )
    schedule = client.schedules().create(
        name=f"{TASK_NAME}-daily",
        cron_expression="0 7 * * *",  # every day at 07:00
        timezone="UTC",
        is_enabled=True,
        is_exclusive=True,  # skip a tick if the previous run is still going
        actions=[{"type": "RUN_ACTOR_TASK", "actorTaskId": task.id}],
    )
    print(f"task {task.id} and schedule {schedule.id} created")
</code></pre>
<p>Two settings matter for unattended jobs:</p>
<ul>
<li><code>max_total_charge_usd</code> on the task caps the cost of <strong>every</strong> scheduled run, so a typo in the input can't turn into a large bill.</li>
<li><code>is_exclusive=True</code> stops runs from piling up if one is slow.</li>
</ul>
<p>You can do the same in the Apify Console (Actor → <em>Save as a new task</em> → <em>Schedule</em>). The code version lives in your repo and is easy to recreate.</p>
<h2>3. Read the latest run and flag what's moving</h2>
<pre><code class="language-python">def latest_items() -&gt; list[dict]:
    task = next(t for t in client.tasks().list().items if t.name == TASK_NAME)
    run = client.task(task.id).last_run(status="SUCCEEDED").get()
    if run is None:
        raise SystemExit("No finished run yet - wait for the schedule or start the task once.")
    print(f"latest run {run.id} finished {run.finished_at:%Y-%m-%d %H:%M} UTC")
    return list(client.dataset(run.default_dataset_id).iterate_items())


def check() -&gt; None:
    items = latest_items()

    # 1) Interest over time: compare the last day with the week's average.
    for term in TRENDS_INPUT["searchTerms"]:
        points = [i["value"] for i in items
                  if i["recordType"] == "interestOverTime" and i["searchTerm"] == term
                  and i.get("hasData", True) and not i.get("isPartial")]
        if len(points) &gt;= 48:
            last_day = sum(points[-24:]) / 24
            week = sum(points) / len(points)
            flag = "  &lt;-- up" if last_day &gt; 1.3 * week else ""
            print(f"{term:12s} last 24h avg {last_day:5.1f} vs 7-day avg {week:5.1f}{flag}")

    # 2) Related queries that are rising fast or marked "Breakout".
    rising = [i for i in items if i["recordType"] == "relatedQuery" and i["rankType"] == "rising"]
    print("\nBreakout / fast-rising related queries:")
    for i in sorted(rising, key=lambda i: (not i.get("isBreakout"), -(i.get("value") or 0)))[:15]:
        label = "Breakout" if i.get("isBreakout") else f'+{i["value"]}%'
        print(f'  [{i["searchTerm"]}] {i["query"]}  ({label})')
</code></pre>
<p>A few details:</p>
<ul>
<li><code>isPartial</code> marks the newest data point, which Google is still filling in. Leave it out, or the last hour always looks like a drop.</li>
<li>For <code>relatedQuery</code> rows with <code>rankType: "rising"</code>, <code>value</code> is the percentage increase. <code>isBreakout</code> is <code>true</code> when Google shows "Breakout" (an increase above 5000%).</li>
<li><code>last_run(status="SUCCEEDED")</code> skips a run that failed, so a bad night doesn't wipe the report.</li>
</ul>
<h2>Running it</h2>
<pre><code class="language-bash">python trends_monitor.py setup   # once
python trends_monitor.py check   # whenever you want the report
</code></pre>
<p>Output from a real run (US, past 7 days, 4 terms; the run took about 9 seconds):</p>
<pre><code class="language-text">latest run 47xnXFExBms4IJ2ba finished 2026-10-27 13:27 UTC
chatgpt      last 24h avg  79.3 vs 7-day avg  71.4
claude ai    last 24h avg  71.5 vs 7-day avg  67.2
gemini ai    last 24h avg  69.8 vs 7-day avg  66.5
perplexity   last 24h avg  46.5 vs 7-day avg  45.3

Breakout / fast-rising related queries:
  [claude ai] claude duet  (Breakout)
  [claude ai] anthropic claude duet ai agent  (Breakout)
  [claude ai] claude ai software vulnerability analysis  (Breakout)
  [claude ai] claude ai oblivion fps boost  (Breakout)
  [perplexity] perplexity ai on device mac  (+1800%)
  [claude ai] venice ai  (+50%)
  [perplexity] is perplexity better than chatgpt  (+50%)
  [claude ai] anthropic claude  (+40%)
</code></pre>
<p>Nothing crossed the +30% threshold that day. The related queries are where the news shows up: four Breakout queries for one term in a single week.</p>
<p>The run's <code>OUTPUT</code> summary for the same run reported <code>terms: {total: 4, succeeded: 4, partial: 0, noData: 0, failed: 0}</code>, with 676 interest-over-time rows and 101 related-query rows. That is the record to alert on if you want to know about failures as well as trends:</p>
<pre><code class="language-python">summary = client.key_value_store(run.default_key_value_store_id).get_record("OUTPUT")["value"]
if summary["terms"]["failed"] or summary["terms"]["noData"]:
    print("some terms returned nothing:", [t["searchTerm"] for t in summary["perTerm"] if t["status"] != "SUCCEEDED"])
</code></pre>
<h2>Getting the report to you</h2>
<p><code>check()</code> only reads stored data and never runs the scraper, so it costs nothing to call. Some ways to wire it up:</p>
<ul>
<li>Run it from your own cron or a GitHub Actions schedule an hour after the Apify schedule.</li>
<li>Add a <strong>webhook</strong> on the task (Console → task → <em>Integrations</em>) that calls your endpoint when a run succeeds, and have the endpoint run <code>check()</code> and post to Slack.</li>
<li>Load the dataset into a sheet or BI tool. <code>GET https://api.apify.com/v2/datasets/{datasetId}/items?format=csv</code> also works.</li>
</ul>
<h2>Full script</h2>
<pre><code class="language-python">"""Watch Google Trends on a schedule and flag rising / breakout queries.

    python trends_monitor.py setup   # once: create a saved task + a daily schedule
    python trends_monitor.py check   # any time: read the latest scheduled run
"""
import os
import sys
from decimal import Decimal

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

ACTOR = "atlasdata/google-trends-scraper-reliable"
TASK_NAME = "trends-watch-ai-tools"
TRENDS_INPUT = {
    "searchTerms": ["chatgpt", "claude ai", "gemini ai", "perplexity"],
    "geo": "US",
    "timeRange": "now 7-d",
    "includeInterestOverTime": True,
    "includeInterestByRegion": False,
    "includeRelatedQueries": True,
    "includeRelatedTopics": False,
    "outputFormat": "flat",
    "maxRetries": 8,  # retries with fresh sessions/proxies when Google returns 429
}


def setup() -&gt; None:
    task = client.tasks().create(
        actor_id=ACTOR,
        name=TASK_NAME,
        task_input=TRENDS_INPUT,
        max_total_charge_usd=Decimal("0.25"),  # cost cap for every run of this task
    )
    schedule = client.schedules().create(
        name=f"{TASK_NAME}-daily",
        cron_expression="0 7 * * *",  # every day at 07:00
        timezone="UTC",
        is_enabled=True,
        is_exclusive=True,  # skip a tick if the previous run is still going
        actions=[{"type": "RUN_ACTOR_TASK", "actorTaskId": task.id}],
    )
    print(f"task {task.id} and schedule {schedule.id} created")


def latest_items() -&gt; list[dict]:
    task = next(t for t in client.tasks().list().items if t.name == TASK_NAME)
    run = client.task(task.id).last_run(status="SUCCEEDED").get()
    if run is None:
        raise SystemExit("No finished run yet - wait for the schedule or start the task once.")
    print(f"latest run {run.id} finished {run.finished_at:%Y-%m-%d %H:%M} UTC")
    return list(client.dataset(run.default_dataset_id).iterate_items())


def check() -&gt; None:
    items = latest_items()

    # 1) Interest over time: compare the last day with the week's average.
    for term in TRENDS_INPUT["searchTerms"]:
        points = [i["value"] for i in items
                  if i["recordType"] == "interestOverTime" and i["searchTerm"] == term
                  and i.get("hasData", True) and not i.get("isPartial")]
        if len(points) &gt;= 48:
            last_day = sum(points[-24:]) / 24
            week = sum(points) / len(points)
            flag = "  &lt;-- up" if last_day &gt; 1.3 * week else ""
            print(f"{term:12s} last 24h avg {last_day:5.1f} vs 7-day avg {week:5.1f}{flag}")

    # 2) Related queries that are rising fast or marked "Breakout".
    rising = [i for i in items if i["recordType"] == "relatedQuery" and i["rankType"] == "rising"]
    print("\nBreakout / fast-rising related queries:")
    for i in sorted(rising, key=lambda i: (not i.get("isBreakout"), -(i.get("value") or 0)))[:15]:
        label = "Breakout" if i.get("isBreakout") else f'+{i["value"]}%'
        print(f'  [{i["searchTerm"]}] {i["query"]}  ({label})')


if __name__ == "__main__":
    {"setup": setup, "check": check}[sys.argv[1] if len(sys.argv) &gt; 1 else "check"]()
</code></pre>
<h2>Variations</h2>
<ul>
<li><strong>Seasonality:</strong> <code>timeRange: "today 5-y"</code> with <code>includeInterestByRegion: True</code> shows when and where demand peaks each year.</li>
<li><strong>Content planning:</strong> feed the Breakout queries into your editorial backlog every Monday.</li>
<li><strong>Other Google properties:</strong> <code>property</code> can be YouTube, News, Images or Shopping searches instead of web search.</li>
<li><strong>Ask an assistant:</strong> the same Actor is available as an MCP tool for Claude, Cursor and other clients. Configs are in <a href="https://github.com/AtlasDataDenmark/atlas-data-mcp">atlas-data-mcp</a>.</li>
</ul>
<p>Every input and output field is documented on the <a href="https://apify.com/atlasdata/google-trends-scraper-reliable">Store page</a> and in the <a href="https://github.com/AtlasDataDenmark/google-trends-scraper">GitHub repo</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Build a small job-market tracker with Google Jobs, Python and SQLite]]></title><description><![CDATA[Google Jobs (the job panel you get when you search "data engineer jobs in Austin") pulls together postings from LinkedIn, ZipRecruiter, company career…]]></description><link>https://atlasdata.hashnode.dev/build-a-small-job-market-tracker-with-google-jobs-python-and-sqlite</link><guid isPermaLink="true">https://atlasdata.hashnode.dev/build-a-small-job-market-tracker-with-google-jobs-python-and-sqlite</guid><category><![CDATA[Python]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[Data Science]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Atlas Data]]></dc:creator><pubDate>Tue, 27 Oct 2026 15:05:43 GMT</pubDate><content:encoded><![CDATA[<p>Google Jobs (the job panel you get when you search "data engineer jobs in Austin") pulls together postings from LinkedIn, ZipRecruiter, company career sites, Glassdoor and many smaller boards. That makes it a good single source if you want to follow one slice of the job market over time: a role in a few cities, a skill or a set of companies.</p>
<p>We'll build a tracker that:</p>
<ol>
<li>snapshots Google Jobs for a few searches once a day,</li>
<li>stores every posting in SQLite and keeps the date it was <strong>first</strong> and <strong>last</strong> seen,</li>
<li>prints the new postings, the top hiring companies and the median advertised salary.</li>
</ol>
<p>It's about 90 lines of Python and uses only the standard library plus <code>apify-client</code>.</p>
<blockquote>
<p><strong>Disclosure:</strong> the scraper used here is an Apify Actor published by Atlas Data, the author of this post: <a href="https://apify.com/atlasdata/google-jobs-scraper-reliable">atlasdata/google-jobs-scraper-reliable</a>. It is pay-per-result and the script sets a cost cap on every run. More examples (Node.js, curl, MCP) are in the <a href="https://github.com/AtlasDataDenmark/google-jobs-scraper">GitHub repo</a>.</p>
</blockquote>
<h2>Setup</h2>
<pre><code class="language-bash">pip install apify-client   # tested with apify-client 3.4
export APIFY_TOKEN=...      # https://console.apify.com/settings/integrations
</code></pre>
<h2>What one result looks like</h2>
<p>The input mirrors the Google Jobs filters: <code>queries</code>, <code>location</code>, <code>country</code>, <code>language</code>, <code>datePosted</code> (<code>any</code>, <code>today</code>, <code>3days</code>, <code>week</code>, <code>month</code>), <code>jobType</code>, <code>remoteOnly</code>, <code>maxResults</code>, plus <code>includeDescription</code> and <code>includeApplyLinks</code> to control how much is returned per job.</p>
<p>Each posting comes back as a flat record. These are the fields we'll use:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td><code>jobId</code></td>
<td>stable ID from Google, used as our primary key</td>
</tr>
<tr>
<td><code>title</code>, <code>companyName</code>, <code>location</code></td>
<td><code>Senior Data Engineer</code>, <code>General Motors</code>, <code>Austin, TX</code></td>
</tr>
<tr>
<td><code>via</code></td>
<td>where Google found it, e.g. <code>LinkedIn</code>, <code>ZipRecruiter</code>, <code>Talent.com</code></td>
</tr>
<tr>
<td><code>jobType</code></td>
<td><code>Full-time</code></td>
</tr>
<tr>
<td><code>postedAt</code></td>
<td>ISO date derived from "3 days ago"</td>
</tr>
<tr>
<td><code>salaryMin</code>, <code>salaryMax</code>, <code>salaryCurrency</code>, <code>salaryPeriod</code></td>
<td><code>95000</code>, <code>112000</code>, <code>USD</code>, <code>year</code></td>
</tr>
<tr>
<td><code>applyUrl</code></td>
<td>first apply link</td>
</tr>
<tr>
<td><code>searchQuery</code></td>
<td>the query that produced the row</td>
</tr>
</tbody></table>
<p><code>salary</code> also keeps the raw text (<code>$95K–$112K a year</code>) in case you want to parse it yourself.</p>
<h2>1. The searches</h2>
<pre><code class="language-python">SEARCHES = [
    {"queries": ["data engineer"], "location": "Austin, TX", "country": "us"},
    {"queries": ["data engineer"], "location": "Seattle, WA", "country": "us"},
]
COMMON = {"datePosted": "week", "maxResults": 30, "includeDescription": False}
</code></pre>
<p><code>datePosted: "week"</code> is a good choice for a daily job: each run covers a week of postings, and a missed day doesn't leave a gap. <code>includeDescription: False</code> keeps rows small. Turn it on if you want to count skills in job descriptions later.</p>
<p>A tip on localization: match <code>country</code> (and, if you set it, <code>language</code>) to the market you're searching, e.g. <code>"country": "de"</code> with a German query such as <code>"Softwareentwickler"</code>. In our tests, an English query with a forced <code>language: "en"</code> in a non-English market sometimes returned an empty Google Jobs panel. The run's <code>OUTPUT</code> record then reports <code>NO_DATA</code> for that search, so it doesn't fail silently.</p>
<h2>2. A table that remembers</h2>
<pre><code class="language-python">db = sqlite3.connect("jobs.db")
db.execute("""
CREATE TABLE IF NOT EXISTS jobs (
    job_id TEXT PRIMARY KEY,
    title TEXT, company TEXT, location TEXT, via TEXT, job_type TEXT,
    salary_min REAL, salary_max REAL, salary_currency TEXT, salary_period TEXT,
    posted_at TEXT, apply_url TEXT, search_query TEXT,
    first_seen TEXT, last_seen TEXT
)""")
</code></pre>
<p><code>first_seen</code> never changes after the first insert. <code>last_seen</code> is refreshed on every run that still finds the job. With these two columns you get "new today" (<code>first_seen = today</code>) and "probably filled or closed" (<code>last_seen</code> older than a few days) without storing full snapshots.</p>
<h2>3. Fetch and upsert</h2>
<pre><code class="language-python">def fetch(search: dict) -&gt; list[dict]:
    run = client.actor("atlasdata/google-jobs-scraper-reliable").call(
        run_input={**COMMON, **search},
        max_total_charge_usd=Decimal("0.30"),
        logger=None,
    )
    if run is None or run.status != "SUCCEEDED":
        raise RuntimeError(f"run failed: {run and run.status}")
    return list(client.dataset(run.default_dataset_id).iterate_items())


def upsert(job: dict, today: str) -&gt; bool:
    """Insert a job or refresh last_seen. Returns True when the job is new."""
    is_new = db.execute("SELECT 1 FROM jobs WHERE job_id = ?", (job["jobId"],)).fetchone() is None
    db.execute(
        """INSERT INTO jobs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
           ON CONFLICT(job_id) DO UPDATE SET last_seen = excluded.last_seen""",
        (
            job["jobId"], job["title"], job["companyName"], job.get("location"), job.get("via"),
            job.get("jobType"), job.get("salaryMin"), job.get("salaryMax"), job.get("salaryCurrency"),
            job.get("salaryPeriod"), job.get("postedAt"), job.get("applyUrl"), job.get("searchQuery"),
            today, today,
        ),
    )
    return is_new
</code></pre>
<p><code>ON CONFLICT ... DO UPDATE</code> (SQLite 3.24+) turns the insert into an upsert, so the script can be re-run safely any number of times.</p>
<h2>4. Simple market stats</h2>
<pre><code class="language-python">    print("\nTop hiring companies (last 30 days):")
    for company, n in db.execute(
        """SELECT company, COUNT(*) FROM jobs WHERE last_seen &gt;= date('now', '-30 day')
           GROUP BY company ORDER BY COUNT(*) DESC LIMIT 10"""
    ):
        print(f"  {n:3d}  {company}")

    yearly = [
        (lo + hi) / 2
        for lo, hi in db.execute(
            """SELECT salary_min, salary_max FROM jobs
               WHERE salary_period = 'year' AND salary_currency = 'USD'
                 AND salary_min &gt;= 10000 AND salary_max IS NOT NULL"""  # skip non-annual figures
        )
    ]
</code></pre>
<p>Only some postings include a salary. Print how many did, so a median based on 13 postings isn't read as covering all 60. The <code>salary_min &gt;= 10000</code> condition is a cheap sanity check that keeps hourly or monthly figures out of an annual median.</p>
<h2>Running it</h2>
<p>First run (real output):</p>
<pre><code class="language-text">$ python jobs_tracker.py
Austin, TX: 30 jobs
Seattle, WA: 30 jobs
60 new postings today

Top hiring companies (last 30 days):
    2  ICF
    2  Amazon
    2  Alignerr
    1  Vaspire Technologies
    ...

Median advertised salary (USD/year): 166,500 from 13 of 60 postings that list one
</code></pre>
<p>From the second day on, "new postings today" only counts jobs that weren't seen before. Put it in cron:</p>
<pre><code class="language-cron">0 6 * * *  cd /path/to/tracker &amp;&amp; APIFY_TOKEN=... python jobs_tracker.py &gt;&gt; tracker.log 2&gt;&amp;1
</code></pre>
<p>or use an Apify schedule if you'd rather not keep a machine running. The companion Google Trends post shows how.</p>
<h2>Full script</h2>
<pre><code class="language-python">"""Daily Google Jobs snapshot -&gt; SQLite, with simple market stats."""
import os
import sqlite3
from datetime import date
from decimal import Decimal
from statistics import median

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

SEARCHES = [
    {"queries": ["data engineer"], "location": "Austin, TX", "country": "us"},
    {"queries": ["data engineer"], "location": "Seattle, WA", "country": "us"},
]
COMMON = {"datePosted": "week", "maxResults": 30, "includeDescription": False}

db = sqlite3.connect("jobs.db")
db.execute("""
CREATE TABLE IF NOT EXISTS jobs (
    job_id TEXT PRIMARY KEY,
    title TEXT, company TEXT, location TEXT, via TEXT, job_type TEXT,
    salary_min REAL, salary_max REAL, salary_currency TEXT, salary_period TEXT,
    posted_at TEXT, apply_url TEXT, search_query TEXT,
    first_seen TEXT, last_seen TEXT
)""")


def fetch(search: dict) -&gt; list[dict]:
    run = client.actor("atlasdata/google-jobs-scraper-reliable").call(
        run_input={**COMMON, **search},
        max_total_charge_usd=Decimal("0.30"),
        logger=None,
    )
    if run is None or run.status != "SUCCEEDED":
        raise RuntimeError(f"run failed: {run and run.status}")
    return list(client.dataset(run.default_dataset_id).iterate_items())


def upsert(job: dict, today: str) -&gt; bool:
    """Insert a job or refresh last_seen. Returns True when the job is new."""
    is_new = db.execute("SELECT 1 FROM jobs WHERE job_id = ?", (job["jobId"],)).fetchone() is None
    db.execute(
        """INSERT INTO jobs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
           ON CONFLICT(job_id) DO UPDATE SET last_seen = excluded.last_seen""",
        (
            job["jobId"], job["title"], job["companyName"], job.get("location"), job.get("via"),
            job.get("jobType"), job.get("salaryMin"), job.get("salaryMax"), job.get("salaryCurrency"),
            job.get("salaryPeriod"), job.get("postedAt"), job.get("applyUrl"), job.get("searchQuery"),
            today, today,
        ),
    )
    return is_new


def main() -&gt; None:
    today = date.today().isoformat()
    new_jobs = 0
    for search in SEARCHES:
        jobs = fetch(search)
        new_jobs += sum(upsert(job, today) for job in jobs)
        print(f'{search["location"]}: {len(jobs)} jobs')
    db.commit()
    print(f"{new_jobs} new postings today")

    print("\nTop hiring companies (last 30 days):")
    for company, n in db.execute(
        """SELECT company, COUNT(*) FROM jobs WHERE last_seen &gt;= date('now', '-30 day')
           GROUP BY company ORDER BY COUNT(*) DESC LIMIT 10"""
    ):
        print(f"  {n:3d}  {company}")

    yearly = [
        (lo + hi) / 2
        for lo, hi in db.execute(
            """SELECT salary_min, salary_max FROM jobs
               WHERE salary_period = 'year' AND salary_currency = 'USD'
                 AND salary_min &gt;= 10000 AND salary_max IS NOT NULL"""  # skip non-annual figures
        )
    ]
    total = db.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
    if yearly:
        print(f"\nMedian advertised salary (USD/year): {median(yearly):,.0f} "
              f"from {len(yearly)} of {total} postings that list one")


if __name__ == "__main__":
    main()
</code></pre>
<h2>Ideas for extending it</h2>
<ul>
<li><strong>Time-to-close:</strong> <code>julianday(last_seen) - julianday(first_seen)</code> per company shows how fast roles disappear.</li>
<li><strong>Skills:</strong> set <code>includeDescription: True</code> and count mentions of <code>dbt</code>, <code>Spark</code>, <code>Airflow</code>, etc. per week.</li>
<li><strong>Remote share:</strong> run the same query with <code>remoteOnly: True</code> and compare counts over time.</li>
<li><strong>Where jobs are posted:</strong> <code>GROUP BY via</code> shows whether a market leans on LinkedIn, company career sites or niche boards.</li>
</ul>
<p>The Actor's <a href="https://apify.com/atlasdata/google-jobs-scraper-reliable">Store page</a> documents every input and output field, and the <a href="https://github.com/AtlasDataDenmark/google-jobs-scraper">GitHub repo</a> has runnable examples. To query jobs from an AI assistant instead, the MCP setup is in <a href="https://github.com/AtlasDataDenmark/atlas-data-mcp">atlas-data-mcp</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Track a competitor's ads on Google, LinkedIn and TikTok with Python]]></title><description><![CDATA[Google, LinkedIn and TikTok all publish public ad libraries: All three are useful for competitive research: what a brand is promoting, which formats it…]]></description><link>https://atlasdata.hashnode.dev/track-a-competitor-s-ads-on-google-linkedin-and-tiktok-with-python</link><guid isPermaLink="true">https://atlasdata.hashnode.dev/track-a-competitor-s-ads-on-google-linkedin-and-tiktok-with-python</guid><category><![CDATA[Python]]></category><category><![CDATA[marketing]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Atlas Data]]></dc:creator><pubDate>Tue, 27 Oct 2026 15:04:31 GMT</pubDate><content:encoded><![CDATA[<p>Google, LinkedIn and TikTok all publish public ad libraries:</p>
<ul>
<li>the <a href="https://adstransparency.google.com/">Google Ads Transparency Center</a>,</li>
<li>the <a href="https://www.linkedin.com/ad-library/">LinkedIn Ad Library</a>,</li>
<li>the <a href="https://library.tiktok.com/ads">TikTok Ad Library</a> (EU/EEA, Switzerland, the UK and Turkey).</li>
</ul>
<p>All three are useful for competitive research: what a brand is promoting, which formats it uses, which landing pages it sends people to, and how long an ad stays live. None of them has a convenient export, and the three sites look nothing alike.</p>
<p>In this tutorial we'll write one Python script (~120 lines) that:</p>
<ol>
<li>fetches a brand's ads from all three libraries in parallel,</li>
<li>maps them to one common table (<code>platform, ad_id, advertiser, format, text, first_shown, last_shown, landing_url, ad_url</code>),</li>
<li>keeps only ads from the brand itself (keyword searches return other advertisers too),</li>
<li>remembers which ads it has already seen, so each run tells you what is <strong>new</strong>.</li>
</ol>
<p>The example brand is Canva, because it runs ads on all three platforms. Swap in any company you like.</p>
<blockquote>
<p><strong>Disclosure:</strong> the three scrapers used here are Apify Actors published by Atlas Data, the author of this post. They are pay-per-result, and every call below sets a hard cost cap. You could also write the scraping yourself; the parts on normalizing and diffing apply either way.</p>
</blockquote>
<h2>Setup</h2>
<pre><code class="language-bash">pip install apify-client   # tested with apify-client 3.4
export APIFY_TOKEN=...      # https://console.apify.com/settings/integrations
</code></pre>
<p>The three Actors:</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Apify Store</th>
<th>Docs &amp; examples</th>
</tr>
</thead>
<tbody><tr>
<td>Google Ads Transparency</td>
<td><a href="https://apify.com/atlasdata/google-ads-transparency-scraper-reliable">atlasdata/google-ads-transparency-scraper-reliable</a></td>
<td><a href="https://github.com/AtlasDataDenmark/google-ads-transparency-scraper">GitHub</a></td>
</tr>
<tr>
<td>LinkedIn Ad Library</td>
<td><a href="https://apify.com/atlasdata/linkedin-ad-library-scraper-reliable">atlasdata/linkedin-ad-library-scraper-reliable</a></td>
<td><a href="https://github.com/AtlasDataDenmark/linkedin-ad-library-scraper">GitHub</a></td>
</tr>
<tr>
<td>TikTok Ad Library</td>
<td><a href="https://apify.com/atlasdata/tiktok-ad-library-scraper-reliable">atlasdata/tiktok-ad-library-scraper-reliable</a></td>
<td><a href="https://github.com/AtlasDataDenmark/tiktok-ads-library-scraper">GitHub</a></td>
</tr>
</tbody></table>
<h2>1. One input per platform</h2>
<p>Each library is searched a little differently, and that affects what you get back:</p>
<ul>
<li><strong>Google</strong> works best with the advertiser's <strong>domain</strong> (<code>canva.com</code>). You can also pass an advertiser name, an advertiser ID (<code>AR...</code>) or a Transparency Center URL. <code>region</code> is an ISO country code.</li>
<li><strong>LinkedIn</strong> has an <code>advertisers</code> field that matches the company page name. <code>dateRange</code> takes values such as <code>last-30-days</code>, <code>current-month</code> or <code>custom</code>.</li>
<li><strong>TikTok</strong> <code>searchTerms</code> match both the advertiser name <strong>and the ad text</strong>. Wrapping the term in double quotes makes it an exact phrase. <code>countries</code> must be EU/EEA, CH, GB or TR, because those are the only markets TikTok publishes.</li>
</ul>
<pre><code class="language-python">BRAND_NAME = "Canva"         # used for TikTok + LinkedIn searches
BRAND_DOMAIN = "canva.com"    # used for Google Ads Transparency
MAX_ADS = 15                  # per platform, keep it small while testing

JOBS = {
    "google": (
        "atlasdata/google-ads-transparency-scraper-reliable",
        {"searchTerms": [BRAND_DOMAIN], "maxAdsPerSearch": MAX_ADS, "region": "DE"},
    ),
    "linkedin": (
        "atlasdata/linkedin-ad-library-scraper-reliable",
        {"advertisers": [BRAND_NAME], "maxAdsPerSearch": MAX_ADS, "dateRange": "last-30-days"},
    ),
    "tiktok": (
        "atlasdata/tiktok-ad-library-scraper-reliable",
        {"searchTerms": [f'"{BRAND_NAME}"'], "countries": ["DE", "FR"], "maxAdsPerSearch": MAX_ADS,
         "dateRange": "last-30-days", "includeDetails": True},
    ),
}
</code></pre>
<p>On TikTok, <code>includeDetails: True</code> opens each ad's detail page. That adds the landing page, CTA, objective, targeting and impressions per country, at the cost of a slower run.</p>
<h2>2. Run the three scrapers in parallel</h2>
<p><code>ActorClient.call()</code> starts a run and waits for it to finish. The calls mostly spend their time waiting, so a thread pool runs all three at once.</p>
<pre><code class="language-python">def run_actor(platform: str) -&gt; tuple[str, list[dict]]:
    actor_id, run_input = JOBS[platform]
    run = client.actor(actor_id).call(
        run_input=run_input,
        max_total_charge_usd=Decimal("0.50"),  # hard cost cap per run
        logger=None,                           # don't stream the run log
    )
    if run is None or run.status != "SUCCEEDED":
        # One blocked platform shouldn't sink the whole report: warn and move on.
        print(f"{platform}: run {run and run.status} - {run and run.status_message}")
        return platform, []
    # Each Actor also writes a short summary (per-search status, errors) to OUTPUT.
    summary = client.key_value_store(run.default_key_value_store_id).get_record("OUTPUT")
    if summary and summary["value"].get("status") != "SUCCEEDED":
        print(f"{platform}: {summary['value'].get('status')} - check the run's OUTPUT record")
    items = list(client.dataset(run.default_dataset_id).iterate_items())
    return platform, items
</code></pre>
<p>Two notes:</p>
<ul>
<li>In <strong>apify-client v3</strong>, <code>call()</code> returns a <code>Run</code> model, so you read <code>run.default_dataset_id</code>. The old <code>run["defaultDatasetId"]</code> from v1/v2 code no longer works.</li>
<li>Public ad libraries do block automated traffic from time to time, LinkedIn's Cloudflare in particular. A run can end <code>PARTIAL</code>, for example when a few detail pages were refused, or <code>FAILED</code> when every attempt was blocked. The <code>OUTPUT</code> record says which. For a daily report, it is better to log the problem and keep the other platforms' data than to crash.</li>
</ul>
<h2>3. Normalize to one schema</h2>
<p>Every platform has its own field names. This function maps them onto one row:</p>
<pre><code class="language-python">def normalize(platform: str, ad: dict) -&gt; dict:
    """Map each platform's fields onto one common row."""
    if platform == "google":
        return {
            "platform": "google",
            "ad_id": ad["creativeId"],
            "advertiser": ad["advertiserName"],
            "format": ad["format"],
            "text": " | ".join(filter(None, [ad.get("headline"), ad.get("description")])),
            "first_shown": (ad.get("firstShown") or "")[:10],
            "last_shown": (ad.get("lastShown") or "")[:10],
            "landing_url": ad.get("targetDomain"),
            "ad_url": ad["adUrl"],
        }
    if platform == "linkedin":
        return {
            "platform": "linkedin",
            "ad_id": ad["adId"],
            "advertiser": ad["advertiserName"],
            "format": ad.get("format"),
            "text": (ad.get("adText") or "")[:300],
            "first_shown": ad.get("firstShown") or "",  # LinkedIn often hides dates
            "last_shown": ad.get("lastShown") or "",
            "landing_url": ad.get("destinationUrl"),
            "ad_url": ad["adUrl"],
        }
    return {  # tiktok
        "platform": "tiktok",
        "ad_id": ad["adId"],
        "advertiser": ad["advertiserName"],
        "format": ad.get("mediaType"),
        "text": (ad.get("adText") or "")[:300],
        "first_shown": ad.get("firstShown"),
        "last_shown": ad.get("lastShown"),
        "landing_url": ad.get("landingPageUrl"),
        "ad_url": ad["adUrl"],
    }
</code></pre>
<p>Things you'll notice in real data:</p>
<ul>
<li><strong>Google</strong> text ads often come back with an empty <code>headline</code>/<code>description</code>. The Transparency Center stores many text ads as rendered images, and <code>imageUrl</code> points to that image. Dates (<code>firstShown</code>, <code>lastShown</code>) are reliable, and <code>regions[]</code> shows where the ad ran.</li>
<li><strong>LinkedIn</strong> shows a first/last-shown date and impression ranges only for some ads (mostly those served in the EU), so expect blanks. <code>adText</code>, <code>headline</code>, <code>ctaText</code> and <code>format</code> are almost always present.</li>
<li><strong>TikTok</strong> gives the most detail: <code>estimatedAudience</code>, <code>impressionsByCountry</code>, <code>targetAges</code>, <code>targetGenders</code> and more, if you want to add columns.</li>
</ul>
<h2>4. Keep the brand's own ads and diff against the last run</h2>
<p>A keyword search for "Canva" on TikTok also returns template creators and agencies whose ads mention Canva. A domain search on Google can return resellers that link to canva.com. If you only want the brand's own ads, filter on the advertiser name:</p>
<pre><code class="language-python">def main() -&gt; None:
    with ThreadPoolExecutor(max_workers=3) as pool:
        results = list(pool.map(run_actor, JOBS))

    rows = [normalize(platform, ad) for platform, items in results for ad in items]
    print("fetched:", {platform: len(items) for platform, items in results})

    # Keyword and domain searches can also return *other* advertisers
    # (resellers, partners, anyone mentioning the brand). Keep the brand's own ads.
    rows = [r for r in rows if BRAND_NAME.lower() in (r["advertiser"] or "").lower()]
    print("from", BRAND_NAME, "itself:", len(rows))

    # Compare with the previous snapshot to find ads we have not seen before.
    seen_file = Path("seen_ads.json")
    seen = set(json.loads(seen_file.read_text())) if seen_file.exists() else set()
    new_rows = [r for r in rows if f'{r["platform"]}:{r["ad_id"]}' not in seen]
    seen_file.write_text(json.dumps(sorted(seen | {f'{r["platform"]}:{r["ad_id"]}' for r in rows})))

    with open("competitor_ads.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["platform"])
        writer.writeheader()
        writer.writerows(rows)

    print(f"{len(rows)} ads saved, {len(new_rows)} new since last run")
    for r in new_rows[:10]:
        print(f'[{r["platform"]}] {r["first_shown"]} {r["format"]}: {r["text"][:80]!r} -&gt; {r["landing_url"]}')
</code></pre>
<p>Leave out the filter if you <em>do</em> want to see who else is bidding on a competitor's name. That is often the more interesting list.</p>
<h2>Running it</h2>
<pre><code class="language-text">$ python competitor_ads.py
fetched: {'google': 15, 'linkedin': 15, 'tiktok': 15}
from Canva itself: 39
39 ads saved, 39 new since last run
[google] 2024-01-14 TEXT: '' -&gt; canva.com
[google] 2023-10-20 TEXT: '' -&gt; canva.com
...
</code></pre>
<p>Of the 45 ads fetched, 39 came from Canva's own accounts: 15 on Google, 15 on LinkedIn and 9 on TikTok. The other 6 TikTok ads were from third parties that mention Canva. The CSV has one row per ad across all three platforms, ready for a spreadsheet or pandas.</p>
<p>Run it again tomorrow, for example from cron or a GitHub Actions schedule, and only the creatives that appeared since then are reported as new. With 15 ads per platform, a run takes about two minutes, and its cost is capped by <code>max_total_charge_usd</code>.</p>
<h2>Full script</h2>
<pre><code class="language-python">"""Collect a competitor's ads from TikTok, LinkedIn and Google into one table."""
import csv
import json
import os
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from pathlib import Path

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

BRAND_NAME = "Canva"         # used for TikTok + LinkedIn searches
BRAND_DOMAIN = "canva.com"    # used for Google Ads Transparency
MAX_ADS = 15                  # per platform, keep it small while testing

JOBS = {
    "google": (
        "atlasdata/google-ads-transparency-scraper-reliable",
        {"searchTerms": [BRAND_DOMAIN], "maxAdsPerSearch": MAX_ADS, "region": "DE"},
    ),
    "linkedin": (
        "atlasdata/linkedin-ad-library-scraper-reliable",
        {"advertisers": [BRAND_NAME], "maxAdsPerSearch": MAX_ADS, "dateRange": "last-30-days"},
    ),
    "tiktok": (
        "atlasdata/tiktok-ad-library-scraper-reliable",
        {"searchTerms": [f'"{BRAND_NAME}"'], "countries": ["DE", "FR"], "maxAdsPerSearch": MAX_ADS,
         "dateRange": "last-30-days", "includeDetails": True},
    ),
}


def run_actor(platform: str) -&gt; tuple[str, list[dict]]:
    actor_id, run_input = JOBS[platform]
    run = client.actor(actor_id).call(
        run_input=run_input,
        max_total_charge_usd=Decimal("0.50"),  # hard cost cap per run
        logger=None,                           # don't stream the run log
    )
    if run is None or run.status != "SUCCEEDED":
        # One blocked platform shouldn't sink the whole report: warn and move on.
        print(f"{platform}: run {run and run.status} - {run and run.status_message}")
        return platform, []
    # Each Actor also writes a short summary (per-search status, errors) to OUTPUT.
    summary = client.key_value_store(run.default_key_value_store_id).get_record("OUTPUT")
    if summary and summary["value"].get("status") != "SUCCEEDED":
        print(f"{platform}: {summary['value'].get('status')} - check the run's OUTPUT record")
    items = list(client.dataset(run.default_dataset_id).iterate_items())
    return platform, items


def normalize(platform: str, ad: dict) -&gt; dict:
    """Map each platform's fields onto one common row."""
    if platform == "google":
        return {
            "platform": "google",
            "ad_id": ad["creativeId"],
            "advertiser": ad["advertiserName"],
            "format": ad["format"],
            "text": " | ".join(filter(None, [ad.get("headline"), ad.get("description")])),
            "first_shown": (ad.get("firstShown") or "")[:10],
            "last_shown": (ad.get("lastShown") or "")[:10],
            "landing_url": ad.get("targetDomain"),
            "ad_url": ad["adUrl"],
        }
    if platform == "linkedin":
        return {
            "platform": "linkedin",
            "ad_id": ad["adId"],
            "advertiser": ad["advertiserName"],
            "format": ad.get("format"),
            "text": (ad.get("adText") or "")[:300],
            "first_shown": ad.get("firstShown") or "",  # LinkedIn often hides dates
            "last_shown": ad.get("lastShown") or "",
            "landing_url": ad.get("destinationUrl"),
            "ad_url": ad["adUrl"],
        }
    return {  # tiktok
        "platform": "tiktok",
        "ad_id": ad["adId"],
        "advertiser": ad["advertiserName"],
        "format": ad.get("mediaType"),
        "text": (ad.get("adText") or "")[:300],
        "first_shown": ad.get("firstShown"),
        "last_shown": ad.get("lastShown"),
        "landing_url": ad.get("landingPageUrl"),
        "ad_url": ad["adUrl"],
    }


def main() -&gt; None:
    with ThreadPoolExecutor(max_workers=3) as pool:
        results = list(pool.map(run_actor, JOBS))

    rows = [normalize(platform, ad) for platform, items in results for ad in items]
    print("fetched:", {platform: len(items) for platform, items in results})

    # Keyword and domain searches can also return *other* advertisers
    # (resellers, partners, anyone mentioning the brand). Keep the brand's own ads.
    rows = [r for r in rows if BRAND_NAME.lower() in (r["advertiser"] or "").lower()]
    print("from", BRAND_NAME, "itself:", len(rows))

    # Compare with the previous snapshot to find ads we have not seen before.
    seen_file = Path("seen_ads.json")
    seen = set(json.loads(seen_file.read_text())) if seen_file.exists() else set()
    new_rows = [r for r in rows if f'{r["platform"]}:{r["ad_id"]}' not in seen]
    seen_file.write_text(json.dumps(sorted(seen | {f'{r["platform"]}:{r["ad_id"]}' for r in rows})))

    with open("competitor_ads.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["platform"])
        writer.writeheader()
        writer.writerows(rows)

    print(f"{len(rows)} ads saved, {len(new_rows)} new since last run")
    for r in new_rows[:10]:
        print(f'[{r["platform"]}] {r["first_shown"]} {r["format"]}: {r["text"][:80]!r} -&gt; {r["landing_url"]}')


if __name__ == "__main__":
    main()
</code></pre>
<h2>Where to go next</h2>
<ul>
<li>Add <code>impressionsByCountry</code> (LinkedIn/TikTok) or <code>regions</code> (Google) as columns to see where spend is concentrated.</li>
<li>Download <code>videoUrl</code> and <code>imageUrls</code> for a swipe file. Some media URLs are signed and expire, so download them soon after the run.</li>
<li>Each repo has Node.js and curl versions of the same calls under <code>examples/</code>: <a href="https://github.com/AtlasDataDenmark/google-ads-transparency-scraper">Google</a>, <a href="https://github.com/AtlasDataDenmark/linkedin-ad-library-scraper">LinkedIn</a>, <a href="https://github.com/AtlasDataDenmark/tiktok-ads-library-scraper">TikTok</a>.</li>
<li>If you'd rather ask an AI assistant ("what has Canva been advertising on LinkedIn this month?"), the same tools can be used over MCP. The setup for Claude Desktop and Cursor is in <a href="https://github.com/AtlasDataDenmark/atlas-data-mcp">atlas-data-mcp</a>.</li>
</ul>
]]></content:encoded></item></channel></rss>