<?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[TubeMind]]></title><description><![CDATA[TubeMind]]></description><link>https://tubemind.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 22:11:47 GMT</lastBuildDate><atom:link href="https://tubemind.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Build a Quota-Aware YouTube Search Rank Tracker in Python]]></title><description><![CDATA[Track where a video ranks for a keyword, per country and language, on the free 10,000-unit API quota]]></description><link>https://tubemind.hashnode.dev/build-a-quota-aware-youtube-search-rank-tracker-in-python</link><guid isPermaLink="true">https://tubemind.hashnode.dev/build-a-quota-aware-youtube-search-rank-tracker-in-python</guid><category><![CDATA[Python]]></category><category><![CDATA[youtube]]></category><category><![CDATA[APIs]]></category><category><![CDATA[data analysis]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[Muhammad Ismail]]></dc:creator><pubDate>Fri, 30 Oct 2026 09:18:27 GMT</pubDate><content:encoded><![CDATA[<p>Ask a creator where their video ranks for a keyword and they will usually type it into the YouTube app on their phone and scroll. The answer they get is real, but it is their answer: YouTube personalises search by watch history, location and language, so the same query on a colleague's laptop in another city returns a different order.</p>
<p>If you want a number you can log every week and compare, you need a check that is repeatable. The YouTube Data API gives you one, with two catches that most tutorials skip: search is the most expensive call in the whole API, and the results are tied to a market. This post builds a small rank tracker that respects both. About 90 lines of Python, <code>requests</code> plus the standard library's <code>sqlite3</code>.</p>
<h2>The quota math comes first</h2>
<p>Every Google Cloud project gets a default of <strong>10,000 quota units per day</strong> for the YouTube Data API, and the counter resets at midnight Pacific Time. Calls are not priced equally:</p>
<table>
<thead>
<tr>
<th>Call</th>
<th>Cost</th>
<th>What we use it for</th>
</tr>
</thead>
<tbody><tr>
<td><code>search.list</code></td>
<td>100 units per request</td>
<td>Getting the ranked results for a keyword</td>
</tr>
<tr>
<td><code>channels.list</code></td>
<td>1 unit</td>
<td>Resolving an @handle to a channel ID</td>
</tr>
<tr>
<td><code>videos.list</code></td>
<td>1 unit</td>
<td>Optional: pulling view counts for the results</td>
</tr>
</tbody></table>
<p>So one search page costs as much as one hundred channel lookups. With the default quota you get <strong>100 search requests a day</strong>. That sounds like plenty until you multiply it out:</p>
<ul>
<li>10 keywords</li>
<li>3 markets (for example US English, Pakistan Urdu, India Hindi)</li>
<li>2 pages deep (top 100) instead of 1 (top 50)</li>
</ul>
<p>That is 60 requests, or 6,000 units, for a single run. Run it daily and you have almost nothing left for anything else. The design decision that follows: <strong>track the top 50 only, and run weekly</strong>. Ranking positions don't move enough day to day to justify the cost, and a video sitting at position 70 needs a different keyword, not a daily log of how stuck it is.</p>
<h2>What "rank" means in this tracker</h2>
<p>Before writing code, pin down the definition, because it decides the parameters:</p>
<ul>
<li><strong>Query:</strong> the exact keyword, sent as <code>q</code>.</li>
<li><strong>Type:</strong> videos only (<code>type=video</code>). The real search page mixes in Shorts shelves, channels and playlists, which push results down the screen without changing the order among videos. We only care about the order among videos.</li>
<li><strong>Market:</strong> <code>regionCode</code> (a two-letter country code like <code>PK</code>) plus <code>relevanceLanguage</code> (a two-letter language code like <code>ur</code>). Leave either out and you are measuring a different market.</li>
<li><strong>Order:</strong> relevance, which is the API default.</li>
<li><strong>Depth:</strong> <code>maxResults=50</code>, the maximum per page.</li>
</ul>
<p>A rank is then "position of my video in the un-personalised relevance order for this keyword, this country and this language, within the top 50". Anything stated more precisely than that is a guess.</p>
<h2>Step 1: Setup</h2>
<p>Create a project in the Google Cloud console, enable <strong>YouTube Data API v3</strong>, and create an API key. Then:</p>
<pre><code class="language-bash">pip install requests
export YT_API_KEY="your-key-here"
</code></pre>
<h2>Step 2: Resolve the channel once</h2>
<p>We match results by channel ID, so the tracker catches any of your videos ranking for a term, not only the one you expected. Handles are what people know, so resolve the handle once and cache it. <code>channels.list</code> accepts a <code>forHandle</code> parameter and costs 1 unit.</p>
<pre><code class="language-python">import os, sqlite3, datetime, requests

API = "https://www.googleapis.com/youtube/v3"
KEY = os.environ["YT_API_KEY"]

def channel_id_for(handle: str) -&gt; str:
    r = requests.get(f"{API}/channels", params={
        "part": "id",
        "forHandle": handle.lstrip("@"),
        "key": KEY,
    }, timeout=20)
    r.raise_for_status()
    items = r.json().get("items", [])
    if not items:
        raise ValueError(f"No channel found for {handle}")
    return items[0]["id"]
</code></pre>
<h2>Step 3: One search, one market</h2>
<p>The <code>fields</code> parameter trims the response to what we need. It does not reduce the quota cost (that stays at 100), but it makes responses smaller and easier to store.</p>
<pre><code class="language-python">def search_top50(keyword: str, region: str, lang: str) -&gt; list[dict]:
    r = requests.get(f"{API}/search", params={
        "part": "snippet",
        "q": keyword,
        "type": "video",
        "regionCode": region,
        "relevanceLanguage": lang,
        "maxResults": 50,
        "fields": "items(id/videoId,snippet/channelId,snippet/title)",
        "key": KEY,
    }, timeout=20)
    r.raise_for_status()
    return [
        {
            "position": i + 1,
            "video_id": it["id"]["videoId"],
            "channel_id": it["snippet"]["channelId"],
            "title": it["snippet"]["title"],
        }
        for i, it in enumerate(r.json().get("items", []))
    ]
</code></pre>
<h2>Step 4: A budget guard</h2>
<p>The worst failure mode for a tracker is silently burning the whole day's quota halfway through, then failing every other script on the same project. A tiny guard fixes that: estimate the cost before running and refuse if it is over budget.</p>
<pre><code class="language-python">SEARCH_COST = 100

def plan_cost(keywords, markets, pages=1) -&gt; int:
    return len(keywords) * len(markets) * pages * SEARCH_COST + 1

def check_budget(cost: int, budget: int = 5000):
    if cost &gt; budget:
        raise SystemExit(
            f"Planned run costs {cost} units, budget is {budget}. "
            "Cut keywords or markets."
        )
</code></pre>
<p>The budget defaults to half the daily quota so other jobs on the project still have room.</p>
<h2>Step 5: Store every observation</h2>
<p>A rank tracker is just a table of observations over time. SQLite is enough.</p>
<pre><code class="language-python">def db():
    con = sqlite3.connect("ranks.db")
    con.execute("""
        CREATE TABLE IF NOT EXISTS ranks (
            checked_on TEXT,
            keyword    TEXT,
            region     TEXT,
            lang       TEXT,
            position   INTEGER,   -- NULL means not in top 50
            video_id   TEXT,
            title      TEXT
        )""")
    return con
</code></pre>
<p>Storing <code>NULL</code> for "not found" matters. If you skip the row instead, you cannot tell the difference between "dropped out" and "never checked".</p>
<h2>Step 6: The run</h2>
<pre><code class="language-python">KEYWORDS = ["youtube rpm pakistan", "faceless channel ideas", "outlier score"]
MARKETS = [("US", "en"), ("PK", "ur"), ("IN", "hi")]
HANDLE = "@yourchannel"

def run():
    cost = plan_cost(KEYWORDS, MARKETS)
    check_budget(cost)
    me = channel_id_for(HANDLE)
    con = db()
    today = datetime.date.today().isoformat()

    for kw in KEYWORDS:
        for region, lang in MARKETS:
            results = search_top50(kw, region, lang)
            mine = [x for x in results if x["channel_id"] == me]
            if mine:
                best = mine[0]
                con.execute("INSERT INTO ranks VALUES (?,?,?,?,?,?,?)",
                    (today, kw, region, lang, best["position"],
                     best["video_id"], best["title"]))
                print(f"{kw:28} {region}/{lang}  #{best['position']:&lt;3} {best['title'][:40]}")
            else:
                con.execute("INSERT INTO ranks VALUES (?,?,?,?,NULL,NULL,NULL)",
                    (today, kw, region, lang))
                print(f"{kw:28} {region}/{lang}  &gt;50")
    con.commit()
    print(f"Used about {cost} units.")

if __name__ == "__main__":
    run()
</code></pre>
<p>Three keywords across three markets costs 901 units. Run it every Monday from cron:</p>
<pre><code class="language-bash">0 6 * * 1 cd /home/me/rank-tracker &amp;&amp; /usr/bin/python3 tracker.py &gt;&gt; run.log 2&gt;&amp;1
</code></pre>
<h2>Step 7: Ask the data a useful question</h2>
<p>Raw positions are less interesting than movement. This query compares each keyword and market with its previous check:</p>
<pre><code class="language-sql">SELECT keyword, region, lang, checked_on, position,
       LAG(position) OVER (
         PARTITION BY keyword, region, lang ORDER BY checked_on
       ) AS previous
FROM ranks
ORDER BY keyword, region, checked_on DESC;
</code></pre>
<p>Window functions need SQLite 3.25 or later, which almost every current Python install already includes. Positive <code>previous - position</code> means the video moved up.</p>
<h2>Mistakes worth avoiding</h2>
<p><strong>Checking one market and assuming it is global.</strong> Leave out <code>relevanceLanguage</code> and the API picks a language for you. A Urdu-language video can rank well for a Roman Urdu query in Pakistan and not at all for the same query with <code>hi</code> in India. Both results are true, which is exactly why the market belongs in every row you store.</p>
<p><strong>Paging deeper to "find" the video.</strong> It is tempting to request page 2 and page 3 with <code>pageToken</code> when a video is missing. Each page is another 100 units, and a video at position 120 is not going to be helped by knowing that precisely. Treat "not in top 50" as the signal: the keyword is too broad, and a narrower phrase is the fix.</p>
<p><strong>Comparing tracker numbers with the phone.</strong> They will disagree. The tracker shows the un-personalised relevance order for one market; the phone shows one viewer's personalised feed. Neither is wrong. They answer different questions, and only the tracker's answer is repeatable next week.</p>
<p><strong>Matching by video ID only.</strong> Channels often have two or three videos on the same topic. Matching on channel ID shows you when an older upload is outranking the new one you are trying to push, and that changes what you should optimise.</p>
<h2>Where the numbers come from on the creator side</h2>
<p>The API tells you where you rank. It does not tell you which keywords are worth tracking. For that, YouTube Studio already has the best list: Analytics, then Reach, then the "Traffic source: YouTube Search" card shows the exact terms that sent you viewers. Feed those into <code>KEYWORDS</code> instead of guessing.</p>
<p>If you want the full picture of the three ways to check a ranking (Studio's search terms report, a signed-out manual search, and an API based check) and why they never agree, this walkthrough on <a href="https://tubemind.io/blog/how-to-check-youtube-video-ranking">how to check where your YouTube video ranks</a> compares them side by side, including a weekly routine and the ranking factors YouTube itself lists.</p>
<h2>Ideas to extend it</h2>
<ul>
<li><strong>Add context, cheaply.</strong> One <code>videos.list</code> call with up to 50 IDs costs 1 unit and returns view counts and publish dates for every result. That tells you whether the videos above you are fresh or years old, which changes how beatable they are.</li>
<li><strong>Track competitors.</strong> Store all 50 results, not just yours, and you can see which channels own a keyword over time.</li>
<li><strong>Alert on drops.</strong> Wrap the SQL above in a check that posts to a webhook when a keyword falls more than 10 positions in one week.</li>
</ul>
<p>The whole thing fits in one file, costs nothing to run, and replaces the scroll-and-guess habit with a log you can actually reason about.</p>
<p><em>Disclosure: I work on TubeMind, a YouTube analytics and research tool. The code in this post is standalone and does not need it.</em></p>
]]></content:encoded></item></channel></rss>