#!/usr/bin/env python3 """Crawl 唐诗三百首 from guwendao.net. Outputs are written to the chosen data directory: - tangshi300.json - tangshi300.jsonl - tangshi300.csv - tangshi300_summary.txt Only Python standard library is used. """ from __future__ import annotations import argparse import csv import html as html_lib import ipaddress import json import re import socket import sys import time from datetime import datetime, timezone from pathlib import Path from typing import Dict, Iterable, List, Optional, Tuple from urllib.error import HTTPError, URLError from urllib.parse import urljoin, urlparse from urllib.request import Request, urlopen BASE_URL = "https://www.guwendao.net" START_URL = "https://www.guwendao.net/gushi/tangshi.aspx" ALLOWED_HOSTS = {"www.guwendao.net", "guwendao.net"} DEFAULT_USER_AGENT = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0 Safari/537.36 TangshiCrawler/1.0" ) SCRIPT_STYLE_RE = re.compile(r"<(script|style)\b[^>]*>.*?", re.I | re.S) TAG_RE = re.compile(r"<[^>]+>", re.S) DIV_TAG_RE = re.compile(r"<(/?)div\b([^>]*)>", re.I | re.S) ATTR_RE_TEMPLATE = r"\b{}\s*=\s*(['\"])(.*?)\1" def log(message: str) -> None: print(message, flush=True) def normalize_text(text: str) -> str: text = html_lib.unescape(text or "") text = text.replace("\xa0", " ").replace("\u3000", " ") lines = [re.sub(r"[ \t\r\f\v]+", " ", line).strip() for line in text.split("\n")] return "\n".join(line for line in lines if line) def html_to_text(fragment: str) -> str: if not fragment: return "" fragment = SCRIPT_STYLE_RE.sub("", fragment) fragment = re.sub(r"", "\n", fragment, flags=re.I) fragment = re.sub(r"\s*", "\n", fragment, flags=re.I) fragment = re.sub(r"]*>", "\n", fragment, flags=re.I) return normalize_text(TAG_RE.sub("", fragment)) def get_attr(attrs: str, name: str) -> Optional[str]: match = re.search(ATTR_RE_TEMPLATE.format(re.escape(name)), attrs or "", re.I | re.S) if not match: return None return html_lib.unescape(match.group(2)) def has_class(attrs: str, class_name: str) -> bool: value = get_attr(attrs, "class") or "" return class_name in value.split() def find_div_blocks( html: str, *, class_name: Optional[str] = None, id_value: Optional[str] = None, start: int = 0, ) -> List[Tuple[str, int, int]]: """Return balanced div inner HTML blocks matching class or id.""" blocks: List[Tuple[str, int, int]] = [] pos = start while True: match = DIV_TAG_RE.search(html, pos) if not match: break closing, attrs = match.group(1), match.group(2) pos = match.end() if closing: continue if class_name and not has_class(attrs, class_name): continue if id_value and get_attr(attrs, "id") != id_value: continue depth = 1 inner_start = match.end() scan = match.end() while True: next_match = DIV_TAG_RE.search(html, scan) if not next_match: break if next_match.group(1): depth -= 1 if depth == 0: blocks.append((html[inner_start:next_match.start()], match.start(), next_match.end())) pos = next_match.end() break else: depth += 1 scan = next_match.end() else: break return blocks def is_blocked_ip(ip_text: str) -> bool: try: ip = ipaddress.ip_address(ip_text) except ValueError: return True first_octet = int(ip_text.split(".", 1)[0]) if ip.version == 4 else None blocked_first_octets = {9, 10, 11, 21, 30} return ( ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified or (first_octet in blocked_first_octets) ) def validate_url(url: str) -> None: parsed = urlparse(url) if parsed.scheme != "https": raise ValueError(f"Only https URLs are allowed: {url}") host = (parsed.hostname or "").lower() if host not in ALLOWED_HOSTS: raise ValueError(f"URL host is not allowed: {host}") try: for family, _, _, _, sockaddr in socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM): ip_text = sockaddr[0] if is_blocked_ip(ip_text): raise ValueError(f"Resolved to blocked/internal IP: {host} -> {ip_text}") except socket.gaierror as exc: raise ValueError(f"Cannot resolve host {host}: {exc}") from exc def fetch_text(url: str, *, timeout: int = 20, retries: int = 3, user_agent: str = DEFAULT_USER_AGENT) -> str: validate_url(url) last_error: Optional[Exception] = None for attempt in range(1, retries + 1): try: req = Request( url, headers={ "User-Agent": user_agent, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }, ) with urlopen(req, timeout=timeout) as resp: final_url = resp.geturl() validate_url(final_url) charset = resp.headers.get_content_charset() or "utf-8" return resp.read().decode(charset, errors="replace") except (HTTPError, URLError, TimeoutError, ValueError) as exc: last_error = exc if attempt < retries: time.sleep(min(2 * attempt, 5)) raise RuntimeError(f"Fetch failed: {url}; last error: {last_error}") def parse_tangshi_list(page_html: str) -> List[Dict[str, object]]: type_blocks = find_div_blocks(page_html, class_name="typecont") if not type_blocks: raise RuntimeError("Cannot find 唐诗三百首 list block: div.typecont") token_re = re.compile( r"\s*(.*?)\s*" r"|\s*]*>(.*?)\s*[((]([^))]+)[))]\s*", re.I | re.S, ) items: List[Dict[str, object]] = [] current_category = "" category_order = 0 for list_html, _, _ in type_blocks: for match in token_re.finditer(list_html): if match.group(1) is not None: current_category = html_to_text(match.group(1)) category_order += 1 continue href = html_lib.unescape(match.group(2) or "") title = html_to_text(match.group(3) or "") author = normalize_text(match.group(4) or "") if not href or not title: continue items.append( { "index": len(items) + 1, "category": current_category, "category_order": category_order, "title": title, "list_author": author, "url": urljoin(BASE_URL, href), } ) if not items: raise RuntimeError("No poem links parsed from list page") return items def extract_poem_id(url: str) -> str: match = re.search(r"shiwenv_([0-9a-fA-F]+)\.aspx", url) return match.group(1) if match else "" def parse_source(source_html: str) -> Tuple[str, str]: anchors = re.findall(r"]*>(.*?)", source_html or "", flags=re.I | re.S) texts = [html_to_text(anchor) for anchor in anchors] texts = [text for text in texts if text] author = texts[0] if texts else "" dynasty = texts[1] if len(texts) > 1 else "" dynasty = dynasty.strip("[]【】〔〕()() ") return author, dynasty def parse_tags(page_html: str) -> List[str]: tag_blocks = find_div_blocks(page_html, class_name="tag") if not tag_blocks: return [] texts = [html_to_text(anchor) for anchor in re.findall(r"]*>(.*?)", tag_blocks[0][0], re.I | re.S)] return [text for text in texts if text] def parse_sections(page_html: str) -> List[Dict[str, str]]: sections: List[Dict[str, str]] = [] for block, _, _ in find_div_blocks(page_html, class_name="contyishang"): title_match = re.search(r"]*>(.*?)", block, re.I | re.S) title = html_to_text(title_match.group(1)) if title_match else "" body_html = block if title_match: body_html = block[: title_match.start()] + block[title_match.end() :] # Remove audio/play links and correction links from section body. body_html = re.sub(r"]*javascript:Play[^>]*>.*?", "", body_html, flags=re.I | re.S) body_html = re.sub(r"]*jiucuo\.aspx[^>]*>.*?", "", body_html, flags=re.I | re.S) text = html_to_text(body_html) if title and text: sections.append({"title": title, "text": text}) return sections def parse_detail(page_html: str, url: str) -> Dict[str, object]: poem_id = extract_poem_id(url) zhengwen_blocks = find_div_blocks(page_html, id_value=f"zhengwen{poem_id}") if poem_id else [] zhengwen_html = zhengwen_blocks[0][0] if zhengwen_blocks else page_html title_match = re.search(r"]*>(.*?)", zhengwen_html, re.I | re.S) title = html_to_text(title_match.group(1)) if title_match else "" source_match = re.search(r"]*>(.*?)

", zhengwen_html, re.I | re.S) author, dynasty = parse_source(source_match.group(1) if source_match else "") content = "" if poem_id: content_match = re.search( rf"]*>(.*?)", zhengwen_html, re.I | re.S, ) if content_match: content = html_to_text(content_match.group(1)) if not content: content_match = re.search(r"]*>(.*?)", zhengwen_html, re.I | re.S) if content_match: content = html_to_text(content_match.group(1)) sections = parse_sections(page_html) extra: Dict[str, str] = {} for section in sections: section_title = section["title"] if "译文" in section_title or "注释" in section_title: extra.setdefault("translation_annotation", section["text"]) elif "赏析" in section_title or "鉴赏" in section_title or "简析" in section_title: extra.setdefault("appreciation", section["text"]) elif "背景" in section_title: extra.setdefault("background", section["text"]) return { "poem_id": poem_id, "detail_title": title, "author": author, "dynasty": dynasty, "content": content, "tags": parse_tags(page_html), "sections": sections, "translation_annotation": extra.get("translation_annotation", ""), "appreciation": extra.get("appreciation", ""), "background": extra.get("background", ""), } def write_outputs(records: List[Dict[str, object]], output_dir: Path, source_url: str) -> None: output_dir.mkdir(parents=True, exist_ok=True) scraped_at = datetime.now(timezone.utc).isoformat() payload = { "source": source_url, "site": "古文岛/古诗文网", "scraped_at": scraped_at, "count": len(records), "records": records, } json_path = output_dir / "tangshi300.json" json_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") jsonl_path = output_dir / "tangshi300.jsonl" with jsonl_path.open("w", encoding="utf-8") as f: for record in records: f.write(json.dumps(record, ensure_ascii=False) + "\n") csv_path = output_dir / "tangshi300.csv" fieldnames = [ "index", "category", "title", "author", "dynasty", "content", "url", "tags", "translation_annotation", "appreciation", "background", "sections_json", "error", ] with csv_path.open("w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() for record in records: writer.writerow( { "index": record.get("index", ""), "category": record.get("category", ""), "title": record.get("title", ""), "author": record.get("author", "") or record.get("list_author", ""), "dynasty": record.get("dynasty", ""), "content": record.get("content", ""), "url": record.get("url", ""), "tags": ";".join(record.get("tags", []) or []), "translation_annotation": record.get("translation_annotation", ""), "appreciation": record.get("appreciation", ""), "background": record.get("background", ""), "sections_json": json.dumps(record.get("sections", []), ensure_ascii=False), "error": record.get("error", ""), } ) categories: Dict[str, int] = {} errors = 0 for record in records: categories[str(record.get("category", ""))] = categories.get(str(record.get("category", "")), 0) + 1 if record.get("error"): errors += 1 summary_lines = [ f"source: {source_url}", f"scraped_at: {scraped_at}", f"count: {len(records)}", f"errors: {errors}", "categories:", ] summary_lines.extend(f" - {name}: {count}" for name, count in categories.items()) (output_dir / "tangshi300_summary.txt").write_text("\n".join(summary_lines) + "\n", encoding="utf-8") def crawl(args: argparse.Namespace) -> List[Dict[str, object]]: log(f"Fetch list: {args.start_url}") list_html = fetch_text(args.start_url, timeout=args.timeout, retries=args.retries, user_agent=args.user_agent) items = parse_tangshi_list(list_html) if args.limit: items = items[: args.limit] log(f"Parsed {len(items)} poem links") records: List[Dict[str, object]] = [] for item in items: record = dict(item) if args.skip_details: records.append(record) continue url = str(item["url"]) try: log(f"[{item['index']}/{len(items)}] {item['title']} - {url}") detail_html = fetch_text(url, timeout=args.timeout, retries=args.retries, user_agent=args.user_agent) detail = parse_detail(detail_html, url) record.update(detail) if not record.get("author"): record["author"] = record.get("list_author", "") if detail.get("detail_title"): record["title"] = detail["detail_title"] except Exception as exc: # keep remaining records available record["error"] = str(exc) log(f" ERROR: {exc}") records.append(record) if args.delay > 0: time.sleep(args.delay) return records def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Crawl 唐诗三百首 from guwendao.net") parser.add_argument("--start-url", default=START_URL, help="唐诗三百首列表页 URL") parser.add_argument("--output-dir", default=str(Path(__file__).resolve().parents[1]), help="数据输出目录") parser.add_argument("--delay", type=float, default=0.3, help="每个详情页之间的延迟秒数") parser.add_argument("--timeout", type=int, default=20, help="单次请求超时秒数") parser.add_argument("--retries", type=int, default=3, help="请求失败重试次数") parser.add_argument("--limit", type=int, default=0, help="只爬前 N 条;0 表示全部") parser.add_argument("--skip-details", action="store_true", help="只保存列表页条目,不爬详情页") parser.add_argument("--user-agent", default=DEFAULT_USER_AGENT, help="请求 User-Agent") return parser def main(argv: Optional[Iterable[str]] = None) -> int: args = build_arg_parser().parse_args(argv) output_dir = Path(args.output_dir).expanduser().resolve() try: records = crawl(args) write_outputs(records, output_dir, args.start_url) except Exception as exc: print(f"Fatal: {exc}", file=sys.stderr) return 1 log(f"Done. Files saved to: {output_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())