#!/usr/bin/env python3 """Abruftest für den Beitrag „KI-Crawler im Überblick“: ruft fundkiste.studio mit curl und den Kennungen der Crawler ab. Fassung vom 21.10.2026 (nach der Freigabe der Website für Suchmaschinen); erste Fassung vom 09.10.2026. Der Test sendet nur den Wortlaut der Kennung (User-Agent). Er kommt nicht aus den Netzen der Betreiber; ein Server, der Crawler an ihrer IP-Adresse prüft, kann echten Crawlern anders antworten. Aufruf: python3 abruftest.py """ import datetime import hashlib import html import json import os import re import subprocess import sys import tempfile import urllib.robotparser HOST = 'https://fundkiste.studio' PFADE = ['/robots.txt', '/', '/leistungen', '/ueber', '/ergebnisse', '/blog/geo-vs-seo'] # (Betreiber, Kennung in der robots.txt, gesendeter Wortlaut, Herkunft des Wortlauts) DOKU = 'Dokumentation des Betreibers, abgerufen am 21.10.2026' KENNUNGEN = [ ('Vergleich', '(Browser)', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36', 'üblicher Wortlaut eines Chrome-Browsers, Hauptversion 156'), ('Vergleich', '(curl ohne Angabe)', '', 'Standard von curl'), ('OpenAI', 'GPTBot', 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.4; +https://openai.com/gptbot', DOKU), ('OpenAI', 'OAI-SearchBot', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36; compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot', DOKU), ('OpenAI', 'ChatGPT-User', 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot', DOKU), ('Anthropic', 'ClaudeBot', 'ClaudeBot', 'nur der Name der Kennung; die Hilfeseite von Anthropic nennt keinen vollen Wortlaut'), ('Anthropic', 'Claude-SearchBot', 'Claude-SearchBot', 'nur der Name der Kennung; die Hilfeseite von Anthropic nennt keinen vollen Wortlaut'), ('Anthropic', 'Claude-User', 'Claude-User', 'nur der Name der Kennung; die Hilfeseite von Anthropic nennt keinen vollen Wortlaut'), ('Perplexity', 'PerplexityBot', 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)', DOKU), ('Perplexity', 'Perplexity-User', 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)', DOKU), ('Google', 'Googlebot', 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', DOKU + ' (Platzhalter der Chrome-Version durch 156.0.0.0 ersetzt)'), ('Microsoft', 'bingbot', 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) Chrome/156.0.0.0 Safari/537.36', 'Hilfeseite von Bing, abgerufen am 21.10.2026 (Platzhalter der Edge-Version durch 156.0.0.0 ersetzt)'), ('Apple', 'Applebot', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot)', DOKU + ' (englische Fassung)'), ('Meta', 'meta-externalagent', 'meta-externalagent/1.1 (+/documentation/sharing/webmasters/web-crawlers)', DOKU), ('Amazon', 'Amazonbot', 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Amazonbot/0.1) Chrome/156.0.0.0 Safari/537.36', DOKU + ' (Platzhalter der Chrome-Version durch 156.0.0.0 ersetzt)'), ('Common Crawl', 'CCBot', 'CCBot/2.0 (https://commoncrawl.org/faq/)', DOKU), ('ByteDance', 'Bytespider', 'Mozilla/5.0 (Linux; Android 5.0) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; Bytespider; spider-feedback@bytedance.com)', 'verbreiteter Wortlaut, keine Seite des Betreibers'), ] # Kennungen, die nur in der robots.txt wirken und keinen eigenen Abruf senden NUR_ROBOTS = ['Google-Extended', 'Applebot-Extended'] TEMP = tempfile.mkdtemp(prefix='abruftest-') def abruf(pfad, ua): kopf, rumpf = os.path.join(TEMP, 'kopf.txt'), os.path.join(TEMP, 'rumpf.bin') befehl = ['curl', '-sS', '-m', '30', '-D', kopf, '-o', rumpf, '-w', '%{http_code} %{time_total}'] if ua: befehl += ['-A', ua] aus = subprocess.run(befehl + [HOST + pfad], capture_output=True, text=True) status, zeit = (aus.stdout.split() + ['', ''])[:2] koepfe = {} for z in open(kopf, encoding='utf-8', errors='replace'): if ':' in z: k, v = z.split(':', 1) koepfe[k.strip().lower()] = v.strip() daten = open(rumpf, 'rb').read() text = daten.decode('utf-8', errors='replace') e = {'status': status, 'sekunden': zeit, 'bytes': len(daten), 'sha1': hashlib.sha1(daten).hexdigest()[:12], 'typ': koepfe.get('content-type', ''), 'x_robots_tag': koepfe.get('x-robots-tag', ''), 'fehler': aus.stderr.strip()} if 'html' in e['typ']: m = re.search(r']+name=["\']robots["\'][^>]*>', text, re.I) e['meta_robots'] = (re.search(r'content=["\']([^"\']*)', m.group(0)) or [None, ''])[1] if m else '' t = re.search(r']*>(.*?)', text, re.S | re.I) e['titel'] = html.unescape(t.group(1)).strip() if t else '' h = re.search(r']*>(.*?)', text, re.S | re.I) e['h1'] = re.sub(r'\s+', ' ', html.unescape(re.sub(r'<[^>]+>', ' ', h.group(1)))).strip() if h else '' sicht = re.sub(r'<(script|style|noscript|template)\b.*?', ' ', text, flags=re.S | re.I) haupt = re.search(r'', sicht, re.S | re.I) e['woerter_main'] = len(re.sub(r'<[^>]+>', ' ', html.unescape(haupt.group(0))).split()) if haupt else 0 e['woerter_gesamt'] = len(re.sub(r'<[^>]+>', ' ', html.unescape(sicht)).split()) else: e['inhalt'] = text if len(text) < 600 else text[:600] return e def main(ziel): os.makedirs(ziel, exist_ok=True) start = datetime.datetime.now().astimezone() robots = subprocess.run(['curl', '-sS', '-m', '30', HOST + '/robots.txt'], capture_output=True, text=True).stdout rp = urllib.robotparser.RobotFileParser() rp.parse(robots.splitlines()) zeilen = [] for betreiber, kennung, ua, herkunft in KENNUNGEN: for pfad in PFADE: e = abruf(pfad, ua) e.update({'betreiber': betreiber, 'kennung': kennung, 'wortlaut': ua, 'herkunft': herkunft, 'pfad': pfad, 'robots_erlaubt': rp.can_fetch(kennung, HOST + pfad) if not kennung.startswith('(') else None}) zeilen.append(e) ende = datetime.datetime.now().astimezone() kopf = {'host': HOST, 'beginn': start.isoformat(timespec='seconds'), 'ende': ende.isoformat(timespec='seconds'), 'curl': subprocess.run(['curl', '--version'], capture_output=True, text=True).stdout.split('\n')[0], 'robots_txt': robots, 'nur_robots': {k: rp.can_fetch(k, HOST + '/') for k in NUR_ROBOTS}} json.dump({'kopf': kopf, 'abrufe': zeilen}, open(os.path.join(ziel, 'abruftest-roh.json'), 'w', encoding='utf-8'), ensure_ascii=False, indent=1) spalten = ['betreiber', 'kennung', 'pfad', 'status', 'bytes', 'sha1', 'woerter_main', 'woerter_gesamt', 'meta_robots', 'x_robots_tag', 'robots_erlaubt', 'titel', 'h1', 'sekunden', 'wortlaut', 'herkunft'] with open(os.path.join(ziel, 'abruftest.tsv'), 'w', encoding='utf-8') as f: f.write('\t'.join(spalten) + '\n') for e in zeilen: f.write('\t'.join(str(e.get(s, '')).replace('\t', ' ').replace('\n', ' ') for s in spalten) + '\n') print(json.dumps(kopf, ensure_ascii=False, indent=1)) print('Abrufe:', len(zeilen)) for pfad in PFADE: gruppe = [e for e in zeilen if e['pfad'] == pfad] print(pfad, 'Status', sorted({e['status'] for e in gruppe}), 'Bytes', sorted({e['bytes'] for e in gruppe}), 'Prüfsummen', len({e['sha1'] for e in gruppe}), 'Wörter main', sorted({e.get('woerter_main', '') for e in gruppe}, key=str), 'Wörter gesamt', sorted({e.get('woerter_gesamt', '') for e in gruppe}, key=str), 'meta robots', sorted({e.get('meta_robots', '') for e in gruppe}), 'X-Robots-Tag', sorted({e['x_robots_tag'] for e in gruppe}), 'Titel', sorted({e.get('titel', '') for e in gruppe}), 'h1', sorted({e.get('h1', '') for e in gruppe})) print('robots erlaubt je Kennung (Startseite):', {e['kennung']: e['robots_erlaubt'] for e in zeilen if e['pfad'] == '/'}) print('Fehler:', [(e['kennung'], e['pfad'], e['fehler']) for e in zeilen if e['fehler']]) if __name__ == '__main__': main(sys.argv[1])