Lab 06

Web Crawling, Web Graphs and PageRank

Crawlers robots.txt Web graph PageRank
Work in progress These notes are still being written and reviewed: content may change, and some parts may still contain mistakes.

Overview of the lab

So far we assumed that the document collection already exists. For web search that assumption breaks: before we can index, rank and evaluate web pages, we must answer "How do we discover and collect the documents?" The Web also gives us something ordinary collections do not: pages link to one another. The links form a graph, and the graph itself provides ranking signals.

Web → crawler → document collection → index → content-based ranking + link-based ranking → search results
  1. From a fixed corpus to the Web: what crawlers are, their types, crawling vs scraping.
  2. How a crawler works: the crawl loop, seed URLs, the URL frontier.
  3. URLs are messier than they look: normalization, URL and content duplicates.
  4. robots.txt and politeness, and crawl traps.
  5. Crawling in practice: Scrapy, BeautifulSoup, dynamic pages, good practices.
  6. The Web as a directed graph: in-degree, and why counting links is not enough.
  7. PageRank: the random surfer, damping, dangling nodes, link farms, real graphs.
  8. Combining textual and link signals.
  9. Connection to modern retrieval and RAG.
  10. Summary, and optional exercises.

Learning objectives

By the end you should be able to explain:

  • why web crawling is a separate problem from indexing, and the basic architecture of a crawler;
  • what a URL frontier is, and why URL normalization and duplicate detection are necessary;
  • the purpose and limitations of robots.txt, and why crawlers need politeness and rate limiting;
  • how hyperlinks form a directed graph, and why counting incoming links is not enough;
  • the intuition behind PageRank and the role of the damping factor;
  • why link-based authority and textual relevance are different signals;
  • how web crawling relates to modern search and RAG ingestion pipelines.
Part I

From a fixed corpus to the Web

In previous labs we started with documents = [...] and built documents → preprocessing → index → retrieval model. But where did the documents come from? For a web search engine the collection is not given in advance: the system must discover pages.

1. Crawling

discover and fetch pages

2. Indexing

turn fetched pages into searchable representations (Lab 01)

3. Ranking

order the results for a query (Lab 04)

These are different engineering problems. A crawler is not a search engine by itself, an inverted index is not a crawler, and a ranking function does not discover new pages.

What are web crawlers?

📐 Web crawler

A web crawler (crawler, web spider) is a program that searches and automatically indexes website content and other information over the internet. Alternatively: crawlers are software or scripts programmed to systematically browse the World Wide Web as part of an automated process.

  1. Step 1: the crawler starts processing a predefined list of web pages.
  2. Step 2: once it crawls a page, it discovers new pages via hyperlinks.
  3. Step 3: it adds the newly discovered URLs to the crawl queue, to be crawled later.

Result: a crawler can explore every page that is connected to the ones it already knows. It does not magically know every URL on the Web.

What are they used for?

  • Search engines: Googlebot, Bingbot, Applebot (Siri and Spotlight), OpenAI's crawlers for ChatGPT, and Xenon, a crawler the Dutch tax administration has used since 2004 to find undeclared online sellers.
  • Web scraping: data analytics and data science, marketing and sales, competition research, and academic research (especially with the machine-learning advances of recent years).
  • Data analytics.

See it: types of web crawlers

The taxonomy of Kumar, Bhatia & Rattan (2017), A survey of Web crawlers for information retrieval. Click a type.

Crawling vs scraping

CrawlingScraping
Goaldiscover and fetch whole pages, and the URLs they link toextract specific data from one or more websites
Outputa collection of pages + a link graphstructured records (a table, JSON)
Scalealways largesmall or large
Knows the page layout?no: works on any pageyes: written for a particular page structure
1A client extracts large amounts of sports data with a framework that executes "crawlers". Looking at the architecture, you see that a separate "crawler" is implemented for every web page of interest. How does this align with the definition of a crawler?

It does not: these are scrapers. Web scraping extracts specific data from one or more websites, with code written for each page's layout. Crawling extracts the whole page and the additional URLs it links to, and one crawler works on any page.

2Does a web crawler exclude the use of a web scraper? How do crawlers and scrapers scale?

No, they combine: a crawler detects pages, then scrapers extract specific information rather than the whole page content. This is used when the target domain is clear. Crawlers are always used at large scale; scrapers at small and large scale.

Part II

How a crawler works

Start from one page, https://example.org/. Fetch it and find links to /about, /courses, /people. Fetch those and discover more links. The process is recursive.

crawl_loop.py (conceptual) Python
frontier = [seed_url]
visited = set()

while frontier:
    url = frontier.pop()

    if url in visited:
        continue

    page = fetch(url)
    visited.add(url)

    store(page)

    for link in extract_links(page):
        frontier.append(link)

This is enough to explain the idea. It is not enough to build a responsible production crawler.

Crawler architecture

A slightly more realistic pipeline. It is a loop: the links a page yields go back into the frontier. Click a stage.

  • Seed URLs: a crawler must start somewhere: a university homepage, a news homepage, a known domain list, a sitemap, a previous crawl. The seed set strongly affects what the crawler discovers.
  • URL frontier: the collection of URLs waiting to be crawled. A naive implementation uses a list, a queue or a stack; a real crawler needs scheduling: Which URL should be fetched next? Which host was contacted recently? Which pages are likely to be important? Which should be revisited? How do we avoid crawling one site too aggressively?

Breadth-first vs depth-first

With links A → B → C → D and A → E, a depth-first crawler may go A, B, C, D, E; a breadth-first crawler A, B, E, C, D. For web crawling a pure DFS or BFS policy is usually too simplistic, but they are useful starting points for understanding frontier scheduling.

Try it: a crawl simulator

Choose a site and a frontier policy, then step through the crawl loop. The toy graph is the example above. example.org is an invented site with the problems of Parts III and IV: messy links, a printer-friendly copy, a /private/ area and an endless calendar. Switch the protections on one by one and watch the wasted fetches fall.

20
step 0

stored   fetched, wasted   should not be fetched   in frontier

Frontier

💡 The loop above uses frontier.pop(), which takes the last URL: a stack, so it is depth-first, and it visits A, E, B, C, D (the last link first). The simulator pushes each page's links in reverse, so its DFS follows the first link first: A, B, C, D, E. BFS uses collections.deque and popleft().

Part III

URLs are messier than they look

Are https://example.org/page, https://example.org/page/, https://example.org/page#section and https://EXAMPLE.org/page four different documents? Maybe. Maybe not. This introduces URL normalization and canonicalization.

  • Normalization steps: lowercase the hostname, remove URL fragments, resolve relative URLs, remove default ports, normalize path components (https://example.org/a/../page → https://example.org/page).
  • ❗ Normalization must be conservative. Different URLs can genuinely represent different resources: /page and /page/ may differ, and so may ?id=1 and ?id=2.

Try it: normalize a URL

Each step shows what it changed. The last three are not safe in general: turn them on to see what an aggressive crawler would merge.

Why duplicate URLs matter

Without duplicate handling, with links A → B, A → C, B → C, C → A, the crawler keeps rediscovering A, B, C, A, B, C, … At minimum, maintain visited_urls = set(). But there is a second problem: different URLs → the same or nearly identical content: printer-friendly pages, tracking-parameter variants, mobile/desktop versions, mirrored pages, session-specific URLs. So we distinguish a URL duplicate from a content duplicate.

📐 Exact duplicate detection

Hash the (normalized) content: if the same content produces the same hash, it is an exact duplicate.

fingerprint.py Python
import hashlib

fingerprint = hashlib.sha256(
    text.encode("utf-8")
).hexdigest()

Near duplicates: two pages may differ only slightly: the same article with a different navigation bar, timestamp or ad. Exact hashes will not detect this. Large crawlers use approximate techniques such as shingling, MinHash and SimHash. We do not derive them here; the point is that duplicate detection is an IR systems problem, not merely a storage problem.

Try it: exact vs near duplicates

SHA-256 of both texts (computed in your browser), and, as a preview of shingling, the Jaccard overlap of their word 3-grams. One changed character changes the whole hash.

💡 This happens on real sites. The polite crawler of Part VII fetched 50 pages of quotes.toscrape.com and found 7 pairs of byte-identical pages, like /tag/love/ and /tag/love/page/1/: 14% of the crawl budget spent on copies.

Part IV

robots.txt and politeness

A crawler should not fetch arbitrary URLs as fast as possible. Two concepts matter: rules + politeness.

robots.txt

Websites can publish a file at /robots.txt, e.g. https://example.org/robots.txt. The Robots Exclusion Protocol is standardized in RFC 9309.

📐 A simple robots.txt
User-agent: *
Disallow: /private/
Allow: /public/

Meaning: all crawlers should not crawl /private/. ❗ robots.txt is not an authentication or authorization mechanism: it communicates crawling preferences. Sensitive information should never be protected by relying on robots.txt.

Python includes a parser:

robots.py Python
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url("https://example.org/robots.txt")
rp.read()

allowed = rp.can_fetch(
    "MyCourseCrawler",
    "https://example.org/page"
)

# the same parser on rules given as text
rp = RobotFileParser()
rp.parse("""User-agent: *
Disallow: /private/
Allow: /private/press/""".splitlines())
rp.can_fetch("MyCourseCrawler", "https://example.org/private/press/news")
# False  (RFC 9309: allowed, see below)

Try it: a robots.txt tester

Edit the file, the crawler name or the path. The tester applies RFC 9309 (the most specific, i.e. longest, matching rule wins; on a tie Allow wins; * and $ are wildcards) and, next to it, what Python's urllib.robotparser answers.

💡 Python's parser predates RFC 9309: it applies the rules in file order (first match wins) and treats * and $ inside paths literally. On /private/press/news it says no, although the RFC says yes; on /files/report.pdf it says yes, although Disallow: /*.pdf$ means no. Checked with Python 3.14. Order your rules from specific to general, or use a parser that follows the RFC.

Politeness

Even when crawling is allowed, a crawler should avoid overwhelming a server. A basic policy: do not request many pages from the same host simultaneously, wait between requests, identify the crawler (an honest User-agent), handle HTTP errors, respect retry signals (429 Too Many Requests, Retry-After). The precise policy depends on the application.

Why this matters technically: too many requests → server overload → blocking / failures → a worse crawl. Politeness is not separate from system quality.

Try it: one frontier vs per-host queues

Twelve URLs wait in the frontier, eight of them on a.org. Each fetch takes 0.5 s. The naive scheduler gives the next URL to any free fetcher; the polite one keeps one queue per host, never has two requests open to the same host, and waits the delay between them.

4
1 s

Crawl traps

Some sites generate effectively unlimited URL spaces. A crawler needs heuristics and limits, otherwise the frontier explodes (try the calendar on example.org in the simulator with DFS and no trap limit).

  • Calendars: ?year=2026, ?year=2027, ?year=2028, …
  • Faceted navigation: ?color=red&size=m&sort=price, every combination of filters is a URL.
  • Session identifiers: ?id=123, ?id=456, … the same page under endless names.
  • Honeypot traps: links in the HTML that only bots see, not human visitors. A crawler that follows them identifies itself as a bot that ignores the page as a user sees it.

Why crawlers get blocked

  • Too many requests to the same pages or host: reduce the number of crawls for specific web pages, and slow down on 429 and 503 responses.
  • Ignoring robots.txt or following honeypot links.
  • No agreement: for large, repeated crawls the commercial solution is a contract with the site (whitelisted IPs and a permitted number of crawls per day, week or month), or its official API or data dumps.

Recap: the crawl loop

seed URLs → frontier → check URL → robots + politeness → fetch → parse → store content → extract links → normalize / deduplicate → frontier

Once we have crawled these pages, is the text the only information available to us? No: pages link to one another, and that link structure gives us another ranking signal (Part VI). First, the notebook's practical tools.

Part V

Crawling in practice

All examples use quotes.toscrape.com, a sandbox site made for practising scraping. Only crawl a site you own, have permission to crawl, or that is explicitly provided for the exercise.

Crawling with Scrapy

Scrapy is a crawling framework: you write a spider (where to start, how to parse a page, which links to follow), and Scrapy runs the frontier, the scheduler, robots.txt, the delays and the output pipelines. Items go through item pipelines (pipeline 1: our own JSON-lines writer) and feed exports (pipeline 2: a JSON file).

quotes_spider.py Python
# pip install scrapy
import json
import scrapy
from scrapy.crawler import CrawlerProcess

class JsonWriterPipeline:
    def open_spider(self):
        self.file = open("quoteresult.jl", "w", encoding="utf-8")

    def close_spider(self):
        self.file.close()

    def process_item(self, item):
        self.file.write(json.dumps(dict(item), ensure_ascii=False) + "\n")
        return item

class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/page/1/"]
    custom_settings = {
        "ITEM_PIPELINES": {"__main__.JsonWriterPipeline": 1},  # pipeline 1: our own writer
        "FEEDS": {"quoteresult.json": {"format": "json"}},    # pipeline 2: a feed export
    }

    def parse(self, response):
        for quote in response.css("div.quote"):
            yield {
                "text": quote.css("span.text::text").get(),
                "author": quote.css("small.author::text").get(),
                "tags": quote.css("div.tags a.tag::text").getall(),
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:                                  # follow the link: now it crawls
            yield response.follow(next_page, self.parse)

process = CrawlerProcess({
    "USER_AGENT": "IRCourseBot/0.1 (student lab crawler)",
    "ROBOTSTXT_OBEY": True,
    "DOWNLOAD_DELAY": 1.0,
    "CONCURRENT_REQUESTS_PER_DOMAIN": 1,
    "LOG_LEVEL": "WARNING",
})
process.crawl(QuotesSpider)
process.start()
# 10 pages followed through "Next", 100 quotes in quoteresult.jl and quoteresult.json
read_results.py Python
import pandas as pd

df = pd.read_json("quoteresult.jl", lines=True)   # one JSON object per line
df.shape
# (100, 3)
df.loc[0, "author"], df.loc[0, "tags"]
# ('Albert Einstein', ['change', 'deep-thoughts', 'thinking', 'world'])

df2 = pd.read_json("quoteresult.json")            # one JSON array
df2.shape
# (100, 3)

💡 Updated from the course notebook for current Scrapy (2.19): the FEEDS setting replaces the deprecated FEED_FORMAT / FEED_URI, and pipeline methods no longer take a spider argument. The spider now follows the "Next" link, which turns a scraper of two pages into a small crawler, and it identifies itself honestly instead of pretending to be Internet Explorer 7.

A single web page with BeautifulSoup

For one page, requests fetches the HTML and BeautifulSoup parses it. The notebook leaves getPropertyFromPage as an exercise; one possible solution:

single_page.py Python
import requests
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "IRCourseBot/0.1 (student lab crawler)"}

def getPropertyFromPage(url):
    """
    Visit the page and choose some properties to crawl from the parser
    """
    html = requests.get(url, headers=HEADERS, timeout=10).text
    document = BeautifulSoup(html, "html.parser")
    return {
        "title": document.title.get_text(strip=True),
        "quotes": [q.get_text() for q in document.select("div.quote span.text")],
        "authors": [a.get_text() for a in document.select("small.author")],
        "links": [a["href"] for a in document.find_all("a", href=True)],
    }

page = getPropertyFromPage("https://quotes.toscrape.com/page/1/")
page["title"]
# 'Quotes to Scrape'
len(page["quotes"]), page["authors"][:3]
# (10, ['Albert Einstein', 'J.K. Rowling', 'Albert Einstein'])
len(page["links"]), page["links"][:4]
# (55, ['/', '/login', '/author/Albert-Einstein', '/tag/change/page/1/'])

The links are relative: a crawler resolves them against the page URL (urljoin) and normalizes them before they enter the frontier (Part III).

The problem for all of the above: infinite scroll (dynamic content)

Many pages load their content with JavaScript as you scroll. The HTML that requests or Scrapy downloads then contains no content at all.

Solution✅ Pros❌ Cons
1. Naive, semi-automatic: open the page, scroll down as far as necessary, save it (Ctrl + S)easy; surprisingly helps with hard-to-crawl websitesa lot of manual work; not scalable
2. Fully automatic: a browser-automation tool (Selenium, Playwright) scrolls and renders the pageovercomes dynamic content; complex logic; scalesrequires deeper understanding; much slower in most cases
Extra option: find the JavaScript's data source (the browser's Network tab) and request it directlyfast, structured JSON, no renderingworks only when such an endpoint exists; it can change without notice

Not all dynamic pages are the same: some render content through JS APIs visible in the Network tab, others embed the JS-rendered content as JSON in the page. quotes.toscrape.com/scroll is an infinite-scroll page: its script calls /api/quotes?page=N.

scroll_api.py Python
import requests

r = requests.get("https://quotes.toscrape.com/api/quotes", params={"page": 1},
                 headers={"User-Agent": "IRCourseBot/0.1 (student lab crawler)"}, timeout=10)
data = r.json()
data.keys()
# dict_keys(['has_next', 'page', 'quotes', 'tag', 'top_ten_tags'])
data["has_next"], len(data["quotes"])
# (True, 10)
data["quotes"][0]["author"]["name"]
# 'Albert Einstein'

Good practices for large-scale crawlers

  • Ensure you have full access to the server the crawler runs on.
  • Identify slow websites, e.g. with the TTFB (time to first byte) metric.
  • Crawl during low-traffic (off-peak) hours.
  • Use a fast and stable connection, preferably Ethernet: Wi-Fi is slower and less reliable.
  • For big-scale crawling, run crawlers on a server (cloud compute) instead of a desktop.
  • Partial crawls: crawl a single website with several continuous crawlers, each focusing on a different section. Things stay simple and the data is updated faster.
  • Ignore URL parameters and other URLs that are duplicates of a canonical URL.
Part VI

The Web as a directed graph

Represent each page as a node and each hyperlink as a directed edge: if page A links to page B, then \(A \rightarrow B\). A small web graph: A → B, A → C, B → C, C → A, D → C, D → A. It is the graph in the playground below.

📐 In-degree
\[ \text{indegree}(p) = \text{number of pages linking to } p \]

A naive idea: a page with many incoming links may be important.

Why counting links is not enough

100 obscure pages link to X; 1 very authoritative page links to Y. Should X automatically be more important? Not necessarily. We want a recursive idea: a link from an important page should count more than a link from an unimportant page. This is the intuition behind PageRank. (Load Many weak vs one strong in the playground.)

Part VII

PageRank

PageRank can be explained with a random surfer browsing the Web. At each page: with probability \(d\), follow one outgoing link chosen at random; with probability \(1 - d\), jump to a random page. The long-run probability of being on a page is its PageRank.

📐 PageRank
\[ PR(u) = \frac{1-d}{N} + d \sum_{v \in B_u} \frac{PR(v)}{L(v)} \]

\(N\) = number of pages; \(d\) = damping factor, commonly \(d = 0.85\); \(B_u\) = pages linking to \(u\); \(L(v)\) = number of outgoing links from \(v\).

  • Incoming PageRank: a page receives score from the pages that link to it, \(\sum_{v \in B_u} PR(v) / L(v)\).
  • Outgoing-link division: if page A has PageRank 0.8 and links to four pages, it distributes 0.2 to each before damping. A page does not give its full score independently to every outgoing link.
  • Damping: the \(1 - d\) component means the surfer can jump rather than follow links forever. This helps avoid problems caused by graph structure and gives a stable stochastic interpretation.
  • Computing it: start with \(PR = 1/N\) everywhere and apply the formula to every page again and again (power iteration) until the values stop changing.

Try it: a PageRank playground

Node size shows PageRank. Click a node to see its formula with the numbers plugged in; click one node and then another to add or remove a link. Step through the power iteration, or send a random surfer and compare its visit frequencies with PageRank. The last column is the converged value, the same as nx.pagerank(G, alpha=d).

0.85
iteration 0

PageRank is query-independent

TF-IDF and BM25 compute \(score(q, d)\). PageRank computes something closer to \(importance(d)\), without knowing the user's query: BM25 is a query-dependent relevance signal, PageRank a query-independent authority / popularity signal. They measure different things. For the query python sorting algorithm, a globally important world news page should not rank highly merely because it has a high PageRank: textual relevance still matters.

The live demo with NetworkX

A small deterministic graph, not the live Web. Before running it: which node do you expect to get the largest score? Then compare the in-degree ranking with the PageRank ranking.

pagerank_demo.py Python
# pip install networkx scipy
import networkx as nx

G = nx.DiGraph()
G.add_edges_from([
    ("A", "B"),
    ("A", "C"),
    ("B", "C"),
    ("C", "A"),
    ("D", "C"),
    ("D", "A"),
])

list(G.edges())
# [('A', 'B'), ('A', 'C'), ('B', 'C'), ('C', 'A'), ('D', 'C'), ('D', 'A')]
dict(G.in_degree())
# {'A': 2, 'B': 1, 'C': 3, 'D': 0}

pr = nx.pagerank(G, alpha=0.85)
for page, score in sorted(pr.items(), key=lambda x: x[1], reverse=True):
    print(page, round(score, 4))
# C 0.3839
# A 0.3797
# B 0.1989
# D 0.0375

💡 NetworkX's pagerank uses alpha=0.85 by default (docs) and needs SciPy: with pip install networkx alone it fails with No module named 'scipy'. Are the two rankings identical? Here the order is: C, A, B, D both ways. But C has 3 in-links and A only 2, and A ends only 0.004 behind: A's one link from C carries C's whole score, while C's three links come from pages that split theirs.

Dangling nodes

A dangling node has no outgoing links: A → B → C → nothing. In the random-surfer model the surfer would get stuck at C. Practical implementations redistribute this probability mass, usually uniformly over all pages (NetworkX does; the playground shows the extra term). Load Dangling node in the playground: C = 0.4744, B = 0.3412, A = 0.1844.

Link farms and manipulation

Once links affect rankings, people have incentives to manipulate links, e.g. artificial networks of pages created to amplify authority. Load Link farm: five farm pages and a target S that link to each other push S (0.2838) above C (0.1536), although no real page links to any of them. This illustrates a broader IR principle: a ranking feature can change user and publisher behaviour once people know it matters. Link analysis is useful, but it is not an unquestionable measure of quality.

PageRank on real graphs

A tiny crawler on a controlled site: one domain, at most 50 pages, one request at a time. It stores the URL, the page title and the outgoing links, builds the directed graph and calculates PageRank.

tiny_crawler.py Python
# pip install requests beautifulsoup4 networkx scipy
import hashlib
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
from urllib.robotparser import RobotFileParser

import networkx as nx
import requests
from bs4 import BeautifulSoup

SEED = "https://quotes.toscrape.com/"
AGENT = "IRCourseBot/0.1 (student lab crawler)"
MAX_PAGES, DELAY = 50, 1.0

def normalize(base, href):
    url = urldefrag(urljoin(base, href)).url          # resolve relative, drop #fragment
    p = urlparse(url)
    return p._replace(netloc=p.netloc.lower()).geturl()

robots = RobotFileParser()
r = requests.get(urljoin(SEED, "/robots.txt"), headers={"User-Agent": AGENT}, timeout=10)
robots.parse(r.text.splitlines() if r.status_code == 200 else [])   # 404: no rules, all allowed

host = urlparse(SEED).netloc
frontier, seen = deque([SEED]), {SEED}                 # BFS frontier + URL-duplicate check
G, pages, fingerprints = nx.DiGraph(), {}, {}

while frontier and len(pages) < MAX_PAGES:
    url = frontier.popleft()
    if not robots.can_fetch(AGENT, url):
        continue
    r = requests.get(url, headers={"User-Agent": AGENT}, timeout=10)
    time.sleep(DELAY)                                  # politeness: one request at a time
    if r.status_code != 200 or "text/html" not in r.headers.get("Content-Type", ""):
        continue
    soup = BeautifulSoup(r.text, "html.parser")
    pages[url] = soup.title.get_text(strip=True) if soup.title else ""
    fp = hashlib.sha256(r.text.encode("utf-8")).hexdigest()   # exact-duplicate fingerprint
    fingerprints.setdefault(fp, []).append(url)
    G.add_node(url)
    for a in soup.find_all("a", href=True):
        link = normalize(url, a["href"])
        if urlparse(link).netloc != host:              # stay on one domain
            continue
        G.add_edge(url, link)
        if link not in seen:
            seen.add(link)
            frontier.append(link)

G = G.subgraph(pages)                                  # keep only fetched pages
print(len(pages), "pages,", G.number_of_edges(), "links,", len(frontier), "URLs still in the frontier")
dupes = [urls for urls in fingerprints.values() if len(urls) > 1]
print(len(dupes), "groups of identical pages, e.g.", [u.replace(SEED, "/") for u in dupes[0]])

pr = nx.pagerank(G, alpha=0.85)
indeg = dict(G.in_degree())
for url, score in sorted(pr.items(), key=lambda x: -x[1])[:8]:
    print(f"{score:.4f}  in={indeg[url]:>2}  {url.replace(SEED, '/')}")
# 50 pages, 785 links, 99 URLs still in the frontier
# 7 groups of identical pages, e.g. ['/tag/inspirational/page/1/', '/tag/inspirational/']
# 0.1316  in=50  /
# 0.1316  in=50  /login
# 0.0343  in=40  /tag/truth/
# 0.0343  in=40  /tag/life/
# ...                                   (the ten "Top Ten tags" pages tie at 0.0343)

The winners are / and /login: every page's header links to them. Inside one site, PageRank mostly measures the navigation template, not content quality; the ten sidebar tag pages tie for the same reason. The run took about 75 seconds, almost all of it the politeness delay.

A pre-existing web graph, without live crawling: the Stanford web graph from SNAP, pages of stanford.edu (2002) as nodes and hyperlinks as directed edges: 281,903 nodes, 2,312,497 edges (an 8.9 MB file). PageRank on the whole graph takes a few seconds; do not try to visualize the whole graph.

stanford_pagerank.py Python
# download web-Stanford.txt.gz from https://snap.stanford.edu/data/web-Stanford.html
import networkx as nx

G = nx.read_edgelist("web-Stanford.txt.gz", create_using=nx.DiGraph, nodetype=int, comments="#")
print(G.number_of_nodes(), G.number_of_edges())
# 281903 2312497

pr = nx.pagerank(G, alpha=0.85)
indeg = dict(G.in_degree())
by_indeg = sorted(indeg, key=indeg.get, reverse=True)
indeg_rank = {n: r for r, n in enumerate(by_indeg, start=1)}

for node, score in sorted(pr.items(), key=lambda x: -x[1])[:10]:
    print(f"node {node:>6}  PageRank {score:.5f}  in-degree {indeg[node]:>6} (rank {indeg_rank[node]})")
PageRank rankNodePageRankIn-degreeIn-degree rank
1890730.0100115,27712
22264110.0096838,6061
32414540.0089019,3774
41348320.0058910,33615
5693580.0034013,93613
6677560.0032113,87214
71056070.0029319,4573
82258720.002876,67130
92347040.0028721,9202
101867500.002856,69329

On a real graph the two rankings clearly differ: the top PageRank page is only 12th by in-degree, and the 2nd page by in-degree (21,920 in-links) is 9th by PageRank. Which pages link matters, not only how many.

Exercises

For the graph A → B, A → C, B → C, C → A, D → C, D → A (Outline graph in the playground). Answer first, then verify in the playground or with nx.pagerank(G, alpha=0.85).

3Which node has the highest in-degree?

C, with 3 (from A, B and D). A has 2, B 1, D 0.

4Does the highest in-degree guarantee the highest PageRank?

No. Here C does win, but only by 0.3839 to 0.3797 over A, which has one link fewer. In Many weak vs one strong, X has 4 in-links and PageRank 0.0550, while Y has a single in-link, from the authority H, and 0.3893. In the Stanford graph the top PageRank page is 12th by in-degree.

5What happens if C → A is removed?

A loses its strongest in-link and falls from 0.3797 to 0.1919 (now 3rd, behind B with 0.2162). C rises to 0.4572. Less obviously, D rises from 0.0375 to 0.1347 with no new in-link: C now has no outgoing links, it became a dangling node, and its mass is spread over all pages.

6What happens if a high-PageRank page adds a link to D?

Add A → D: D jumps from 0.0375 to 0.1451. A's score is now split three ways instead of two, so B falls from 0.1989 to 0.1451 and C from 0.3839 to 0.3301; A itself stays at 0.3797. A link is a vote, and a page's votes are shared among its links.

7Why does the damping factor matter?

\(d\) sets how much of the score flows along links and how much is spread evenly. At \(d = 0\) every page gets \(1/N = 0.25\). At \(d = 0.5\): C 0.3413, A 0.3269, B 0.2067, D 0.1250. At \(d = 0.99\), D (no in-links) keeps only \((1 - d)/N = 0.0025\), and C and A are almost tied (0.3989, 0.3987). Without damping the surfer can be trapped in parts of the graph with no way out and the iteration may not converge; the closer \(d\) is to 1, the more iterations it needs.

Part VIII

Combining textual and link signals

A conceptual search score might combine a query-dependent text score (e.g. BM25) with a query-independent link score (e.g. PageRank):

\[ Score(q, d) = \alpha \cdot TextScore(q, d) + \beta \cdot LinkScore(d) \]

This formula is only illustrative: real search systems combine many signals with learned ranking models.

Try it: move the weight

Query python sorting algorithm over five pages with invented BM25 and PageRank values. The two signals live on very different scales, so each is first divided by its maximum. Then \(\alpha = 1 - w\), \(\beta = w\).

0.20

Document A has a high BM25 and a low PageRank, document B a medium BM25 and a high PageRank. Which should rank first? There is no universal answer: it depends on the query, the task, the training data and the evaluation metric. This connects directly back to Lab 05: choose \(w\) by evaluating the rankings.

Part IX

Connection to modern retrieval and RAG

Crawling is still relevant when the final application is a RAG system: a RAG system needs a knowledge collection, which may come from web pages, documentation, a company intranet, PDFs, APIs or databases. Before retrieval we still need an ingestion pipeline:

discover → fetch → parse → deduplicate → clean → chunk → index

The technology changes, but many crawler problems remain: duplicate content, stale pages, broken links, canonicalization, refresh scheduling, access restrictions. Crawling is not merely historical web-search material; it is part of modern retrieval infrastructure.

Summary: key takeaways

Web crawling: seed URLs → frontier → robots / politeness → fetch → parse → normalize + deduplicate → store + index
Web ranking: query → textual relevance (TF-IDF / BM25) + link structure (PageRank) → ranked results
  1. Search cannot index pages it has not discovered.
  2. A crawler requires scheduling, normalization, duplicate handling and politeness, not just HTTP requests.
  3. Hyperlinks turn the Web into a directed graph.
  4. PageRank recursively values links from already-important pages.
  5. PageRank measures a query-independent graph signal; it does not replace textual relevance.

Optional exercises

  • Option A: a tiny crawler on a controlled site. One domain, at most 50 pages, one request at a time; store the URL, the page title and the outgoing links; build a directed graph and calculate PageRank. tiny_crawler.py is one solution: extend it, e.g. skip the content duplicates it finds, or compare BFS with DFS.
  • Option B: a pre-existing web graph. Use the SNAP Stanford web graph. For visualization, take a sampled subgraph.