SEO & Digital Marketing Module 2 – Technical SEO: crawling and indexing
Site architecture, URLs and internal links
Plan folders and URLs that stay stable, link every important page with crawlable links and clear anchor text, and find orphan and deep pages.
What you will learn
- Design a URL and folder structure that groups related pages and stays stable for years
- Link every important page through crawlable a href links with descriptive anchor text
- Find orphan pages and deep pages in a crawl, and fix them with hubs, page numbers and breadcrumbs
- Decide which filter, sort and page addresses deserve to be crawled, and keep the rest out
Before you start
What you’ll learn · Before you start
What you will learn
- Design a URL and folder structure that groups related pages and stays stable for years
- Link every important page through crawlable a href links with descriptive anchor text
- Find orphan pages and deep pages in a crawl, and fix them with hubs, page numbers and breadcrumbs
- Decide which filter, sort and page addresses deserve to be crawled, and keep the rest out
Before you start
On this page
Of Pune Bakes’ 300 pages, 3 have no link from any other page of the site, and 6 blog posts can be reached only by pressing “Next page” five times on the blog: 7 clicks from the home page. Google finds most new pages by following links, so pages like these are the last to be found, and visitors rarely find them at all.
The way pages are grouped and linked is the site’s architecture, and it can be measured and fixed. By the end of this page you can design addresses and folders that stay stable, make every important page reachable through links Google can follow with words that say where they lead, find orphan and deep pages in a crawl and fix them, and decide which filter and page addresses deserve to be crawled at all.
Google finds pages by following links, so links decide discovery
Google’s starter guide says that most of the new pages Google finds each day are found through links (SEO Starter Guide). When Googlebot fetches a page it reads the addresses in its links and adds them to the queue of pages to crawl (JavaScript SEO basics), so a page’s links decide which pages are found next. Google’s link guide sets the floor: each page that matters needs a link from at least one other page of the same site (link best practices). A sitemap helps on top of links, as the sitemaps lesson showed, but it is a hint, and it gives a page none of what a link says about it.
Two measures come out of this. A page’s click depth: The fewest links a visitor or a crawler must follow from the home page to reach a page. A crawl measures it breadth first, visiting every page one click away before those two clicks away. is the fewest links that lead to it from the home page, and an orphan page: A page that no other page of the site links to. Crawlers can find it only through a sitemap or another site's link, and visitors only through search or a bookmark. is one that no page of the site links to. Both are found the way a crawler finds pages: start at the home page, visit everything one click away, then everything two clicks away, and so on. Here are ten of the bakery’s pages as letters: H the home page, C the cakes listing, B breads, O offers, M the mango mousse cake, T the eggless truffle, R the rasmalai cake, S the sourdough loaf, P the pav and G the eggless baking guide. Step through the walk from H; d is each page’s click depth:
- being compared or looked at
- being moved or written
- in its final place
- the key, pivot or target
- visited or ruled out
Pseudo-code
-
queue ← [start]; seen ← {start}; dist(start) ← 0 -
while the queue is not empty: -
u ← dequeue -
for each neighbour v of u: -
if v is not seen: seen ← seen + v; dist(v) ← dist(u) + 1; enqueue v
Breadth-first search from H: visit the nodes in order of distance (in edges) from H, using a queue. d is that distance.
When it finishes: Visit order: H B C O S M R T P.
Try your own input
The walk gives the three listings depth 1, and the three cakes and the loaf depth 2. The pav is 3 clicks deep, because only the sourdough page links to it. And G is never reached: the guide links to the cakes and the truffle, but nothing links to it, so it is an orphan, which only a sitemap or another site’s link can bring to Google.
Checkpoint
Check: Discovery through links, depth and orphans
Addresses should be readable words, built to last
Google’s guide to URL structure asks for addresses that people can read (URL structure):
| Prefer | Avoid | Why |
|---|---|---|
Readable words: /cakes/mango-mousse-cake |
Long ID numbers: /p?id=48213 |
Google asks for “readable words rather than long ID numbers” |
| Hyphens between words | Underscores, or words run together | Hyphens help people and search engines see the separate words |
| One case for every address | /Cakes/ and /cakes/ both answering |
Addresses are case-sensitive, so the two are different addresses |
| The words customers search with, transliterated where they use them | Words only the business uses | Google asks for words in the address that may be useful to people |
| Real paths for different content | #/cakes fragments |
Google generally does not support fragments |
Few parameters, joined with & |
Parameters that change nothing | Each parameter can multiply the addresses of one page |
Non-ASCII letters in links are percent-encoded, as the URL standard requires
(RFC 3986), so मोदक in an address travels as %E0%A4%AE…; a
romanised modak is what most customers type. Folders help as well: on a site with more than a few thousand
addresses, grouping similar pages in folders can help Google learn how often each folder changes, and the address can
show in results as a breadcrumb. The words themselves matter less than many think: Google’s starter guide says keywords in
a domain or path have “hardly any effect beyond appearing in breadcrumbs”
(SEO Starter Guide).
What matters most is that an address lasts. Every change costs a redirect, as the previous lesson showed, so an address should hold only what will stay true: no year in an evergreen page, no size that is really an option, no category the product may leave. The program below builds the slug: The readable last part of an address that names its page, such as mango-mousse-cake in /cakes/mango-mousse-cake: lower-case words joined by hyphens., the readable last part of each address, for 20 of the bakery’s products, with Python’s Unicode database taking the accents off (unicodedata):
"""Turn 20 of Pune Bakes' product names into address slugs, and flag the ones that need a person's decision.
A slug is the last part of an address: /cakes/mango-mousse-cake. The rules follow Google's URL structure guide:
readable words in lower case, separated by hyphens. Letters outside a to z are dropped after accents are taken off,
so a name written only in Devanagari needs the romanised name its customers type, kept in ROMANISED.
"""
import re
import unicodedata
NAMES = [
"Mango Mousse Cake", "Eggless Chocolate Truffle", "Rasmalai Cake", "Red Velvet Heart (1 kg)", "Crème Brûlée Tart",
"Butterscotch Cake – Eggless", "Coffee & Walnut Cake", "Blueberry Cheesecake", "Black Forest Mini",
"Pineapple Pastry", "Ajwain Biscuits", "Nankhatai", "Sourdough Loaf", "Pav (6 pieces)", "मोदक", "पुरणपोळी",
"Shrikhand Cups", "Mango mousse cake!", "Diwali Hamper 2026", "Kaju_Katli Box",
]
ROMANISED = {"मोदक": "Modak", "पुरणपोळी": "Puran Poli"} # the names customers type in Latin letters
def slug(name):
text = unicodedata.normalize("NFKD", name.replace("&", " and ").lower())
text = "".join(c for c in text if not unicodedata.combining(c)) # crème -> creme
return "-".join(re.findall(r"[a-z0-9]+", text))
seen = {}
for name in NAMES:
s, notes = slug(name), []
if not s:
s = slug(ROMANISED[name])
notes.append(f"no Latin letters: built from {ROMANISED[name]!r}")
if re.search(r"\b(19|20)\d\d\b", name):
notes.append("a year: will it still be true next year?")
if re.search(r"\d+\s*(kg|g|pieces)\b", name):
notes.append("a size: is it part of the product, or an option?")
if s in seen:
notes.append(f"the same slug as {seen[s]!r}")
seen.setdefault(s, name)
print(f"{s:<30} {name}" + "".join(f"\n{'':<30} {n}" for n in notes))
print(f"\n{len(NAMES)} names, {len(seen)} different slugs") Output
mango-mousse-cake Mango Mousse Cake
eggless-chocolate-truffle Eggless Chocolate Truffle
rasmalai-cake Rasmalai Cake
red-velvet-heart-1-kg Red Velvet Heart (1 kg)
a size: is it part of the product, or an option?
creme-brulee-tart Crème Brûlée Tart
butterscotch-cake-eggless Butterscotch Cake – Eggless
coffee-and-walnut-cake Coffee & Walnut Cake
blueberry-cheesecake Blueberry Cheesecake
black-forest-mini Black Forest Mini
pineapple-pastry Pineapple Pastry
ajwain-biscuits Ajwain Biscuits
nankhatai Nankhatai
sourdough-loaf Sourdough Loaf
pav-6-pieces Pav (6 pieces)
a size: is it part of the product, or an option?
modak मोदक
no Latin letters: built from 'Modak'
puran-poli पुरणपोळी
no Latin letters: built from 'Puran Poli'
shrikhand-cups Shrikhand Cups
mango-mousse-cake Mango mousse cake!
the same slug as 'Mango Mousse Cake'
diwali-hamper-2026 Diwali Hamper 2026
a year: will it still be true next year?
kaju-katli-box Kaju_Katli Box
20 names, 19 different slugs
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 make_slugs.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Most names needed nothing, and Crème Brûlée Tart became creme-brulee-tart. The two Devanagari names have no Latin
letters left, so their slugs come from the names customers type, modak and puran-poli. Two names carry a size and
one a year, each a question for a person, and the second mango mousse cake would collide with the first, the duplicate
of the canonical lesson waiting to happen.
Checkpoint
Check: Readable addresses that last
A link Google can follow is an a element with an href, and its words name the target
Google generally crawls only <a> elements with an href attribute that resolves to a real address, and may skip a
<span href>, an <a> with only an onclick, or an href="javascript:…"
(link best practices).
A link added by JavaScript is fine, as long as it is the same markup. The anchor text: The visible words of a link, or the alt text of a linked image. It tells people and Google what the target page is about, so it should name the target rather than say click here., the
words inside the link, tells people and Google what the target is about, so Google asks for anchors that are
“descriptive, reasonably concise, and relevant”, warns against “click here” and “read more”, uses a linked image’s
alt text as its anchor, and calls keyword stuffing a violation of its spam policies
(spam policies). The words around a link matter
too, and links chained one after another with nothing between them say less than links in sentences. The program
below reads one of the bakery’s pages and reports every element that looks like a link:
"""Which of the links on a Pune Bakes page can Google follow, and what do their words say?
Google generally crawls only <a> elements with an href that resolves to an address; it may skip the rest. The anchor
text, or the alt text of a linked image, tells people and Google what the target is about. The program reads the
page's HTML with Python's parser and reports each link-like element.
"""
from html.parser import HTMLParser
PAGE = """
<nav>
<a href="/cakes/">Cakes</a>
<a href="/cakes/eggless/">Eggless cakes in Pune</a>
<span href="/breads/">Breads</span>
<button onclick="go('/cookies/')">Cookies</button>
<a onclick="go('/hampers/')">Hampers</a>
<a href="javascript:openMenu('offers')">Offers</a>
</nav>
<main>
<p>Our mango mousse sold out by noon. <a href="/cakes/mango-mousse">Click here</a> to order one.</p>
<a href="/cakes/eggless-truffle"><img src="/img/truffle.jpg" alt="Eggless chocolate truffle cake"></a>
<a href="/blog/bakers-notes-12"><img src="/img/oven.jpg" alt=""></a>
<a href="/cakes/">best cake shop pune best cakes pune cheap cakes pune order cake pune</a>
<a href="/cakes/rasmalai-cake">Rasmalai cake</a>
</main>
"""
GENERIC = {"click here", "read more", "here", "more", "this page", "link"}
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.found, self.current = [], None
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "a" or "href" in a or "onclick" in a:
self.current = {"tag": tag, "href": a.get("href"), "onclick": "onclick" in a, "text": "", "alt": None}
self.found.append(self.current)
elif tag == "img" and self.current is not None:
self.current["alt"] = a.get("alt", "")
def handle_endtag(self, tag):
if self.current and tag == self.current["tag"]:
self.current = None
def handle_data(self, data):
if self.current:
self.current["text"] += data
def verdict(link):
href = link["href"] or ""
if link["tag"] != "a" or not href or href.startswith("javascript:"):
why = f"<{link['tag']}>" if link["tag"] != "a" else ("no href" if not href else "a javascript: href")
return f"not a link Google can follow ({why})"
words = " ".join(link["text"].split()) or (link["alt"] or "")
if not words:
return f"{href}: no anchor text and an empty alt"
if words.lower() in GENERIC:
return f'{href}: "{words}" says nothing about the target'
if len(words.split()) > 8:
return f'{href}: {len(words.split())} words of keywords, not a description'
return f'{href}: "{words}"' + (" (the image's alt text)" if not link["text"].strip() else "")
parser = Links()
parser.feed(PAGE)
for link in parser.found:
print(verdict(link)) Output
/cakes/: "Cakes" /cakes/eggless/: "Eggless cakes in Pune" not a link Google can follow (<span>) not a link Google can follow (<button>) not a link Google can follow (no href) not a link Google can follow (a javascript: href) /cakes/mango-mousse: "Click here" says nothing about the target /cakes/eggless-truffle: "Eggless chocolate truffle cake" (the image's alt text) /blog/bakers-notes-12: no anchor text and an empty alt /cakes/: 13 words of keywords, not a description /cakes/rasmalai-cake: "Rasmalai cake"
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 audit_links.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Four of the six menu items are not links Google can follow: the breads span, the cookies button, the hampers a
without an href and the offers javascript: link all work in a browser and lead nowhere for a crawler. Of the page’s
own links, “Click here” names nothing, the oven photo has an empty alt, and a 13-word anchor of town and cake names is
keyword stuffing; the truffle photo’s alt text and “Rasmalai cake” are what anchors should be.
A menu made of buttons
A redesign builds the menu from <button onclick="go('/cookies/')">Cookies</button>, and the cookie pages slowly
drop out of Google. The button works for visitors, but it is not a link. Write
<a href="/cookies/">Cookies</a>, and let a script add any effect on top of the real link.
Checkpoint
Check: Crawlable links and anchor text
Measure click depth and orphans, then fix them with hubs, page numbers and breadcrumbs
A crawl measures a whole site the way the walk above measured ten pages. The program below builds the links of the bakery’s 300 pages as a crawl finds them, walks them from the home page, compares the pages it reached with the sitemap, and counts the anchors used most, first as the site is today and then after three fixes:
"""Click depth, orphans and anchor text on Pune Bakes' 300 pages, before and after three fixes.
links() builds the links a crawl of the site finds: (from page, to page, anchor text). The program walks them
breadth first from the home page, so each page's depth is the fewest clicks that reach it, compares the pages it
reached with the 300 the sitemap lists, and counts the anchors used most.
"""
from collections import Counter, deque
FLAVOURS = ["mango", "chocolate", "rasmalai", "butterscotch", "pineapple", "black-forest", "red-velvet",
"coffee-walnut", "blueberry", "strawberry", "vanilla", "pistachio"]
SECTIONS = { # section -> its products
"cakes": [f"{f}-{s}" for f in FLAVOURS for s in ["mousse", "truffle", "sponge", "cheesecake", "tart",
"cupcakes", "roll", "pastry", "loaf", "gateau"]],
"breads": [f"{g}-{s}" for g in ["wheat", "multigrain", "sourdough", "ragi", "jowar", "oat", "rye", "milk"]
for s in ["loaf", "buns", "pav", "rolls", "baguette"]],
"cookies": [f"{f}-{s}" for f in ["ajwain", "jeera", "nankhatai", "coconut", "cashew", "almond", "oats", "choco"]
for s in ["box", "jar", "tin", "pack", "bag"]],
"hampers": [f"{o}-{s}" for o in ["diwali", "birthday", "wedding", "corporate"]
for s in ["mini", "small", "medium", "large", "grand"]],
}
TOP = ["/cakes/", "/breads/", "/cookies/", "/hampers/", "/blog/", "/about/", "/contact/", "/offers/", "/delivery/",
"/faq/", "/privacy/", "/terms/"]
POSTS = [f"/blog/bakers-notes-{n:02}" for n in range(1, 57)] # 56 posts, newest first
ORPHANS = ["/guides/eggless-baking", "/offers/diwali-hampers", "/cakes/mango-mousse-1kg"]
PER_PAGE = {"cakes": 30, "blog": 10} # listings longer than this are split into pages
def title(path):
return path.rstrip("/").rsplit("/", 1)[-1].replace("-", " ").capitalize()
def listing(base, items, per_page, anchor, numbered):
"""Links of a listing split into pages of per_page items: 'Next page' only, or every page by number."""
pages = [base] + [f"{base}?page={n}" for n in range(2, -(-len(items) // per_page) + 1)]
for i, page in enumerate(pages):
for item in items[i * per_page:(i + 1) * per_page]:
yield page, item, anchor(item)
if numbered:
yield from ((page, other, f"Page {j + 1}") for j, other in enumerate(pages) if other != page)
elif i + 1 < len(pages):
yield page, pages[i + 1], "Next page"
def links(fixed):
yield from (("/", page, title(page)) for page in TOP)
for section, slugs in SECTIONS.items():
items = [f"/{section}/{slug}" for slug in slugs]
yield from listing(f"/{section}/", items, PER_PAGE.get(section, 100), title, fixed)
for i, item in enumerate(items): # each product links to the next two of its section
yield from ((item, items[(i + k) % len(items)], title(items[(i + k) % len(items)])) for k in (1, 2))
read = title if fixed else (lambda post: "Read more")
yield from listing("/blog/", POSTS, PER_PAGE["blog"], read, fixed)
yield from ((post, "/cakes/", "Order a cake") for post in POSTS)
yield from ((orphan, "/cakes/", "Cakes") for orphan in ORPHANS)
if fixed: # the guide gets links from two hubs; the other two left the sitemap with a 301
yield from (("/cakes/", ORPHANS[0], "Eggless baking guide"), ("/blog/", ORPHANS[0], "Eggless baking guide"))
def report(name, fixed):
graph, anchors = {}, Counter()
for source, target, anchor in links(fixed):
graph.setdefault(source, []).append(target)
anchors[anchor] += 1
sitemap = {"/"} | set(graph) | {t for ts in graph.values() for t in ts}
if fixed:
sitemap -= set(ORPHANS[1:])
depth, queue = {"/": 0}, deque(["/"])
while queue: # breadth first: every page gets the fewest clicks from the home page
page = queue.popleft()
for target in graph.get(page, []):
if target not in depth:
depth[target] = depth[page] + 1
queue.append(target)
orphans = sorted(sitemap - set(depth))
print(f"{name}: {len(sitemap)} pages in the sitemap, {len(sitemap) - len(orphans)} reached from the home page")
by_depth = Counter(depth.values())
print(" clicks from home: " + ", ".join(f"{d}: {by_depth[d]}" for d in sorted(by_depth)))
deepest = max(depth.values())
print(f" deepest, {deepest} clicks: {sum(1 for d in depth.values() if d == deepest)} pages, such as "
f"{min(p for p, d in depth.items() if d == deepest)}")
print(f" orphans: {len(orphans)}" + "".join(f"\n {p}" for p in orphans))
print(" anchors used most: " + ", ".join(f'"{a}" {n}' for a, n in anchors.most_common(3)))
report("Today", fixed=False)
print()
report("After the fixes", fixed=True) Output
Today: 300 pages in the sitemap, 297 reached from the home page
clicks from home: 0: 1, 1: 12, 2: 142, 3: 42, 4: 42, 5: 41, 6: 11, 7: 6
deepest, 7 clicks: 6 pages, such as /blog/bakers-notes-51
orphans: 3
/cakes/mango-mousse-1kg
/guides/eggless-baking
/offers/diwali-hampers
anchors used most: "Read more" 56, "Order a cake" 56, "Next page" 8
After the fixes: 298 pages in the sitemap, 298 reached from the home page
clicks from home: 0: 1, 1: 12, 2: 149, 3: 136
deepest, 3 clicks: 136 pages, such as /blog/bakers-notes-11
orphans: 0
anchors used most: "Order a cake" 56, "Page 2" 8, "Page 3" 8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 site_depth.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Today the blog’s chain of “Next page” links puts its six oldest posts 7 clicks deep and the cakes’ chain puts the last 30 cakes 5 deep, 3 pages are orphans, and “Read more”, used 56 times, shares first place among the site’s anchors. The fixes that took every page to 3 clicks or fewer are all ordinary:
- Numbered pages. Each listing page links every page of its listing by number, not only the next one. Google asks
for
<a href>links between the pages of a sequence, each page at its own address such as?page=2(pagination). - Titles as anchors. The blog links each post by its title instead of “Read more”.
- A home for each orphan. The eggless baking guide is linked from the cakes hub and the blog; the old Diwali
landing page and the
1kgvariant left the sitemap with a 301 each, as the two previous lessons advised.
One long blog page
Press Edit, change the blog’s 10 in PER_PAGE to 56 and run again: every post moves up to 2 clicks, and the
deepest pages are now the 30 cakes on the fourth listing page, at 5. One fix moves the problem; numbered pages solve it
for both listings.
A hub page: A page that gathers the links to every page of one section or topic, such as a category listing, so that each of them is a click or two from the home page., such as a category listing, gathers the links of one section, and a
breadcrumb: A short trail of links such as Home > Cakes > Mango mousse cake that shows where a page sits on a site. Marked up as BreadcrumbList, it can also show in Google's results. links each page back up the trail, Home > Cakes > Mango mousse cake. Marked
up as BreadcrumbList, a breadcrumb can also show in Google’s results, which lists the rich result as available on
desktop; Google asks that it represent “a typical user path to a page” rather than mirror the address, and a page may
have more than one trail (breadcrumb).
The cakes section after the fixes:
The cakes section after the fixes: a hub, numbered pages and breadcrumbs
Text description of the diagram
The diagram runs from top to bottom.
1. At the top is the home page, at 0 clicks. Its menu link "Cakes" leads to the cakes hub. 2. The hub is the cakes listing, split into pages 1 to 4, and each page links all four by number, so every page of the listing is 1 or 2 clicks from the home page. 3. From the hub, each cake is linked by its own name: the mango mousse cake is 2 or 3 clicks from the home page. The hub also links the eggless baking guide, which had no links before, so it is now 2 clicks from home. 4. The mango mousse cake links two related cakes by their names, and a dashed arrow, its breadcrumb, leads back to the cakes hub.
Every cake in the drawing is two or three clicks from home and the guide is two, while the dashed breadcrumb takes each cake one click back up to its hub. The crawl program leaves breadcrumbs out, and its depths would not change with them: a link to a page that is already nearer the home page changes no page’s depth.
Website SEO Crawler & Site Audit Crawl your own site from its home page and see each page's click depth, its inlinks and the sitemap addresses no page links to.Checkpoint
Check: Fixing deep and orphan pages
Filters, sort orders and page numbers: decide which addresses deserve to exist
The bakery’s cake listing has four filters, flavour, egg, weight and occasion, and three sort orders. Each choice is a parameter in the address, and the addresses multiply. The program below counts them:
"""How many addresses can Pune Bakes' cake listing make from four filters and a sort order?
Each parameter is either left out or set to one of its values, so a fixed order of parameters gives the product of
(values + 1) over the parameters. If the site accepts the parameters in any order, every order of the same choices is
another address; if flavours can be combined, each flavour becomes on or off.
"""
from itertools import combinations
from math import factorial, prod
CAKES = 120
PARAMS = {"flavour": 12, "egg": 2, "weight": 4, "occasion": 6, "sort": 3} # values of each, besides "left out"
fixed = prod(n + 1 for n in PARAMS.values())
any_order = sum(prod(PARAMS[p] for p in chosen) * factorial(k)
for k in range(len(PARAMS) + 1) for chosen in combinations(PARAMS, k))
combined = fixed // (PARAMS["flavour"] + 1) * 2 ** PARAMS["flavour"]
print(f"{CAKES} cakes, one listing, {len(PARAMS)} parameters")
print(f" one value each, in a fixed order: {fixed:>9,} addresses")
print(f" one value each, in any order: {any_order:>9,} addresses")
print(f" flavours combinable, fixed order: {combined:>9,} addresses")
print(f" that is {combined // CAKES:,} listing addresses for every cake") Output
120 cakes, one listing, 5 parameters one value each, in a fixed order: 5,460 addresses one value each, in any order: 270,044 addresses flavours combinable, fixed order: 1,720,320 addresses that is 14,336 listing addresses for every cake
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 count_urls.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
One listing of 120 cakes makes 5,460 addresses with one value a parameter in a fixed order, 270,044 if the parameters may come in any order, and 1,720,320 if flavours can be combined. Google’s guide to faceted navigation: Filters and sort orders on a listing, such as flavour, size and price, each choice of which makes a new address. Left unchecked, they make far more addresses than the site has products. calls the result an infinite URL space, which costs twice: crawlers fetch many addresses before they learn that they are worthless, and that work slows the discovery of new pages (faceted navigation). It offers two ways out:
| Approach | What it takes |
|---|---|
| Keep filter addresses out of crawling | A robots.txt rule for the filter parameters, while every product and one unfiltered listing stay crawlable |
| Make filter addresses crawl-friendly | & between parameters, one fixed order of filters, no repeated filter, and a 404 for a combination that finds nothing |
Google calls rel="canonical" and nofollow on filter links less effective in the long term than either. For page
numbers, the same pagination guide adds three rules: give each page its own address, do not name page 1 as the
canonical of the others, since each page should have its own, and do not put page numbers in fragments, which Google
ignores. Google no longer uses rel="next" and rel="prev". A sort order that only rearranges the same items is a
copy of its listing: it names the listing as its canonical, as the canonical lesson advised for sorted lists, or the
pagination guide’s noindex or robots.txt rule keeps it out of the index.
Key idea
Decide which addresses deserve to exist before a crawler finds them all: the products, one listing per section, and the few filtered pages people search for; everything else stays out of crawling, names its listing as canonical or answers 404.
Checkpoint
Check: Filters, sort orders and page numbers
Practise: give a flat site folders, hubs and links
Spice Box’s 60 pages all sit at the root of the site, and its home page links only ten of them. Write address() and
link_plan() so that every page gets a clean folder address and is two or three clicks from the home page; the sample
tests rebuild the link graph from your plan and check depth, orphans, broken links and anchors:
Exercise · Medium · Python
Give a flat 60-page site folders, hubs and links
Spice Box's 60 pages all sit at the root of the site today, and its home page links only ten of them. PAGES in site_plan.py lists the titles of the pages of each of four sections, and HUBS names the hub page of each section, which will live at /spices/, /blends/, /recipes/ and /guides/.
- Write
address(section, title)so that it returns the page's new address:/<section>/<slug>, where the slug is the title in lower case, its accents taken off,&written asand, and its words joined by hyphens. - Write
link_plan()so that it returns every link of the new site as(from address, to address, anchor text), starting from the home page/.
The sample tests rebuild the site's link graph from your plan and check that the 60 addresses are clean and different, that every page is within three clicks of the home page, that no link is broken, that every anchor says where it goes and that every page links back to its hub, as a breadcrumb does.
Starter code · site_plan.py
"""Spice Box's 60 pages, today all at the root of the site, and the plan that gives them folders and links."""
HUBS = {"spices": "Spices", "blends": "Blends", "recipes": "Recipes", "guides": "Guides"}
PAGES = { # section -> the titles of its pages
"spices": [
"Kashmiri Chilli Powder", "Black Pepper (Tellicherry)", "Green Cardamom", "Turmeric Powder", "Cinnamon Sticks",
"Cloves", "Saffron Threads", "Cumin Seeds", "Coriander Seeds", "Fenugreek Seeds", "Mustard Seeds", "Nutmeg",
"Star Anise", "Bay Leaves", "Fennel Seeds", "Ajwain", "Asafoetida (Hing)", "Dry Ginger Powder", "Mace",
"Black Cardamom", "Kalonji", "Amchur Powder", "Kokum", "Byadgi Chilli",
],
"blends": [
"Garam Masala", "Chai Masala (Classic)", "Sambar Powder", "Rasam Powder", "Pav Bhaji Masala", "Chole Masala",
"Biryani Masala", "Goda Masala", "Kitchen King Masala", "Salt & Pepper Mix", "Tandoori Masala", "Chaat Masala",
],
"recipes": [
"Masala Chai", "Sambar", "Rasam", "Pav Bhaji", "Chole", "Vegetable Biryani", "Puran Poli", "Kokum Sherbet",
"Turmeric Latte", "Jeera Rice", "Methi Thepla", "Kashmiri Dum Aloo", "Goan Fish Curry", "Saffron Kheer",
"Masala Peanuts", "Spiced Buttermilk",
],
"guides": [
"How to Store Spices", "Whole vs Ground Spices", "Roasting Spices at Home", "Spice Shelf Life",
"Building a Masala Dabba", "Buying Saffron Safely", "Grinding Masalas", "Cooking With Hing",
],
}
def address(section, title):
"""The page's new address: /<section>/<slug>, in lower case, with words joined by hyphens."""
return "/" + title.replace(" ", "_")
def link_plan():
"""Every link of the new site as (from address, to address, anchor text); the home page is "/"."""
titles = [t for section in PAGES.values() for t in section][:10]
return [("/", address("spices", t), "Read more") for t in titles] The sample tests · test_site_plan.py
import re
from collections import deque
from site_plan import HUBS, PAGES, address, link_plan
GENERIC = {"click here", "read more", "here", "more", "this page", "link"}
ALL = [(s, t) for s, titles in PAGES.items() for t in titles]
def depths():
graph = {}
for source, target, _ in link_plan():
graph.setdefault(source, []).append(target)
depth, queue = {"/": 0}, deque(["/"])
while queue:
page = queue.popleft()
for target in graph.get(page, []):
if target not in depth:
depth[target] = depth[page] + 1
queue.append(target)
return depth
def test_addresses_are_clean_folders():
"""60 different addresses, each /<section>/<slug> with lower-case words joined by hyphens"""
made = [address(s, t) for s, t in ALL]
assert len(set(made)) == 60
for (section, title), a in zip(ALL, made):
assert re.fullmatch(rf"/{section}/[a-z0-9]+(-[a-z0-9]+)*", a), f"{title}: {a}"
def test_every_page_within_three_clicks():
"""every page and every hub can be reached from the home page in three clicks or fewer"""
depth = depths()
for section, title in ALL:
assert depth.get(address(section, title), 99) <= 3, title
for section in HUBS:
assert f"/{section}/" in depth, section
def test_no_broken_links():
"""every link leads to the home page, a hub or one of the 60 pages"""
known = {"/"} | {f"/{s}/" for s in HUBS} | {address(s, t) for s, t in ALL}
for source, target, _ in link_plan():
assert source in known and target in known, (source, target)
def test_anchors_say_where_they_go():
"""no empty or generic anchors, and each anchor shares a word with the title of the page it leads to"""
names = {f"/{s}/": name for s, name in HUBS.items()} | {address(s, t): t for s, t in ALL} | {"/": "Home"}
for source, target, anchor in link_plan():
words = set(re.findall(r"[a-z]+", anchor.lower()))
assert anchor.strip() and anchor.lower() not in GENERIC, (source, target, anchor)
assert words & set(re.findall(r"[a-z]+", names[target].lower())), (target, anchor)
def test_every_page_links_back_to_its_hub():
"""each page links to its section's hub, as a breadcrumb does"""
plan = {(source, target) for source, target, _ in link_plan()}
for section, title in ALL:
assert (address(section, title), f"/{section}/") in plan, title A hint
Three kinds of link are enough: the home page links each hub with the hub's name, each hub links each of its pages with the page's title, and each page links back to its hub with the hub's name. That puts every page two clicks from home. For the slug, unicodedata.normalize("NFKD", …) splits a letter from its accent, and re.findall(r"[a-z0-9]+", …) keeps the words.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise the five ideas of this lesson, the weakest first:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what is an orphan page, and why does it matter?
An orphan page is a page that no other page of the site links to. Google finds most new pages by following links, so an orphan can be found only through a sitemap or another site’s link, and the sitemap is a hint, not a promise. Visitors cannot reach it either, except from a search result or a bookmark. Google’s link guide asks that every page you care about have a link from at least one other page of your site. A crawl finds orphans by comparing the pages its walk from the home page reached with the pages the sitemap or the database lists. The fix depends on the page: link a page that matters from its hub and from related pages, redirect one that has a better replacement, and remove one that should not exist, along with its sitemap entry.
Two more questions follow, about a blog whose old posts are buried and a shop that wants new addresses for its keywords. Make the first decision of each before you open it:
Checkpoint
Your first decision
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A blog of 2,000 posts lists them only in a paginated archive, 10 a page, newest first, behind "Older posts" links. Its old posts get no search traffic, and many are "Discovered - currently not indexed". What do you change?
-
Fresher to mid
A shop wants to replace its product addresses, such as /p/48213, with readable ones, such as /cakes/mango-mousse-cake, "to rank better". Is it worth it, and how would you do it?
Key takeaways
- Google finds most new pages through links, so every page that matters needs a link from another page of the site; click depth and orphans are measured by walking the links from the home page.
- An address is readable words in one case joined by hyphens, in folders that group related pages, and holds nothing that will change, because every change costs a redirect.
- Google follows
<a>elements with anhref, and their anchor text should name the target in a few words, never “click here” or a string of keywords. - Numbered pages, titles as anchors, hubs and breadcrumbs bring deep pages within a few clicks, and each orphan is linked, redirected or removed.
- Filters and sort orders multiply addresses into the hundreds of thousands, so a site decides which deserve to be crawled and keeps the rest out with robots.txt or a 404.
Terms in this lesson
- anchor text
- The visible words of a link, or the alt text of a linked image. It tells people and Google what the target page is about, so it should name the target rather than say click here.
- breadcrumb
- A short trail of links such as Home > Cakes > Mango mousse cake that shows where a page sits on a site. Marked up as BreadcrumbList, it can also show in Google's results.
- click depth
- The fewest links a visitor or a crawler must follow from the home page to reach a page. A crawl measures it breadth first, visiting every page one click away before those two clicks away.
- faceted navigation
- Filters and sort orders on a listing, such as flavour, size and price, each choice of which makes a new address. Left unchecked, they make far more addresses than the site has products.
- hub page
- A page that gathers the links to every page of one section or topic, such as a category listing, so that each of them is a click or two from the home page.
- orphan page
- A page that no other page of the site links to. Crawlers can find it only through a sitemap or another site's link, and visitors only through search or a bookmark.
- slug
- The readable last part of an address that names its page, such as mango-mousse-cake in /cakes/mango-mousse-cake: lower-case words joined by hyphens.
References
- Link best practices for Google (Google Search Central)
- URL structure best practices for Google Search (Google Search Central)
- SEO Starter Guide (Google Search Central)
- Understand the JavaScript SEO basics (Google Search Central)
- Breadcrumb (BreadcrumbList) structured data (Google Search Central)
- Pagination, incremental page loading, and their impact on Google Search (Google Search Central)
- Managing crawling of faceted navigation URLs (Google for Developers)
- Spam policies for Google web search (Google Search Central)
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax (IETF)
- unicodedata: Unicode Database (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress