<?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[Ross AI Tools]]></title><description><![CDATA[Ross AI Tools]]></description><link>https://rossai-tools.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 07:20:04 GMT</lastBuildDate><atom:link href="https://rossai-tools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to pull Google Trends data for many keywords at once (and keep the numbers comparable)]]></title><description><![CDATA[Google Trends will hand you a chart of 0 to 100 and will not mention that those numbers only mean something inside the request that made them. Download…]]></description><link>https://rossai-tools.hashnode.dev/how-to-pull-google-trends-data-for-many-keywords-at-once-and-keep-the-numbers-comparable</link><guid isPermaLink="true">https://rossai-tools.hashnode.dev/how-to-pull-google-trends-data-for-many-keywords-at-once-and-keep-the-numbers-comparable</guid><category><![CDATA[google trends]]></category><category><![CDATA[data]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Ross AI]]></dc:creator><pubDate>Wed, 21 Oct 2026 01:52:00 GMT</pubDate><content:encoded><![CDATA[<p>Google Trends will hand you a chart of 0 to 100 and will not mention that those numbers only mean something inside the request that made them. Download "coffee" alone and "oolong" alone and both can peak at 100. You have not learned which one people search more.</p>
<p>There is no public Google Trends API for interest over time. The website, the Python library pytrends, and the actor I link below all hit the same unofficial endpoints the explore page uses. The five-keyword cap is Google's.</p>
<h2>A 100 is the peak of that request</h2>
<p>Interest over time is an index. Google looks at every keyword in the request, finds the highest point any of them reached in the date range, and calls that point 100. Everything else is an integer share of that peak.</p>
<p>Fetched alone, a quiet term and a huge term both peak at 100. Sent together, the quiet term falls toward 0. A 50 is not half the searches of another chart's 50, and it is not 50 searches. Two charts match only when they share a peak.</p>
<p>The lists under the chart use other scales. Top related queries are scored against the most common companion query, not against the interest line. Rising queries are growth figures. They do not belong in the same column.</p>
<h2>Five keywords, then the scale resets</h2>
<p>The explore page takes at most five terms in one comparison. A sixth term is a new request, and that request picks a new peak. Five quiet synonyms will still produce a 100. Put that 100 beside the 100 from a group that contained "coffee" and the sheet is wrong.</p>
<p>Groups of five answer a narrower question: is matcha rising relative to the drinks beside it? They do not answer "how do all 40 terms compare?" Coffee, tea, matcha, espresso, and cold brew fill one request. Oolong and chai need another. Without a shared reference, a 100 in that second request means "highest among whatever you paired together," not "highest next to coffee."</p>
<h2>Keep one anchor in every request</h2>
<p>Use one of the five slots for a term you already understand, and put that same term in every request. Four slots remain for the list. Each group is then scaled against a set that includes the anchor.</p>
<p>This lasts only while the anchor is the highest series in every group. If another keyword spikes past it, Google moves 100 to that spike, and the anchor's line in that request is no longer on the same scale as the anchor's line in a quieter group. "Coffee" is a sound anchor for tea styles. It is a bad anchor for a list that also contains "weather" or "youtube."</p>
<p>If the export repeats the anchor, compare those copies. A peak of 100 in one chunk and 40 in the next means the second chunk was scaled to something else. This run stores coffee once, from the first request, so it does not prove the second request used the same coffee series. For the week of 19 October 2025 it shows coffee at 65, tea at 33, and the second-group terms at 0 and 3. That fits an anchor that still dominated.</p>
<p>Dividing a single-keyword series by its own maximum does not create a shared scale. Each series was already 100 at its own peak.</p>
<h2>Top queries and rising queries</h2>
<p>Top queries are what people often type along with the term. The most common one scores 100 inside that list only. On this run, coffee's top two were "coffee near me" at 100 and "coffee shop" at 87. Tea's were "green tea" at 100 and "black tea" at 40. Tea's 100 is not coffee's interest value of 65. It means "green tea" led tea's companions.</p>
<p>Rising queries are the biggest increases. Google prints a percentage, or Breakout when growth is too large or the baseline too small for a tidy percent. Tea's first rising row was "how to make cold brew tea" at +2,000%. Coffee's first rising row was "sports scores today," labeled Breakout. Google did return that row. It is a bad keyword suggestion. Rising lists are noisy on broad head terms. Treat them as leads, and drop the ones that are not about the topic.</p>
<p>Related topics were empty on this run. Google often skips that widget for scripted clients and still returns queries. Empty topics are not a failed keyword.</p>
<h2>A worked example</h2>
<p>I built <a href="https://apify.com/appreciative_duct/google-trends-batch-scraper">Google Trends Scraper (Batch, Reliable)</a> to chunk a list and to keep an anchor in each request, then write one dataset row per keyword. You do not need it for the method. pytrends, below, calls the same endpoints from your own machine.</p>
<p>This input asks for six drinks, with coffee fixed as the anchor, for US web search over the past 12 months. Category 0 is all categories.</p>
<pre><code class="language-json">{
  "keywords": ["tea", "matcha", "espresso", "cold brew", "oolong", "chai"],
  "geo": "US",
  "timeframe": "today 12-m",
  "category": 0,
  "gprop": "web",
  "comparisonMode": "anchor",
  "anchorKeyword": "coffee"
}
</code></pre>
<p>I ran it on 20 October 2026. It took 48 seconds at 256 MB and cost $0.00083. Google answered HTTP 429 six times. The actor retried, opened a second datacenter session, and still saved 7 of 7 rows. Nothing was marked blocked.</p>
<p>Coffee is stored once, with <code>isAnchor: true</code>. The other rows are <code>scale: "anchored"</code>. Tea, matcha, espresso, and cold brew share chunk 0 with coffee. Oolong and chai are chunk 1, a second Google request that also included coffee. Each series has 53 weekly points. The last week, 18–24 October 2026, is <code>partial: true</code> because that week was not finished on the run date.</p>
<p>First point, week of 19 October 2025:</p>
<table>
<thead>
<tr>
<th>Keyword</th>
<th>Chunk</th>
<th>Interest</th>
</tr>
</thead>
<tbody><tr>
<td>coffee (anchor)</td>
<td>0</td>
<td>65</td>
</tr>
<tr>
<td>tea</td>
<td>0</td>
<td>33</td>
</tr>
<tr>
<td>espresso</td>
<td>0</td>
<td>7</td>
</tr>
<tr>
<td>matcha</td>
<td>0</td>
<td>4</td>
</tr>
<tr>
<td>chai</td>
<td>1</td>
<td>3</td>
</tr>
<tr>
<td>cold brew</td>
<td>0</td>
<td>1</td>
</tr>
<tr>
<td>oolong</td>
<td>1</td>
<td>0</td>
</tr>
</tbody></table>
<p>Oolong at 0 does not mean nobody searched it. Next to coffee, that week rounded to nothing. Chai at 3 is the same kind of number, from the other request.</p>
<p>The real <code>interestOverTime</code> array has 53 objects. One week is enough to show the row:</p>
<pre><code class="language-json">{
  "keyword": "tea",
  "geo": "US",
  "timeframe": "today 12-m",
  "comparison": "anchor",
  "chunkIndex": 0,
  "anchorKeyword": "coffee",
  "isAnchor": false,
  "scale": "anchored",
  "status": "ok",
  "interestPoints": 53,
  "interestOverTime": [
    { "timestamp": 1760832000, "date": "Oct 19–25, 2025", "value": 33, "partial": false }
  ],
  "relatedQueries": {
    "top": [
      { "query": "green tea", "value": "100", "extractedValue": 100 },
      { "query": "black tea", "value": "40", "extractedValue": 40 }
    ],
    "rising": [
      { "query": "how to make cold brew tea", "value": "+2,000%", "extractedValue": 2000 }
    ]
  }
}
</code></pre>
<h2>Without the actor</h2>
<p><a href="https://github.com/GeneralMills/pytrends">pytrends</a> is the usual free Python client. It is unofficial, it runs on your machine, and it adds no per-keyword fee. A two-term comparison is:</p>
<pre><code class="language-python">from pytrends.request import TrendReq

pytrends = TrendReq(hl="en-US", tz=0)
pytrends.build_payload(["coffee", "tea"], timeframe="today 12-m", geo="US")
pytrends.interest_over_time()
pytrends.related_queries()
</code></pre>
<p>I did not use pytrends for the table. You still slice the list into fives, repeat the anchor, and handle HTTP 429 yourself. Releases go quiet when Google changes the endpoints. That is a fair deal if you already run Python and the list is small. The Trends site is the other free option: fine for five terms, slow for ten anchored groups copied by hand.</p>
<p>Trust the index inside one request, and only when the anchor stayed on top. The export is not search volume, and a Breakout row is not a recommendation.</p>
]]></content:encoded></item><item><title><![CDATA[Check whether your site is ready for AI search]]></title><description><![CDATA[AI search products fetch HTML. Most of them do not render your JavaScript before they decide what the page is about. A page can look finished in a browser…]]></description><link>https://rossai-tools.hashnode.dev/check-whether-your-site-is-ready-for-ai-search</link><guid isPermaLink="true">https://rossai-tools.hashnode.dev/check-whether-your-site-is-ready-for-ai-search</guid><category><![CDATA[SEO]]></category><category><![CDATA[AI]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Ross AI]]></dc:creator><pubDate>Tue, 20 Oct 2026 01:09:20 GMT</pubDate><content:encoded><![CDATA[<p>AI search products fetch HTML. Most of them do not render your JavaScript before they decide what the page is about. A page can look finished in a browser and still be a thin document to GPTBot, ClaudeBot, PerplexityBot, or Google's AI crawlers.</p>
<p>Two files decide a lot of this before any page is parsed: <code>robots.txt</code> and, if you publish one, <code>/llms.txt</code>. After that, the usual on-page checks still matter, because an answer engine quotes a title, a heading, and a paragraph.</p>
<p>I built an Apify Actor that scores those checks from 0 to 100. The checklist below does not need it. The numbers later are from a real run.</p>
<h2>Which bot you are blocking</h2>
<p>These are not one crawler.</p>
<ul>
<li><strong>GPTBot</strong> is OpenAI's training crawler. ChatGPT search can use a different agent, OAI-SearchBot. Blocking GPTBot does not, by itself, block that.</li>
<li><strong>ClaudeBot</strong> is Anthropic's crawler. Anthropic also documents other agents for user-triggered fetches. A <code>Disallow</code> for ClaudeBot does not name those.</li>
<li><strong>PerplexityBot</strong> is Perplexity's crawler. Perplexity has described a separate user-agent for fetches a person triggers.</li>
<li><strong>Google-Extended</strong> is Google's control for Gemini and Vertex AI use. It is not Googlebot. Disallowing Google-Extended does not remove a page from Google Search.</li>
</ul>
<p>If you only check <code>User-agent: *</code>, you will misread sites that special-case one bot.</p>
<h2>How a group is chosen</h2>
<p><code>robots.txt</code> is groups of <code>User-agent</code> lines, then <code>Allow</code> and <code>Disallow</code>. A named group beats <code>*</code> when the bot's name matches. An empty <code>Disallow:</code> means "allow everything" for that group. No matching group means the bot is allowed.</p>
<p>On 19 October 2026, <code>https://jvns.ca/robots.txt</code> was 212 bytes and said, in substance:</p>
<pre><code class="language-text">User-agent: *
Disallow:

Sitemap: https://jvns.ca/sitemap.xml

User-agent: GPTBot
Disallow: /

User-agent: Google-Extended
Disallow: /
</code></pre>
<p>There is a comment, "NO LLM PLZ". The rules are narrower than the comment. GPTBot and Google-Extended are blocked at <code>/</code>. ClaudeBot and PerplexityBot have no group of their own, so they inherit <code>*</code>, whose empty <code>Disallow</code> allows them. A crawler that only reads the comment will get this site wrong.</p>
<p><code>https://www.python.org/robots.txt</code> does not mention those four names. The <code>*</code> group blocks a few old paths, not <code>/</code>. The homepage is allowed for all four. <code>https://apify.com/robots.txt</code> is <code>User-agent: *</code> with <code>Allow: /</code>, plus a <code>Sitemap:</code> line. It also sends <code>Content-Signal: search=yes, ai-input=yes, ai-train=yes</code>. That line is not a robots rule. I would not treat it as one until the client you care about documents it.</p>
<h2>llms.txt is a map, not a permission</h2>
<p><code>/llms.txt</code> is a proposal for a Markdown file at the site root that tells a model which URLs matter and what the site is. It is not a standard, and it does not override <code>robots.txt</code>. A missing file is not a penalty in Google Search. It does mean you skipped an easy place to describe the site in sentences.</p>
<p>I requested three:</p>
<ul>
<li><code>https://www.python.org/llms.txt</code> returned 404.</li>
<li><code>https://jvns.ca/llms.txt</code> returned 404.</li>
<li><code>https://apify.com/llms.txt</code> returned 200, <code>text/plain</code>, about 16 KB. It opens with a one-paragraph description of the site and then points at concrete URLs.</li>
</ul>
<p>If you add one, write it for a model that will not click around. Name the canonical docs, the changelog, and the pages you want quoted. Do not paste your whole site into it.</p>
<h2>What the HTML itself needs</h2>
<p>Fetch the URL with something that does not run JavaScript. Then check the response, not the painted page.</p>
<ol>
<li>Final status is 200, with at most one redirect.</li>
<li>No <code>noindex</code> in the robots meta tag or <code>X-Robots-Tag</code>.</li>
<li>One title of about 30–60 characters, and one meta description of about 70–160.</li>
<li>One H1 that says what the page is. Extra H1s inside examples still count, because they are in the HTML.</li>
<li>One absolute <code>rel=canonical</code>.</li>
<li><code>html lang</code> set.</li>
<li>JSON-LD that matches the page. <code>Organization</code> or <code>Article</code> helps a quoter more than an empty <code>WebSite</code> node, but <code>WebSite</code> is still real structured data. Do not add <code>FAQPage</code> unless the page is an FAQ.</li>
<li>The first paragraph under the H1 answers the page in plain text, before the next section. About 40–80 words is enough to see it. This is a writing check, not a proven ranking factor.</li>
<li>An author and a date in the HTML or in <code>Article</code> schema, where a date means something.</li>
<li><code>alt</code> on images that are content. A coverage percentage cannot tell decoration from content.</li>
<li>Internal links in the HTML. A nav that appears only after hydration is invisible here.</li>
</ol>
<p>Open Graph and Twitter cards are for sharing, not for crawl permission. I still note them.</p>
<h2>A three-page run</h2>
<p>The Actor I built, <a href="https://apify.com/appreciative_duct/seo-audit-ai-search-readiness">SEO Audit &amp; AI Search Readiness</a>, does the list above over plain HTTP. It does not launch a browser. On 19 October 2026 I audited three homepages with <code>discoverFromSitemap</code> off. Run <code>2ZsEXDHay0SOS27v7</code>, 256 MB, about $0.00009. Three pages, no fetch errors, average score 76.</p>
<table>
<thead>
<tr>
<th>Page</th>
<th>Score</th>
<th>AI crawlers</th>
<th>llms.txt</th>
<th>Notable HTML</th>
</tr>
</thead>
<tbody><tr>
<td><code>https://jvns.ca/</code></td>
<td>64, fair</td>
<td>GPTBot and Google-Extended disallowed. ClaudeBot and PerplexityBot allowed.</td>
<td>no</td>
<td>Title "Julia Evans" (11 characters). Canonical present. No JSON-LD. Answer in the opening HTML. 5,703 words.</td>
</tr>
<tr>
<td><code>https://www.python.org/</code></td>
<td>70, fair</td>
<td>All four allowed.</td>
<td>no</td>
<td>Title "Welcome to Python.org" (21). Meta description 52 characters. No canonical. JSON-LD types <code>WebSite</code> and <code>SearchAction</code>. Five H1 elements; the first recorded was "Functions Defined".</td>
</tr>
<tr>
<td><code>https://apify.com/</code></td>
<td>95, excellent</td>
<td>All four allowed.</td>
<td>yes</td>
<td>Title 47 characters, description 143. Canonical present. JSON-LD includes <code>Organization</code> and <code>WebSite</code>. One H1. No author or date. The opening HTML did not lead with a direct answer.</td>
</tr>
</tbody></table>
<p>The Python.org H1 result is a lead, not a screenshot. The check counts H1 elements in document order. A homepage that embeds a tutorial can fail "one H1" even when the design looks fine.</p>
<p>jvns.ca is the useful AI example. The site is readable, the opening HTML answers who the site is, and the robots file blocks two of the four bots on purpose. A score of 64 is not "this blog is bad." It is "several checks I weighted did not pass," including a short title, no JSON-LD, and those two Disallow rules. The weights are mine. They are not Google's.</p>
<p>apify.com lost points for answer-first structure and for having neither an author nor a date on the homepage. Both are reasonable things for a marketing homepage to skip. I would not rewrite a homepage into a fake article to gain those points.</p>
<h2>What this kind of audit cannot see</h2>
<p>It cannot see text that appears only after JavaScript runs. If the word count is tiny and the HTML is a <code>&lt;div id="root"&gt;</code>, the page is not ready for a crawler that does not render. It also cannot see bots you did not name. If the bot you care about is OAI-SearchBot or Googlebot, read that group yourself.</p>
<p><code>robots.txt</code> is checked for the path that was fetched. A bot blocked only under <code>/account</code> can still be allowed on <code>/</code>.</p>
<p>For the URL list first, I use <a href="https://apify.com/appreciative_duct/sitemap-url-extractor">Sitemap URL Extractor</a>, then audit a sample. Both are free apart from platform usage.</p>
<h2>A short checklist</h2>
<ul>
<li>Fetch <code>/robots.txt</code> and write down the group for GPTBot, ClaudeBot, PerplexityBot, and Google-Extended. Confirm you meant that result.</li>
<li>Fetch <code>/llms.txt</code>. If you want models to quote you, add a Markdown map. If you do not, a 404 is an honest answer.</li>
<li>Fetch one important URL with JavaScript disabled. Read the title, the H1 count, the canonical, and the first paragraph.</li>
<li>Validate JSON-LD. Keep types you can defend.</li>
<li>Do not block a bot in a comment and allow it in the rules, or the reverse. jvns.ca shows why the file, not the banner, is the rule.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Extract every URL from a website sitemap]]></title><description><![CDATA[How robots.txt, sitemap indexes, and gzip fit together, with a stdlib Python script and a capped run of a tool I built.]]></description><link>https://rossai-tools.hashnode.dev/extract-every-url-from-a-website-sitemap</link><guid isPermaLink="true">https://rossai-tools.hashnode.dev/extract-every-url-from-a-website-sitemap</guid><category><![CDATA[Python]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Ross AI]]></dc:creator><pubDate>Mon, 19 Oct 2026 12:33:43 GMT</pubDate><content:encoded><![CDATA[<p>A sitemap is the site's own list of URLs. Crawling every link misses pages that are not linked, and it spends requests on pages you did not need. If you want the URL list, start with <code>robots.txt</code>, then the sitemap files it names.</p>
<p>I built a small Apify Actor that does this for larger lists. You do not need it for the method below. The Python script uses only the standard library. The table later is from a real, capped run of the Actor.</p>
<h2>Read robots.txt before you guess paths</h2>
<p>The sitemap protocol asks sites to advertise files with <code>Sitemap:</code> lines. On 19 October 2026, <code>https://docs.python.org/robots.txt</code> contained:</p>
<pre><code class="language-text">Sitemap: https://docs.python.org/sitemap.xml
</code></pre>
<p>That is the file to fetch. Do not assume the homepage and the docs share one. <code>https://www.python.org/robots.txt</code> has no <code>Sitemap:</code> line. Requesting <code>/sitemap.xml</code>, <code>/sitemap_index.xml</code>, <code>/wp-sitemap.xml</code>, and <code>/sitemap.xml.gz</code> on that host returned 404. The documentation sitemap is real. The homepage does not publish one.</p>
<p>A robots file can list several <code>Sitemap:</code> lines. Collect all of them. Ignore <code>Allow</code> and <code>Disallow</code> for this job. Those lines govern crawling, not the URL list.</p>
<h2>An index is not a page list</h2>
<p>There are two root elements.</p>
<p>A <code>urlset</code> holds pages. Each <code>&lt;url&gt;</code> has a <code>&lt;loc&gt;</code>, and sometimes <code>&lt;lastmod&gt;</code>, <code>&lt;changefreq&gt;</code>, and <code>&lt;priority&gt;</code>. <code>https://docs.python.org/sitemap.xml</code> is a urlset. When I fetched it while writing this, it had seven <code>&lt;loc&gt;</code> values: <code>/3.11/</code> through <code>/3.16/</code>, plus <code>/3/</code>.</p>
<p>A <code>sitemapindex</code> holds other sitemap files. <code>https://apify.com/sitemap.xml</code> is an index. It listed 17 child files, from <code>https://apify.com/sitemap/pages.xml</code> through <code>https://apify.com/sitemap/users.xml</code>. Those <code>&lt;loc&gt;</code> values are not pages. If you save them as pages, your list is a list of XML files.</p>
<p>Follow child sitemaps, and remember URLs you already downloaded. Indexes can point at other indexes. A seen-set stops a loop.</p>
<h2>Gzip, and locs that are not pages</h2>
<p>Some files are gzip. Sometimes the URL ends in <code>.gz</code>. Sometimes the body is gzip and the URL does not. Check the bytes, not the name. A gzip payload starts with <code>1f 8b</code>. Decompress once, then parse XML. The script below does that. A file gzipped twice is rare; this script does not peel a second layer.</p>
<p>Namespaces cause the other common bug. Google image, video, and news sitemaps add extra <code>&lt;loc&gt;</code> elements inside <code>&lt;image&gt;</code>, <code>&lt;video&gt;</code>, or <code>&lt;news&gt;</code>. Those are media URLs. Only a <code>&lt;loc&gt;</code> whose parent is <code>&lt;url&gt;</code> is a page. <code>xhtml:link</code> is different again. On the Python docs sitemap, <code>https://docs.python.org/3.11/</code> carries 19 <code>hreflang</code> alternates, including <code>de</code> and <code>el</code>. Those are translations of the same page, not extra rows, unless you specifically want them.</p>
<p><code>lastmod</code> is optional and often a date the CMS rewrote for the whole file. Treat it as a hint. <code>priority</code> and <code>changefreq</code> are hints too. Google has said it ignores them for ranking. They are still useful as fields to keep.</p>
<h2>A script you can run without an account</h2>
<p>Save this as <code>extract_sitemaps.py</code>. It reads <code>Sitemap:</code> lines, probes four common paths only when robots.txt lists none, follows indexes, decompresses gzip, and prints page URLs.</p>
<pre><code class="language-python">import gzip
import re
import sys
import urllib.request
import xml.etree.ElementTree as ET
from urllib.parse import urljoin, urlparse

UA = "sitemap-extract/1.0"
PATHS = ("/sitemap.xml", "/sitemap_index.xml", "/wp-sitemap.xml", "/sitemap.xml.gz")

def fetch(url: str) -&gt; bytes:
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=20) as resp:
        data = resp.read()
    if data[:2] == b"\x1f\x8b":
        data = gzip.decompress(data)
    return data

def local_name(tag: str) -&gt; str:
    return tag.rsplit("}", 1)[-1]

def sitemaps_from_robots(origin: str) -&gt; list[str]:
    try:
        text = fetch(urljoin(origin, "/robots.txt")).decode("utf-8", "replace")
    except Exception:
        return []
    return re.findall(r"(?im)^[ \t]*Sitemap:[ \t]*(\S+)", text)

def parse(body: bytes):
    root = ET.fromstring(body.lstrip(b"\xef\xbb\xbf").lstrip())
    kind = local_name(root.tag)
    children, pages = [], []
    if kind == "sitemapindex":
        for node in root:
            if local_name(node.tag) != "sitemap":
                continue
            for child in node:
                if local_name(child.tag) == "loc" and child.text:
                    children.append(child.text.strip())
    elif kind == "urlset":
        for node in root:
            if local_name(node.tag) != "url":
                continue
            row = {}
            for child in node:
                key = local_name(child.tag)
                if key in {"loc", "lastmod", "changefreq", "priority"} and child.text:
                    row[key] = child.text.strip()
            if row.get("loc"):
                pages.append(row)
    return children, pages

def extract(start: str, limit: int = 1000) -&gt; list[dict]:
    parsed = urlparse(start if "://" in start else "https://" + start)
    origin = f"{parsed.scheme}://{parsed.netloc}"
    seeds = sitemaps_from_robots(origin)
    if not seeds:
        seeds = [urljoin(origin, path) for path in PATHS]
    seen, out, queue = set(), [], list(dict.fromkeys(seeds))
    while queue and len(out) &lt; limit:
        url = queue.pop(0)
        if url in seen:
            continue
        seen.add(url)
        try:
            body = fetch(url)
        except Exception as exc:
            print(f"skip {url}: {exc}", file=sys.stderr)
            continue
        if not body.lstrip().startswith(b"&lt;"):
            continue
        children, pages = parse(body)
        for child in children:
            if child not in seen:
                queue.append(child)
        for page in pages:
            if len(out) &gt;= limit:
                break
            page["source"] = url
            out.append(page)
    return out

if __name__ == "__main__":
    site = sys.argv[1] if len(sys.argv) &gt; 1 else "https://docs.python.org/"
    limit = int(sys.argv[2]) if len(sys.argv) &gt; 2 else 20
    for row in extract(site, limit):
        print(f"{row.get('lastmod','')}\t{row.get('changefreq','')}\t{row['loc']}")
</code></pre>
<p>I ran <code>python3 extract_sitemaps.py https://docs.python.org/ 20</code>. It printed seven rows, including:</p>
<pre><code class="language-text">	yearly	https://docs.python.org/3.11/
	daily	https://docs.python.org/3.14/
	daily	https://docs.python.org/3/
</code></pre>
<p><code>python3 extract_sitemaps.py https://apify.com/ 3</code> followed the index and printed <code>https://apify.com/</code>, <code>/about</code>, and <code>/actors</code>, not the XML file URLs. <code>python3 extract_sitemaps.py https://www.python.org/ 5</code> printed nothing and logged four 404s, which matches the missing <code>Sitemap:</code> line.</p>
<p>The script is a teaching tool. It has no retries, no size cap, and no check that a URL stays on the public internet. Point it at sites you are allowed to fetch. A timeout or a bad XML file is skipped, and the rest continue.</p>
<h2>What a larger run looks like</h2>
<p>For a long list I use the Actor I built, <a href="https://apify.com/appreciative_duct/sitemap-url-extractor">Sitemap URL Extractor</a>. It is the same idea with retries, a byte cap, nested indexes, and optional image, video, news, and hreflang fields. It uses plain HTTP at 256 MB. It does not open a browser. It is free: you pay Apify platform usage only.</p>
<p>On 19 October 2026 I ran it with <code>docs.python.org</code> and <code>jvns.ca</code>, <code>maxUrls</code> 8, and 256 MB of memory. Run <code>7Nt2owGKPyqvBpr87</code> succeeded in under a second and cost about $0.00009. It fetched two sitemaps, both via <code>robots.txt</code>, and saved 8 URLs because of the cap. These rows are from that dataset:</p>
<table>
<thead>
<tr>
<th>URL</th>
<th>lastmod</th>
<th>changefreq</th>
<th>source sitemap</th>
</tr>
</thead>
<tbody><tr>
<td><code>https://docs.python.org/3.14/</code></td>
<td></td>
<td>daily</td>
<td><code>https://docs.python.org/sitemap.xml</code></td>
</tr>
<tr>
<td><code>https://docs.python.org/3.11/</code></td>
<td></td>
<td>yearly</td>
<td><code>https://docs.python.org/sitemap.xml</code></td>
</tr>
<tr>
<td><code>https://jvns.ca/</code></td>
<td>2026-09-27T00:00:00+00:00</td>
<td></td>
<td><code>https://jvns.ca/sitemap.xml</code></td>
</tr>
<tr>
<td><code>https://jvns.ca/post/</code></td>
<td>2026-09-27T00:00:00+00:00</td>
<td></td>
<td><code>https://jvns.ca/sitemap.xml</code></td>
</tr>
</tbody></table>
<p><code>https://docs.python.org/3.11/</code> also had <code>alternateCount</code> 19. The first two alternates were <code>de</code> â†’ <code>https://docs.python.org/de/3.11/</code> and <code>el</code> â†’ <code>https://docs.python.org/el/3.11/</code>. The run stopped at <code>maxUrls</code>, so it is not the full jvns.ca sitemap. The docs file had seven page URLs when I fetched it separately a few minutes later. A cap truncates. Raise <code>maxUrls</code> only after the first rows look right.</p>
<p>Use the script when you want a few hundred URLs on your own machine. Use the Actor when you want many sites, gzip indexes, hreflang, and a dataset you can hand to something else. One thing I hand it to is an <a href="https://apify.com/appreciative_duct/seo-audit-ai-search-readiness">SEO audit</a> that scores the pages. That is a separate post.</p>
]]></content:encoded></item></channel></rss>