image: teaser.webp:0:50 image_alt: "Eine Seite mit Suchergebnissen: im Suchfeld »katze«, darunter drei Treffer mit Titel, Adresse und dem Anfang ihres Textes, oben die Hauskatze mit 3 Links auf sie, dann der Hund mit 2, zuletzt orange die Anzeige »Katze Katze Katzenfutter« mit 1 Link, deren Text nur aus dem Wort Katze besteht." kind: projekt language: Ruby / Python languages: [ruby, python] duration: 2 Doppelstunden curriculum: "Sek I: Leben in und mit vernetzten Systemen (Suchmaschinen, Beschaffung und Prüfung von Informationen), Algorithmisches Problemlösen (Implementieren von Algorithmen); Oberstufe: Algorithmen und Datenstrukturen (Breitensuche, ein invertierter Index), Vertiefung Informatik und Gesellschaft (wer bestimmt, was gefunden wird)" topics: [suchmaschinen, graphen, gesellschaft] age: Sek I requires: [kata-dicts, kata-lists] reference: [graphen, listen, regex, http]

Eine Suchmaschine

Wie findet eine Suchmaschine unter Milliarden Seiten die zehn, die sie dir zeigt? Du baust eine für eine kleine Website: einen Crawler, der den Links folgt, einen Index, der zu jedem Wort die Seiten kennt, und eine Rangfolge der Treffer. Dabei siehst du, wer bestimmt, was oben steht, und wie man das ausnutzt.

1. Drei Teile

Eine Suchmaschine liest nicht erst dann im Web nach, wenn du etwas eintippst. Das würde viel zu lange dauern. Sie hat vorher drei Schritte gemacht, und jeden davon baust du hier selbst:

  • Ein Crawler hat das Web abgesucht: Er beginnt bei einer Seite, folgt ihren Links, dann den Links der nächsten Seiten, und hebt jede Seite auf.
  • Ein Index sagt zu jedem Wort, auf welchen Seiten es steht, so wie das Stichwortverzeichnis hinten in einem Buch.
  • Ein Ranking ordnet die Treffer: Was oben steht, wird gelesen.

Deine Suchmaschine durchsucht das »Kleine Tierlexikon«, eine Website mit elf Seiten.

2. Einrichten

Lege einen Ordner an, lade das Tierlexikon herunter und lege zwei leere Dateien für deine Suchmaschine an:

mkdir -p ~/suchmaschine
cd ~/suchmaschine
wget -qO- https://workspace.hackschule.de/suchmaschine/tierlexikon.tar.gz | tar xz
touch search.rb main.rb

In search.rb kommen die Funktionen deiner Suchmaschine, in main.rb das Programm, das sie benutzt.

mkdir -p ~/suchmaschine
cd ~/suchmaschine
wget -qO- https://workspace.hackschule.de/suchmaschine/tierlexikon.tar.gz | tar xz
touch search.py main.py

In search.py kommen die Funktionen deiner Suchmaschine, in main.py das Programm, das sie benutzt.

Das Tierlexikon braucht einen Webserver. Öffne mit StrgShift5 ein zweites Terminal und starte darin Pythons eingebauten Webserver; er liefert die Dateien eines Ordners aus:

cd ~/suchmaschine
python3 -m http.server 8000 --directory tierlexikon

Öffne die »Forwarded Address« für Port 8000 und schau dich um: eine Startseite mit sieben Tieren, auf jeder Seite ein paar Links zu anderen.

3. Der Crawler

Der Crawler holt Seiten über HTTP, so wie dein Browser, und sucht darin nach Links. Beides gibt die Seite vor.

Schreib in search.rb:

require 'net/http'

# Wo die Website läuft, die die Suchmaschine durchsucht
SITE = 'http://localhost:8000'

# Eine Seite der Website als String, oder nil, wenn es sie nicht gibt
def fetch_page(path)
  response = Net::HTTP.get_response(URI(SITE + path))
  response.is_a?(Net::HTTPSuccess) ? response.body.force_encoding('UTF-8') : nil
end

# Die Ziele der Links einer Seite, die auf derselben Website bleiben
def links(html)
  html.scan(/<a href="(\/[^"#]*)"/).flatten
end
  • fetch_page('/igel.html') holt die Seite vom Webserver. Antwortet er mit einem Fehler wie 404, gibt sie nil zurück.
  • links findet mit einem regulären Ausdruck jedes <a href="/…">. Nur Ziele, die mit / beginnen, bleiben auf der Website; ein Link zur Wikipedia beginnt mit https: und zählt nicht.

Schreib in search.py:

import re
import urllib.error
import urllib.request

# Wo die Website läuft, die die Suchmaschine durchsucht
SITE = 'http://localhost:8000'


def fetch_page(path):
    """Eine Seite der Website als String, oder None, wenn es sie nicht gibt"""
    try:
        with urllib.request.urlopen(SITE + path) as response:
            return response.read().decode()
    except urllib.error.HTTPError:
        return None


def links(html):
    """Die Ziele der Links einer Seite, die auf derselben Website bleiben"""
    return re.findall(r'<a href="(/[^"#]*)"', html)
  • fetch_page('/igel.html') holt die Seite vom Webserver. Antwortet er mit einem Fehler wie 404, gibt sie None zurück.
  • links findet mit einem regulären Ausdruck jedes <a href="/…">. Nur Ziele, die mit / beginnen, bleiben auf der Website; ein Link zur Wikipedia beginnt mit https: und zählt nicht.

Von Link zu Link

Der Crawler beginnt bei /index.html. Von dort führen Links zu den Tieren, von den Tieren zu anderen Tieren und zurück. Er geht so vor:

  1. In einer Queue stehen die Pfade, die er noch holen muss, am Anfang nur /index.html. In einer zweiten Liste steht alles, was er schon einmal in die Queue gestellt hat.
  2. Er nimmt den ersten Pfad aus der Queue und holt die Seite. Gibt es sie nicht, geht es mit dem nächsten Pfad weiter.
  3. Er hebt die Seite auf und stellt jedes Linkziel hinten in die Queue, das er noch nie gesehen hat.
  4. Das wiederholt er, bis die Queue leer ist.

Ohne die zweite Liste liefe er im Kreis: Von der Katze geht es zum Hund und vom Hund zurück zur Katze. Dieses Vorgehen heißt Breitensuche: zuerst alle Seiten, die einen Link entfernt sind, dann alle, die zwei Links entfernt sind.

Im Ordner tierlexikon liegt auch eine Datei geheim.html.

Vermutung 1Eine Seite ohne Link

Im Ordner tierlexikon liegt die Datei geheim.html, und der Webserver liefert sie aus. Findet ein Crawler, der bei /index.html anfängt und den Links folgt, diese Seite?

Eine Seite ohne Link
Jetzt bist du dran: Schreib die Funktion crawl(start, fetch). Sie gibt einen Hash zurück, der jeden gefundenen Pfad auf das HTML seiner Seite abbildet. Die Seiten holt sie nicht selbst mit fetch_page, sondern mit fetch.call(path): Dein Programm gibt ihr nachher method(:fetch_page), die Prüfung eine kleine ausgedachte Website.
Tipp

Du brauchst drei Variablen: den Hash pages für das Ergebnis, die Queue als Array mit start darin und ein Array seen, ebenfalls mit start. shift nimmt das erste Element aus einem Array, << hängt hinten eins an.

Nächster Tipp

In einer Schleife, solange die Queue nicht leer ist (until queue.empty?): den ersten Pfad nehmen, die Seite holen, bei nil mit next weitermachen, sonst die Seite in pages eintragen und jedes Linkziel aus links(html), das nicht in seen steht, in seen und in die Queue stellen.

Nächster Tipp
def crawl(start, fetch)
  pages = {}
  queue = [start]
  seen = [start]
  until queue.empty?
    path = queue.shift
    html = fetch.call(path)
    next if html.nil?

    pages[path] = html
    # hier: jedes neue Linkziel aus links(html) in seen und in die Queue
  end
  pages
end
Lösung anzeigen
# Alle Seiten, die von start aus über Links erreichbar sind, als Hash von
# Pfad zu HTML; fetch holt eine Seite
def crawl(start, fetch)
  pages = {}
  queue = [start]
  seen = [start]
  until queue.empty?
    path = queue.shift
    html = fetch.call(path)
    next if html.nil?

    pages[path] = html
    links(html).each do |link|
      next if seen.include?(link)

      seen << link
      queue << link
    end
  end
  pages
end
Jetzt bist du dran: Schreib die Funktion crawl(start, fetch). Sie gibt ein dict zurück, das jeden gefundenen Pfad auf das HTML seiner Seite abbildet. Die Seiten holt sie nicht selbst mit fetch_page, sondern mit fetch(path): Dein Programm gibt ihr nachher die Funktion fetch_page, die Prüfung eine kleine ausgedachte Website.
Tipp

Du brauchst drei Variablen: das dict pages für das Ergebnis, die Queue als Liste mit start darin und eine Liste seen, ebenfalls mit start. pop(0) nimmt das erste Element aus einer Liste, append hängt hinten eins an.

Nächster Tipp

In einer Schleife, solange die Queue nicht leer ist (while queue:): den ersten Pfad nehmen, die Seite holen, bei None mit continue weitermachen, sonst die Seite in pages eintragen und jedes Linkziel aus links(html), das nicht in seen steht, in seen und in die Queue stellen.

Nächster Tipp
def crawl(start, fetch):
    pages = {}
    queue = [start]
    seen = [start]
    while queue:
        path = queue.pop(0)
        html = fetch(path)
        if html is None:
            continue
        pages[path] = html
        # hier: jedes neue Linkziel aus links(html) in seen und in die Queue
    return pages
Lösung anzeigen
def crawl(start, fetch):
    """Alle Seiten, die von start aus über Links erreichbar sind, als dict von
    Pfad zu HTML; fetch holt eine Seite"""
    pages = {}
    queue = [start]
    seen = [start]
    while queue:
        path = queue.pop(0)
        html = fetch(path)
        if html is None:
            continue
        pages[path] = html
        for link in links(html):
            if link not in seen:
                seen.append(link)
                queue.append(link)
    return pages
Aufgabe 2Der Crawler

Schreib in suchmaschine/search.rb die Funktion crawl(start, fetch). Die Prüfung gibt ihr statt fetch_page eine kleine ausgedachte Website mit vier Seiten, /a.html bis /d.html, auf der /d.html niemand verlinkt und ein Link auf /fehlt.html zeigt, die es nicht gibt. Sie fragt, welche Pfade crawl("/a.html", …) findet, was unter /c.html steht und wie oft crawl eine Seite holt.

Tipp

fetch.call(path) holt eine Seite mit der Funktion, die crawl bekommen hat.

Aufgabe 2Der Crawler

Schreib in suchmaschine/search.py die Funktion crawl(start, fetch). Die Prüfung gibt ihr statt fetch_page eine kleine ausgedachte Website mit vier Seiten, /a.html bis /d.html, auf der /d.html niemand verlinkt und ein Link auf /fehlt.html zeigt, die es nicht gibt. Sie fragt, welche Pfade crawl('/a.html', …) findet, was unter /c.html steht und wie oft crawl eine Seite holt.

Tipp

fetch(path) holt eine Seite mit der Funktion, die crawl bekommen hat.

Jetzt lässt du ihn auf das Tierlexikon los. Schreib in main.rb:

require_relative 'search'

pages = crawl('/index.html', method(:fetch_page))
puts "#{pages.size} Seiten gefunden: #{pages.keys.join(', ')}"

method(:fetch_page) ist die Funktion selbst, ohne sie aufzurufen; crawl ruft sie dann für jeden Pfad mit call auf. Starte das Programm, während der Webserver im zweiten Terminal läuft:

ruby main.rb

Jetzt lässt du ihn auf das Tierlexikon los. Schreib in main.py:

from search import *

pages = crawl('/index.html', fetch_page)
print(f'{len(pages)} Seiten gefunden:', ', '.join(pages))

fetch_page ohne Klammern ist die Funktion selbst, ohne sie aufzurufen; crawl ruft sie dann für jeden Pfad auf. Starte das Programm, während der Webserver im zweiten Terminal läuft:

python3 main.py
Aufgabe 3Wie viele Seiten?

Wie viele Seiten findet dein Crawler im Tierlexikon, wenn er bei /index.html anfängt?

Tipp

Starte main im ersten Terminal, während der Webserver im zweiten läuft.

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

geheim.html liegt auf dem Server, aber keine Seite verlinkt sie. Ein Crawler findet nur, wohin ein Link führt. Was nirgends verlinkt ist, fehlt in jeder Suchmaschine, auch wenn jeder es mit der genauen Adresse abrufen kann.

Ausprobieren: Lass den Crawler bei /pinguin.html statt bei /index.html anfangen. Achte darauf, in welcher Reihenfolge er die Seiten findet und welche zuerst kommt.

4. Der Index

Um ein Wort zu suchen, könnte die Suchmaschine jedes Mal alle Seiten durchlesen. Bei Milliarden Seiten geht das nicht. Darum schreibt sie einmal auf, wo jedes Wort steht. Für zwei kleine Seiten sieht das so aus:

pages = { "/a.html" => "<p>Der Igel schläft.</p>", "/b.html" => "<p>Der Hund schläft nicht.</p>" }
build_index(pages)
# {
#   "der" => ["/a.html", "/b.html"],
#   "igel" => ["/a.html"],
#   "schläft" => ["/a.html", "/b.html"],
#   "hund" => ["/b.html"],
#   "nicht" => ["/b.html"]
# }
pages = {'/a.html': '<p>Der Igel schläft.</p>', '/b.html': '<p>Der Hund schläft nicht.</p>'}
build_index(pages)
# {
#     'der': ['/a.html', '/b.html'],
#     'igel': ['/a.html'],
#     'schläft': ['/a.html', '/b.html'],
#     'hund': ['/b.html'],
#     'nicht': ['/b.html'],
# }

Andersherum als eine Seite, die zu jedem Pfad die Wörter kennt, kennt der Index zu jedem Wort die Pfade. Darum heißt er invertierter Index. Die Tags gehören nicht zu den Wörtern, und groß oder klein geschrieben ist egal: Wer »Igel« sucht, findet auch »igel«.

Diese Funktion zerlegt eine Seite in ihre Wörter; schreib sie unter links:

# Die Wörter einer Seite, klein geschrieben, ohne die Tags
def words(html)
  html.gsub(/<[^>]*>/, ' ').downcase.scan(/\p{L}+/)
end

gsub ersetzt jedes Tag, alles von < bis >, durch ein Leerzeichen, downcase macht alles klein, und scan(/\p{L}+/) findet jedes Wort aus Buchstaben, auch mit Umlauten.

Diese Funktion zerlegt eine Seite in ihre Wörter; schreib sie unter links:

def words(html):
    """Die Wörter einer Seite, klein geschrieben, ohne die Tags"""
    return re.findall(r'[^\W\d_]+', re.sub(r'<[^>]*>', ' ', html).lower())

re.sub ersetzt jedes Tag, alles von < bis >, durch ein Leerzeichen, lower macht alles klein, und [^\W\d_]+ findet jedes Wort aus Buchstaben, auch mit Umlauten.

Jetzt bist du dran: Schreib die Funktion build_index(pages). Sie bekommt den Hash, den crawl liefert, und gibt den Index zurück: zu jedem Wort die Pfade der Seiten, auf denen es steht, in der Reihenfolge der Seiten und jeden Pfad nur einmal, auch wenn das Wort auf einer Seite öfter vorkommt.
Tipp

Zwei Schleifen ineinander: außen über die Seiten mit pages.each do |path, html|, innen über ihre Wörter. uniq macht aus words(html) eine Liste, in der jedes Wort nur einmal steht.

Nächster Tipp
def build_index(pages)
  index = {}
  pages.each do |path, html|
    words(html).uniq.each do |word|
      # hier: die Liste für word anlegen, wenn sie fehlt, und path anhängen
    end
  end
  index
end
Lösung anzeigen
# Für jedes Wort die Seiten, auf denen es steht
def build_index(pages)
  index = {}
  pages.each do |path, html|
    words(html).uniq.each do |word|
      index[word] ||= []
      index[word] << path
    end
  end
  index
end
Jetzt bist du dran: Schreib die Funktion build_index(pages). Sie bekommt das dict, das crawl liefert, und gibt den Index zurück: zu jedem Wort die Pfade der Seiten, auf denen es steht, in der Reihenfolge der Seiten und jeden Pfad nur einmal, auch wenn das Wort auf einer Seite öfter vorkommt.
Tipp

Zwei Schleifen ineinander: außen über die Seiten mit for path, html in pages.items():, innen über ihre Wörter. dict.fromkeys(words(html)) behält jedes Wort nur einmal, in seiner Reihenfolge.

Nächster Tipp
def build_index(pages):
    index = {}
    for path, html in pages.items():
        for word in dict.fromkeys(words(html)):
            # hier: die Liste für word anlegen, wenn sie fehlt, und path anhängen
    return index
Lösung anzeigen
def build_index(pages):
    """Für jedes Wort die Seiten, auf denen es steht"""
    index = {}
    for path, html in pages.items():
        for word in dict.fromkeys(words(html)):
            if word not in index:
                index[word] = []
            index[word].append(path)
    return index
Aufgabe 4Der Index

Schreib in suchmaschine/search.rb die Funktion build_index(pages). Die Prüfung ruft sie mit zwei kleinen Seiten über eine Katze und einen Hund auf, mit einer Seite, auf der dasselbe Wort dreimal steht, und mit einem leeren Hash, und vergleicht den Index, der herauskommt.

Aufgabe 4Der Index

Schreib in suchmaschine/search.py die Funktion build_index(pages). Die Prüfung ruft sie mit zwei kleinen Seiten über eine Katze und einen Hund auf, mit einer Seite, auf der dasselbe Wort dreimal steht, und mit einem leeren dict, und vergleicht den Index, der herauskommt.

Suchen

Eine Suche nach »nachts insekten« soll die Seiten finden, auf denen beide Wörter stehen. Im Index stehen zu jedem Wort seine Seiten; gesucht sind die Seiten, die in beiden Listen vorkommen, ihre Schnittmenge.

Jetzt bist du dran: Schreib die Funktion search(index, query). Sie zerlegt die Anfrage mit words in Wörter und gibt die Pfade der Seiten zurück, auf denen jedes Wort steht, nach dem Alphabet sortiert. Steht ein Wort auf keiner Seite, oder ist die Anfrage leer, gibt sie eine leere Liste zurück.
Tipp

Fang mit der Liste des ersten Wortes an. Für jedes weitere Wort behältst du nur die Pfade, die auch in dessen Liste stehen: a & b ist in Ruby die Schnittmenge zweier Arrays. Ein Wort, das nicht im Index steht, hat die leere Liste [].

Nächster Tipp
def search(index, query)
  results = nil
  words(query).each do |word|
    found = index[word] || []
    # hier: beim ersten Wort results = found, danach die Schnittmenge
  end
  (results || []).sort
end
Lösung anzeigen
# Die Seiten, auf denen jedes Wort der Anfrage steht, nach dem Alphabet
def search(index, query)
  results = nil
  words(query).each do |word|
    found = index[word] || []
    results = results.nil? ? found : results & found
  end
  (results || []).sort
end
Jetzt bist du dran: Schreib die Funktion search(index, query). Sie zerlegt die Anfrage mit words in Wörter und gibt die Pfade der Seiten zurück, auf denen jedes Wort steht, nach dem Alphabet sortiert. Steht ein Wort auf keiner Seite, oder ist die Anfrage leer, gibt sie eine leere Liste zurück.
Tipp

Fang mit der Liste des ersten Wortes an. Für jedes weitere Wort behältst du nur die Pfade, die auch in dessen Liste stehen. Ein Wort, das nicht im Index steht, hat die leere Liste: index.get(word, []).

Nächster Tipp
def search(index, query):
    results = None
    for word in words(query):
        found = index.get(word, [])
        # hier: beim ersten Wort results = found, danach nur die Pfade, die auch in found stehen
    return sorted(results or [])
Lösung anzeigen
def search(index, query):
    """Die Seiten, auf denen jedes Wort der Anfrage steht, nach dem Alphabet"""
    results = None
    for word in words(query):
        found = index.get(word, [])
        results = found if results is None else [path for path in results if path in found]
    return sorted(results or [])
Aufgabe 5Suchen

Schreib in suchmaschine/search.rb die Funktion search(index, query). Die Prüfung gibt ihr einen kleinen Index mit den Wörtern »katze«, »hund« und »maus« und sucht nach »katze«, nach »Katze Hund«, nach »katze vogel« und nach einer leeren Anfrage.

Aufgabe 5Suchen

Schreib in suchmaschine/search.py die Funktion search(index, query). Die Prüfung gibt ihr einen kleinen Index mit den Wörtern »katze«, »hund« und »maus« und sucht nach »katze«, nach »Katze Hund«, nach »katze vogel« und nach einer leeren Anfrage.

Jetzt fragt main.rb nach Suchwörtern, bis du eine leere Zeile eingibst. Hänge unten an:

puts "#{pages.size} Seiten gefunden: #{pages.keys.join(', ')}"index = build_index(pages)loop do  print 'Suche: '  query = gets  break if query.nil? || query.strip.empty?  results = search(index, query)  puts '  Nichts gefunden.' if results.empty?  results.each { |path| puts "  #{path}" }end

Jetzt fragt main.py nach Suchwörtern, bis du eine leere Zeile eingibst. Hänge unten an:

print(f'{len(pages)} Seiten gefunden:', ', '.join(pages))index = build_index(pages)while True:    query = input('Suche: ')    if not query.strip():        break    results = search(index, query)    if not results:        print('  Nichts gefunden.')    for path in results:        print(' ', path)
Aufgabe 6Nachts und Insekten

Auf wie vielen Seiten des Tierlexikons stehen die Wörter »nachts« und »Insekten« beide? Such mit deinem Programm nach nachts insekten.

5. Wer steht oben?

Eine echte Suchmaschine findet zu fast jedem Wort Tausende Seiten, und kaum jemand schaut über die ersten zehn hinaus. Welche oben stehen, entscheidet das Ranking. Eine naheliegende Idee: Je öfter das Suchwort auf einer Seite steht, desto mehr geht es dort darum.

Schreib diese Funktion unter search:

# Die Treffer, die Seite mit dem Wort am häufigsten zuerst
def rank_by_count(results, pages, word)
  results.sort_by { |path| [-words(pages[path]).count(word), path] }
end

sort_by sortiert nach dem, was der Block liefert: zuerst nach der Anzahl, mit Minus davor, damit die größte vorn steht, bei gleicher Anzahl nach dem Pfad. In main.rb ordnet die Suche ihre Treffer jetzt nach dem ersten Wort der Anfrage:

  break if query.nil? || query.strip.empty?  results = rank_by_count(search(index, query), pages, words(query).first)  puts '  Nichts gefunden.' if results.empty?

Schreib diese Funktion unter search:

def rank_by_count(results, pages, word):
    """Die Treffer, die Seite mit dem Wort am häufigsten zuerst"""
    return sorted(results, key=lambda path: (-words(pages[path]).count(word), path))

sorted sortiert nach dem, was key liefert: zuerst nach der Anzahl, mit Minus davor, damit die größte vorn steht, bei gleicher Anzahl nach dem Pfad. In main.py ordnet die Suche ihre Treffer jetzt nach dem ersten Wort der Anfrage:

        break    results = rank_by_count(search(index, query), pages, words(query)[0])    if not results:
Vermutung 7Wer steht bei »katze« oben?

Du suchst nach katze, und rank_by_count ordnet die Treffer danach, wie oft das Wort auf der Seite steht. Welche Seite steht oben?

Wer steht bei »katze« oben?

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Ganz oben steht katzenfutter-billig.html, eine Anzeige, die kaum etwas anderes enthält als »Katze«, achtzehnmal. Die Seite über die Hauskatze kommt erst danach. Wörter so oft zu wiederholen, bis eine Suchmaschine anbeißt, heißt Keyword-Stuffing. Frühe Suchmaschinen der Neunzigerjahre ordneten ungefähr so wie deine, und Seiten dieser Art standen bei ihnen oft oben.

Links als Stimmen

Ein Link ist eine Empfehlung: Wer auf eine Seite verlinkt, hält sie für lesenswert. Auf die Seite über die Hauskatze verlinken die Startseite, der Hund und die Amsel. Auf die Anzeige verlinkt nur der Hund, und sich selbst zu verlinken, zählt nicht.

Jetzt bist du dran: Schreib die Funktion incoming(pages). Sie gibt einen Hash zurück, der für jede Seite zählt, wie viele andere Seiten auf sie verlinken. Ein Link einer Seite auf sich selbst zählt nicht, mehrere Links derselben Seite auf dasselbe Ziel zählen einmal, und Ziele, die nicht in pages stehen, kommen nicht vor. Seiten, auf die keine verlinkt, fehlen im Hash.
Tipp

Geh die Seiten durch und für jede ihre Linkziele aus links(html), mit uniq jedes nur einmal. Ein Ziel zählst du, wenn es nicht die Seite selbst ist und in pages steht (pages.key?(link)).

Nächster Tipp
def incoming(pages)
  counts = {}
  pages.each do |path, html|
    links(html).uniq.each do |link|
      # hier: Selbstlinks und Ziele außerhalb von pages auslassen, die anderen zählen
    end
  end
  counts
end
Lösung anzeigen
# Für jede Seite, wie viele andere Seiten auf sie verlinken
def incoming(pages)
  counts = {}
  pages.each do |path, html|
    links(html).uniq.each do |link|
      next if link == path || !pages.key?(link)

      counts[link] = (counts[link] || 0) + 1
    end
  end
  counts
end
Jetzt bist du dran: Schreib die Funktion incoming(pages). Sie gibt ein dict zurück, das für jede Seite zählt, wie viele andere Seiten auf sie verlinken. Ein Link einer Seite auf sich selbst zählt nicht, mehrere Links derselben Seite auf dasselbe Ziel zählen einmal, und Ziele, die nicht in pages stehen, kommen nicht vor. Seiten, auf die keine verlinkt, fehlen im dict.
Tipp

Geh die Seiten durch und für jede ihre Linkziele aus links(html), mit dict.fromkeys jedes nur einmal. Ein Ziel zählst du, wenn es nicht die Seite selbst ist und in pages steht.

Nächster Tipp
def incoming(pages):
    counts = {}
    for path, html in pages.items():
        for link in dict.fromkeys(links(html)):
            # hier: Selbstlinks und Ziele außerhalb von pages auslassen, die anderen zählen
    return counts
Lösung anzeigen
def incoming(pages):
    """Für jede Seite, wie viele andere Seiten auf sie verlinken"""
    counts = {}
    for path, html in pages.items():
        for link in dict.fromkeys(links(html)):
            if link != path and link in pages:
                counts[link] = counts.get(link, 0) + 1
    return counts
Aufgabe 8Eingehende Links

Schreib in suchmaschine/search.rb die Funktion incoming(pages). Die Prüfung ruft sie mit kleinen ausgedachten Websites auf: mit einem Link auf sich selbst, mit zwei Links auf dasselbe Ziel, mit einem Link auf eine Seite, die nicht in pages steht, und mit zwei Seiten, die auf eine dritte verlinken.

Aufgabe 8Eingehende Links

Schreib in suchmaschine/search.py die Funktion incoming(pages). Die Prüfung ruft sie mit kleinen ausgedachten Websites auf: mit einem Link auf sich selbst, mit zwei Links auf dasselbe Ziel, mit einem Link auf eine Seite, die nicht in pages steht, und mit zwei Seiten, die auf eine dritte verlinken.

Unter incoming kommt die Funktion, die danach ordnet:

# Die Treffer, die Seite mit den meisten eingehenden Links zuerst
def rank_by_links(results, counts)
  results.sort_by { |path| [-(counts[path] || 0), path] }
end

und main.rb zählt die Links einmal nach dem Index und ordnet damit:

index = build_index(pages)counts = incoming(pages)loop do  …  results = rank_by_links(search(index, query), counts)  puts '  Nichts gefunden.' if results.empty?

Unter incoming kommt die Funktion, die danach ordnet:

def rank_by_links(results, counts):
    """Die Treffer, die Seite mit den meisten eingehenden Links zuerst"""
    return sorted(results, key=lambda path: (-counts.get(path, 0), path))

und main.py zählt die Links einmal nach dem Index und ordnet damit:

index = build_index(pages)counts = incoming(pages)while True:    …    results = rank_by_links(search(index, query), counts)    if not results:

Such noch einmal nach »katze«: Jetzt steht die Hauskatze oben, die Anzeige unten.

PageRank

Genau diese Idee hatten 1998 Sergey Brin und Larry Page, zwei Doktoranden in Stanford, und gründeten damit Google. Ihr Verfahren heißt PageRank und geht einen Schritt weiter als deins: Ein Link zählt umso mehr, je wichtiger die Seite ist, von der er kommt, und wichtig ist eine Seite, auf die wichtige Seiten verlinken. Ein Link von einer großen Zeitung wiegt mehr als einer von einer Seite, die niemand kennt.

The Anatomy of a Large-Scale Hypertextual Web Search Engine, Sergey Brin und Lawrence Page, Computer Networks and ISDN Systems 30, 1998. Der Aufsatz, in dem Google mit Crawler, invertiertem Index und PageRank beschrieben wird: PDF, auch bei Elsevier.

Aufgabe 10Fünfzig neue Seiten

Der Händler des Katzenfutters legt fünfzig neue Seiten an, die alle auf seine Anzeige verlinken und auf die sonst niemand verlinkt. Dein Crawler findet sie. Was geschieht bei der Suche nach »katze«, und was ändert PageRank daran?

Fünfzig neue Seiten

6. Geschrieben für die Suchmaschine

Die Anzeige für Katzenfutter hat dein erstes Ranking mit einem einfachen Trick gewonnen: Sie wiederholt das Wort, nach dem gesucht wird. Weil oben stehen Geld bringt, lebt eine ganze Branche davon, Seiten nach oben zu bringen: die Suchmaschinenoptimierung, englisch Search Engine Optimization (SEO). Seit es Suchmaschinen gibt, geht es hin und her: Die Optimierer finden einen Trick, die Suchmaschine lernt, ihn zu erkennen, und die Optimierer suchen den nächsten.

  • Wörter: Ende der 1990er Jahre füllten Seiten sich mit Suchwörtern, oft in weißer Schrift auf weißem Grund, unsichtbar für Menschen, aber nicht für den Crawler.
  • Links: Als Links zählten, kauften und tauschten Seiten Links und bauten Linkfarmen. 2012 stufte Google mit einer Änderung namens Penguin Seiten herab, die das taten.
  • Masse: Firmen ließen billig Texte zu allem schreiben, wonach oft gesucht wird, sogenannte Inhaltsfarmen; 2011 stufte Google sie mit einer Änderung namens Panda herab. Heute schreiben Sprachmodelle solche Texte in Sekunden, viel größere als das aus »Ein winziges Sprachmodell«.

Was das aus den Treffern macht, haben Informatiker der Universität Leipzig und der Bauhaus-Universität Weimar ein Jahr lang gemessen. Sie stellten Google, Bing und DuckDuckGo 7392 Suchanfragen nach Testberichten, etwa nach den besten Kopfhörern. Im Web verdienen nur wenige Testberichte an Affiliate-Links: Links zu einem Shop, für die der Shop bei jedem Kauf einen Anteil zahlt. Unter den Treffern war es die Mehrheit. Wenn eine Suchmaschine solche Seiten herabstufte, half das nur eine Weile.

Is Google Getting Worse? A Longitudinal Investigation of SEO Spam in Search Engines, Janek Bevendorff, Matti Wiegmann, Martin Potthast und Benno Stein, ECIR 2024. Ein Jahr Suchanfragen nach Testberichten bei drei Suchmaschinen: Springer.

Manches, was Suchmaschinen belohnen, hilft auch Menschen: ein Titel, der sagt, worum es geht, Bilder mit Alt-Text, eine Seite, die schnell lädt. Schwierig wird es, wenn eine Seite für den Crawler geschrieben ist statt für die, die sie lesen.

Ausprobieren: Such nach »beste Kopfhörer« oder einem anderen Gerät. Achte darauf, wie viele der ersten zehn Treffer Listen mit Links zum Kaufen sind, und ob eine Seite sagt, wer die Geräte wirklich ausprobiert hat.

7. Wer entscheidet, was gefunden wird

Deine Suchmaschine hat zwei Rankings gesehen, und jedes bevorzugt andere Seiten. Nach der Anzahl der Wörter gewinnt, wer sein Wort am häufigsten wiederholt. Nach den Links gewinnt, wer schon bekannt ist; eine neue Seite, auf die noch niemand verlinkt, steht unten, egal wie gut sie ist.

  • Echte Suchmaschinen verbinden Hunderte solcher Signale: Links, Wörter, wie neu eine Seite ist, wo du gerade bist, was andere angeklickt haben. Wie genau, verraten sie nicht, auch damit niemand das Ranking so leicht ausnutzt wie das Katzenfutter das deine.
  • In Deutschland läuft der weitaus größte Teil aller Suchanfragen über eine einzige Suchmaschine. Was sie unten einordnet, findet kaum jemand.
  • Ein Crawler fragt eine Website zuerst nach der Datei /robots.txt, in der steht, was er nicht lesen soll. Daran hält er sich freiwillig; es ist eine Bitte, kein Schloss.

Und manchmal soll eine Seite gar nicht mehr gefunden werden, etwa ein alter Zeitungsartikel über die Schulden eines Menschen, die er längst bezahlt hat.

Aufgabe 11Ein alter Artikel

Ein Zeitungsartikel von vor fünfzehn Jahren berichtet über Schulden einer Frau, die sie längst bezahlt hat. Sucht jemand nach ihrem Namen, steht er ganz oben. Sie möchte, dass der Link dort verschwindet. Wer entscheidet in der EU zuerst darüber?

Ein alter Artikel

Hier geht es weiter, sobald du deine Antwort oben abgegeben hast.

Über so einen Fall hat der Europäische Gerichtshof 2014 entschieden (Google Spain): Eine Suchmaschine muss einen Link aus den Treffern zu einem Namen nehmen, wenn das Recht der Person schwerer wiegt als das Interesse der Öffentlichkeit, ihn zu finden. Darüber entscheidet zuerst die Suchmaschine selbst, auf einen Antrag der Person; wer mit ihrer Antwort nicht einverstanden ist, wendet sich an die Datenschutzbehörde oder ein Gericht. Der Artikel selbst bleibt dabei, wo er ist; er ist nur über den Namen nicht mehr zu finden. Seit 2018 steht dieses Recht auf Vergessenwerden in Artikel 17 der DSGVO.

Aufgabe 12Wer unten steht

Deine Suchmaschine ordnet nach eingehenden Links. Beschreib zwei Arten von Seiten, die dabei unten landen, obwohl sie gute Antworten haben können, und erkläre jeweils, warum. Was würdest du ändern, damit sie eine Chance bekommen, und was würde ein Spammer dann ausnutzen?

Mindestens 40 Wörter. Nur du und deine Lehrkraft können die Antwort lesen.

8. Ins echte Web Extra

Ein Extra für Schnelle. Wer es auslässt, springt zu Zum Nachschlagen.

Dein Crawler kennt bisher nur das Tierlexikon auf deinem eigenen Webserver. Der Workspace erreicht aber das ganze Internet, und so könnte dein Crawler auch eine echte Website durchsuchen, etwa die deiner Schule. Draußen fehlt ihm einiges:

  • Echte Websites schreiben ihre Links oft als ganze Adresse (https://www.beispiel-schule.de/kontakt) oder relativ zu der Seite, auf der sie stehen (hund.html statt /tiere/hund.html). links kennt nur Pfade, die mit / anfangen.
  • Er holt Seite um Seite, so schnell er kann, und hört erst auf, wenn er alles hat. Ein kleiner Webserver merkt so einen Ansturm, und die Crawler einer ganzen Klasse auf einmal erst recht.
  • Er fragt nicht, was er lesen soll. Das steht in der Datei /robots.txt.

Ein höflicher Crawler holt höchstens 30 Seiten, wartet vor jeder Anfrage eine Sekunde, hält sich an robots.txt und bleibt auf der Website, bei der er angefangen hat. Mit jeder Anfrage schickt er seinen User-Agent, einen Namen, der sagt, wer da fragt, so wie dein Browser seinen.

Der Crawler für draußen

Lege neben search.rb eine neue Datei webcrawler.rb an:

require 'net/http'
require 'uri'

# Wer der Crawler ist; viele Websites schreiben das in ihr Protokoll
USER_AGENT = 'Hackschule-Crawler (Informatikkurs, Hackschule Workspace)'
# Höchstens so viele Seiten, und so viele Sekunden Pause vor jeder
MAX_PAGES = 30
PAUSE = 1

# Die Ziele aller Links einer Seite, so wie sie im HTML stehen
def href_targets(html)
  html.scan(/<a\s[^>]*?href\s*=\s*["']([^"']*)["']/i).flatten
end

# Was robots.txt allen Crawlern verbietet (User-agent: *), als Liste von
# Anfängen von Pfaden; vereinfacht, RFC 9309 kennt noch mehr
def robots_rules(text)
  rules = []
  applies = false
  text.each_line do |line|
    key, value = line.sub(/#.*/, '').split(':', 2).map(&:strip)
    next if value.nil?

    case key.downcase
    when 'user-agent' then applies = (value == '*')
    when 'disallow' then rules << value if applies && !value.empty?
    end
  end
  rules
end

# Eine Anfrage mit dem User-Agent des Crawlers
def get(url)
  uri = URI(url)
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
                  open_timeout: 10, read_timeout: 10) do |http|
    http.get(uri.request_uri, 'User-Agent' => USER_AGENT)
  end
end

# robots.txt der Website, oder nichts, wenn es keine gibt
def fetch_robots(start)
  response = get(URI.join(start, '/robots.txt').to_s)
  response.is_a?(Net::HTTPSuccess) ? response.body.force_encoding('UTF-8').scrub : ''
rescue StandardError
  ''
end

# Eine HTML-Seite als [Adresse, HTML], oder nil; folgt Weiterleitungen,
# solange sie auf derselben Website bleiben
def fetch_url(url)
  host = URI(url).host
  5.times do
    response = get(url)
    if response.is_a?(Net::HTTPRedirection)
      url = URI.join(url, response['location']).to_s
      return nil unless URI(url).host == host

      next
    end
    return nil unless response.is_a?(Net::HTTPSuccess) && response['content-type'].to_s.start_with?('text/html')

    return [url, response.body.force_encoding('UTF-8').scrub]
  end
  nil
rescue StandardError
  nil
end

# Höchstens max_pages Seiten der Website von start, mit Pause und nach
# robots.txt, als Hash von Adresse zu HTML
def polite_crawl(start, max_pages = MAX_PAGES)
  rules = robots_rules(fetch_robots(start))
  puts "robots.txt verbietet: #{rules.empty? ? 'nichts' : rules.join(', ')}"
  pages = {}
  queue = [start]
  seen = [start]
  until queue.empty? || pages.size >= max_pages
    url = queue.shift
    unless allowed?(rules, URI(url).request_uri)
      puts "übersprungen: #{url}"
      next
    end
    sleep PAUSE
    found = fetch_url(url)
    next if found.nil?

    address, html = found
    puts "geholt: #{address}"
    pages[address] = html
    absolute_links(html, address).each do |link|
      next if seen.include?(link)

      seen << link
      queue << link
    end
  end
  pages
end
  • href_targets findet das Ziel jedes Links, so wie es im HTML steht, auch in einfachen Anführungszeichen.
  • get schickt eine Anfrage mit dem User-Agent des Crawlers; nach zehn Sekunden ohne Antwort gibt sie auf.
  • fetch_url holt eine Seite. Leitet der Server weiter, etwa von http auf https, folgt sie, solange die Weiterleitung auf derselben Website bleibt. Ist die Antwort keine HTML-Seite, sondern ein Bild oder ein PDF, oder lehnt der Server ab, gibt sie nil zurück, und der Crawler macht mit der nächsten Adresse weiter.
  • polite_crawl ist dein crawl, nur mit ganzen Adressen statt Pfaden, mit der Pause und der Grenze, und mit den Regeln aus robots.txt. Zwei Funktionen, die es aufruft, fehlen noch: absolute_links und allowed?.

Lege neben search.py eine neue Datei webcrawler.py an:

import re
import time
import urllib.error
import urllib.request
from urllib.parse import urldefrag, urljoin, urlparse

# Wer der Crawler ist; viele Websites schreiben das in ihr Protokoll
USER_AGENT = 'Hackschule-Crawler (Informatikkurs, Hackschule Workspace)'
# Höchstens so viele Seiten, und so viele Sekunden Pause vor jeder
MAX_PAGES = 30
PAUSE = 1


def href_targets(html):
    """Die Ziele aller Links einer Seite, so wie sie im HTML stehen"""
    return re.findall(r'<a\s[^>]*?href\s*=\s*["\']([^"\']*)["\']', html, re.IGNORECASE)


def robots_rules(text):
    """Was robots.txt allen Crawlern verbietet (User-agent: *), als Liste von
    Anfängen von Pfaden; vereinfacht, RFC 9309 kennt noch mehr"""
    rules = []
    applies = False
    for line in text.splitlines():
        line = line.split('#', 1)[0]
        if ':' not in line:
            continue
        key, value = (part.strip() for part in line.split(':', 1))
        if key.lower() == 'user-agent':
            applies = value == '*'
        elif key.lower() == 'disallow' and applies and value:
            rules.append(value)
    return rules


def get(url):
    """Eine Anfrage mit dem User-Agent des Crawlers"""
    request = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
    return urllib.request.urlopen(request, timeout=10)


def fetch_robots(start):
    """robots.txt der Website, oder nichts, wenn es keine gibt"""
    try:
        with get(urljoin(start, '/robots.txt')) as response:
            return response.read().decode('utf-8', errors='replace')
    except Exception:
        return ''


def fetch_url(url):
    """Eine HTML-Seite als (Adresse, HTML), oder None; urlopen folgt
    Weiterleitungen, die Seite muss aber auf derselben Website bleiben"""
    try:
        with get(url) as response:
            address = response.geturl()
            if urlparse(address).netloc != urlparse(url).netloc:
                return None
            if response.headers.get_content_type() != 'text/html':
                return None
            return address, response.read().decode('utf-8', errors='replace')
    except Exception:
        return None


def request_path(url):
    """Der Pfad einer Adresse mit dem Teil hinter ?, wie robots.txt ihn meint"""
    parts = urlparse(url)
    return (parts.path or '/') + ('?' + parts.query if parts.query else '')


def polite_crawl(start, max_pages=MAX_PAGES):
    """Höchstens max_pages Seiten der Website von start, mit Pause und nach
    robots.txt, als dict von Adresse zu HTML"""
    rules = robots_rules(fetch_robots(start))
    print('robots.txt verbietet:', ', '.join(rules) if rules else 'nichts')
    pages = {}
    queue = [start]
    seen = [start]
    while queue and len(pages) < max_pages:
        url = queue.pop(0)
        if not allowed(rules, request_path(url)):
            print('übersprungen:', url)
            continue
        time.sleep(PAUSE)
        found = fetch_url(url)
        if found is None:
            continue
        address, html = found
        print('geholt:', address)
        pages[address] = html
        for link in absolute_links(html, address):
            if link not in seen:
                seen.append(link)
                queue.append(link)
    return pages
  • href_targets findet das Ziel jedes Links, so wie es im HTML steht, auch in einfachen Anführungszeichen.
  • get schickt eine Anfrage mit dem User-Agent des Crawlers; nach zehn Sekunden ohne Antwort gibt sie auf.
  • fetch_url holt eine Seite. Leitet der Server weiter, etwa von http auf https, folgt urlopen der Weiterleitung; die Seite muss aber auf derselben Website bleiben. Ist die Antwort keine HTML-Seite, sondern ein Bild oder ein PDF, oder lehnt der Server ab, gibt sie None zurück, und der Crawler macht mit der nächsten Adresse weiter.
  • polite_crawl ist dein crawl, nur mit ganzen Adressen statt Pfaden, mit der Pause und der Grenze, und mit den Regeln aus robots.txt. Zwei Funktionen, die es aufruft, fehlen noch: absolute_links und allowed.

Ganze Adressen

Steht auf der Seite https://schule.example/tiere/katze.html ein Link auf hund.html, meint er die Seite im selben Ordner, https://schule.example/tiere/hund.html. Ein Link auf /index.html meint https://schule.example/index.html, und eine ganze Adresse bleibt, wie sie ist. Was hinter einem # steht, ist nur eine Stelle auf einer Seite: Für den Crawler ist index.html#oben dieselbe Seite wie index.html.

Das Rechnen nimmt dir URI.join ab:

URI.join('https://schule.example/tiere/katze.html', 'hund.html').to_s
# => "https://schule.example/tiere/hund.html"

Ein URI kennt die Teile einer Adresse: scheme ("https"), host ("schule.example"), port und fragment, das, was hinter # steht.

Jetzt bist du dran: Schreib in webcrawler.rb die Funktion absolute_links(html, base). base ist die Adresse der Seite. Sie gibt die Ziele ihrer Links als ganze Adressen zurück: ohne #…, nur mit http oder https, nur auf demselben Host und Port wie base, und jede Adresse nur einmal.
Tipp

Für jedes Ziel aus href_targets(html) rechnest du mit URI.join(base, target) die ganze Adresse aus. Manche Ziele kann URI.join nicht lesen, etwa mit Leerzeichen darin; mit (URI.join(base, target) rescue nil) bekommst du dann nil und überspringst das Ziel.

Nächster Tipp

Weiter mit next, wenn uri.scheme weder "http" noch "https" ist oder uri.host und uri.port andere sind als bei URI(base). Sonst entfernt uri.fragment = nil das #…, und uri.to_s ist die Adresse, die du anhängst, wenn sie noch nicht im Ergebnis steht.

Lösung anzeigen
# Die Links einer Seite als ganze Adressen, nur auf ihrer eigenen Website,
# ohne #…, jede nur einmal; base ist die Adresse der Seite
def absolute_links(html, base)
  base_uri = URI(base)
  result = []
  href_targets(html).each do |target|
    uri = (URI.join(base, target) rescue nil)
    next if uri.nil?
    next unless %w[http https].include?(uri.scheme)
    next unless uri.host == base_uri.host && uri.port == base_uri.port

    uri.fragment = nil
    url = uri.to_s
    result << url unless result.include?(url)
  end
  result
end

Das Rechnen nimmt dir urljoin ab, das Abschneiden von #… urldefrag:

urljoin('https://schule.example/tiere/katze.html', 'hund.html')
# 'https://schule.example/tiere/hund.html'
urldefrag('https://schule.example/index.html#oben')[0]
# 'https://schule.example/index.html'

urlparse(url) zerlegt eine Adresse in ihre Teile: .scheme ist 'https', .netloc der Host, mit Port, wenn einer dasteht.

Jetzt bist du dran: Schreib in webcrawler.py die Funktion absolute_links(html, base). base ist die Adresse der Seite. Sie gibt die Ziele ihrer Links als ganze Adressen zurück: ohne #…, nur mit http oder https, nur mit demselben netloc wie base, und jede Adresse nur einmal.
Tipp

Für jedes Ziel aus href_targets(html) rechnest du mit urldefrag(urljoin(base, target))[0] die ganze Adresse ohne #… aus.

Nächster Tipp

Mit continue weiter, wenn urlparse(url).scheme weder 'http' noch 'https' ist oder netloc ein anderes ist als bei urlparse(base). Sonst hängst du die Adresse an, wenn sie noch nicht im Ergebnis steht.

Lösung anzeigen
def absolute_links(html, base):
    """Die Links einer Seite als ganze Adressen, nur auf ihrer eigenen Website,
    ohne #…, jede nur einmal; base ist die Adresse der Seite"""
    result = []
    for target in href_targets(html):
        url = urldefrag(urljoin(base, target))[0]
        parts = urlparse(url)
        if parts.scheme not in ('http', 'https') or parts.netloc != urlparse(base).netloc:
            continue
        if url not in result:
            result.append(url)
    return result
Aufgabe 13Ganze AdressenExtra

Schreib in suchmaschine/webcrawler.rb die Funktion absolute_links(html, base). Die Prüfung gibt ihr die Seite https://schule.example/tiere/katze.html mit sechs Links: einem relativen (hund.html), einem mit #oben, einem zur Wikipedia, einem mit ganzer Adresse auf derselben Website, einem mailto: und noch einmal hund.html.

Tipp

URI.join(base, target) macht aus einem Linkziel eine ganze Adresse, als URI: URI.join("https://schule.example/tiere/katze.html", "hund.html").to_s ist "https://schule.example/tiere/hund.html".

Nächster Tipp

Ein URI hat scheme ("https"), host ("schule.example"), port und fragment (was hinter # steht). uri.fragment = nil entfernt das Fragment, uri.to_s gibt die Adresse als String.

Nächster Tipp

Für jedes Ziel aus href_targets(html): URI.join, dann weiter mit next, wenn scheme weder "http" noch "https" ist oder host und port andere sind als bei URI(base); sonst das Fragment entfernen und die Adresse anhängen, wenn sie noch nicht im Ergebnis steht.

Aufgabe 13Ganze AdressenExtra

Schreib in suchmaschine/webcrawler.py die Funktion absolute_links(html, base). Die Prüfung gibt ihr die Seite https://schule.example/tiere/katze.html mit sechs Links: einem relativen (hund.html), einem mit #oben, einem zur Wikipedia, einem mit ganzer Adresse auf derselben Website, einem mailto: und noch einmal hund.html.

Tipp

urljoin(base, target) macht aus einem Linkziel eine ganze Adresse: urljoin("https://schule.example/tiere/katze.html", "hund.html") ist "https://schule.example/tiere/hund.html".

Nächster Tipp

urldefrag(url)[0] ist die Adresse ohne das, was hinter # steht. urlparse(url) zerlegt sie: .scheme ist "https", .netloc der Host, mit Port, wenn einer dasteht.

Nächster Tipp

Für jedes Ziel aus href_targets(html): urljoin, dann urldefrag, dann mit continue weiter, wenn scheme weder "http" noch "https" ist oder netloc ein anderes als bei urlparse(base); sonst die Adresse anhängen, wenn sie noch nicht im Ergebnis steht.

Was robots.txt erlaubt

In der Datei /robots.txt sagt eine Website Crawlern, was sie nicht holen sollen. Wie sie aufgebaut ist, steht in RFC 9309, dem Robots Exclusion Protocol:

User-agent: Googlebot
Disallow: /

User-agent: *
Disallow: /intern/
Disallow: /impressum.html

Jede Gruppe beginnt mit User-agent, dem Crawler, für den sie gilt; * meint alle. Eine Zeile Disallow verbietet jeden Pfad, der so anfängt: /intern/ also /intern/noten.html und alles andere in diesem Ordner, aber nicht /internat.html. Hier darf der Googlebot gar nichts holen, alle anderen alles außer /intern/ und dem Impressum.

robots_rules liest aus der Datei die Zeilen Disallow der Gruppe für *, im Beispiel ["/intern/", "/impressum.html"]. Das ist vereinfacht: RFC 9309 kennt auch Zeilen Allow und die Zeichen * und $ in einer Regel.

Pythons Standardbibliothek liest die ganze Datei mit urllib.robotparser.

Jetzt bist du dran: Schreib in webcrawler.rb die Funktion allowed?(rules, path). Sie gibt true zurück, wenn keine der Regeln der Anfang von path ist.
Tipp

path.start_with?(rule) sagt, ob der Pfad mit der Regel anfängt, und rules.none? { |rule| … }, ob das für keine Regel gilt.

Lösung anzeigen
# Ob der Crawler den Pfad holen darf: Keine Regel ist sein Anfang
def allowed?(rules, path)
  rules.none? { |rule| path.start_with?(rule) }
end
Jetzt bist du dran: Schreib in webcrawler.py die Funktion allowed(rules, path). Sie gibt True zurück, wenn keine der Regeln der Anfang von path ist.
Tipp

path.startswith(rule) sagt, ob der Pfad mit der Regel anfängt, und any(… for rule in rules), ob das für mindestens eine Regel gilt; davor ein not.

Lösung anzeigen
def allowed(rules, path):
    """Ob der Crawler den Pfad holen darf: Keine Regel ist sein Anfang"""
    return not any(path.startswith(rule) for rule in rules)
Aufgabe 14Was robots.txt erlaubtExtra

Schreib in suchmaschine/webcrawler.rb die Funktion allowed?(rules, path). Die Prüfung gibt ihr die Regeln ["/intern/", "/impressum.html"] und fragt nach /intern/noten.html, /impressum.html und /index.html, dann nach einem Pfad ohne Regeln.

Tipp

Verboten ist ein Pfad, wenn er mit einer der Regeln anfängt: path.start_with?(rule).

Nächster Tipp

rules.none? { |rule| … } ist true, wenn der Block für keine Regel true ergibt.

Aufgabe 14Was robots.txt erlaubtExtra

Schreib in suchmaschine/webcrawler.py die Funktion allowed(rules, path). Die Prüfung gibt ihr die Regeln ['/intern/', '/impressum.html'] und fragt nach /intern/noten.html, /impressum.html und /index.html, dann nach einem Pfad ohne Regeln.

Tipp

Verboten ist ein Pfad, wenn er mit einer der Regeln anfängt: path.startswith(rule).

Nächster Tipp

any(… for rule in rules) ist True, wenn es für mindestens eine Regel True ist; davor ein not.

Erst im Tierlexikon

Gib dem Tierlexikon eine robots.txt, die das Impressum verbietet:

printf 'User-agent: *\nDisallow: /impressum.html\n' > ~/suchmaschine/tierlexikon/robots.txt

Das Programm dazu kommt in web.rb. Ohne Adresse fängt es beim Tierlexikon an:

require_relative 'webcrawler'

start = ARGV[0] || 'http://localhost:8000/index.html'
pages = polite_crawl(start)
puts "#{pages.size} Seiten geholt"

Starte es, während der Webserver im zweiten Terminal läuft:

ruby web.rb

Das Programm dazu kommt in web.py. Ohne Adresse fängt es beim Tierlexikon an:

import sys

from webcrawler import *

start = sys.argv[1] if len(sys.argv) > 1 else 'http://localhost:8000/index.html'
pages = polite_crawl(start)
print(f'{len(pages)} Seiten geholt')

Starte es, während der Webserver im zweiten Terminal läuft:

python3 web.py
Aufgabe 15Der höfliche Crawler im TierlexikonExtra

Wie viele Seiten holt dein höflicher Crawler aus dem Tierlexikon, wenn dort die robots.txt von oben liegt?

Tipp

Starte web im ersten Terminal, während der Webserver im zweiten läuft, und zähl die Zeilen mit »geholt«.

Ausprobieren: Achte darauf, wie lange der Lauf dauert und an welcher Stelle der Crawler eine Seite überspringt. Ändere dann PAUSE auf 0 und starte noch einmal: So schnell holt ein Crawler, der auf niemanden Rücksicht nimmt.

Auf einer echten Website

Bevor dein Crawler eine echte Website besucht:

  • Frag, wer die Website betreut, an einer Schule oft eine Lehrkraft oder die Schul-IT. Jede Anfrage deines Crawlers landet im Protokoll ihres Webservers, mit deinem User-Agent.
  • Lasst ihn einmal für die ganze Klasse laufen, am Beamer, statt dreißigmal gleichzeitig.

Dann bekommt web.rb die Startseite der Website:

ruby web.rb https://www.beispiel-schule.de/

Dann bekommt web.py die Startseite der Website:

python3 web.py https://www.beispiel-schule.de/
Ausprobieren: Achte darauf, was robots.txt verbietet, wie viele Seiten der Crawler holt, bevor er bei 30 aufhört, und wie lange das dauert. Vergleiche mit dem, was du im Browser siehst: Links, die erst ein Skript im Browser erzeugt, stehen nicht im HTML, und dein Crawler findet sie nicht.

9. Zum Nachschlagen