#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从电子课本网(dzkbw.com)抓取义务教育阶段教材目录,并按阶段/年级/科目/版本建立文件夹。
说明:
- dzkbw.com 本身是导航站,教材正文托管在国家中小学智慧教育平台(basic.smartedu.cn)。
- 本脚本会解析出每个章节的官方直达链接,写入各本书的 Markdown 索引。
- 可开启 --search,使用 DuckDuckGo Lite 按章节标题搜索网络补充资料。
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.parse
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter, Retry
BASE_URL = "http://www.dzkbw.com"
SMARTEDU_BASE = "https://basic.smartedu.cn/tchMaterial/detail"
DDG_URL = "https://lite.duckduckgo.com/lite/"
JINA_BASE = "https://r.jina.ai/http://"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9",
}
PREFERRED_VERSIONS = [
"人教版", "部编版", "北师大版", "苏教版",
"外研版", "译林版", "教科版", "青岛版", "北京版",
"沪教版", "冀教版", "鲁教版", "湘教版", "浙教版",
]
GRADE_RE = re.compile(r"(五四制)?\s*([一二三四五六七八九十]+年级|高[一二三]|初[一二三])([上下]?)册?")
SAFE_RE = re.compile(r'[\\/*?:"<>|]+')
KINDERGARTEN_STAGES = ["小班", "中班", "大班"]
KINDERGARTEN_SUBJECTS = ["语言", "健康", "社会", "科学", "艺术", "数学启蒙", "英语启蒙"]
class Cache:
def __init__(self, path: Path):
self.path = Path(path)
self.data = {}
if self.path.exists():
try:
self.data = json.loads(self.path.read_text(encoding="utf-8"))
except Exception:
self.data = {}
def get(self, url: str):
return self.data.get(url)
def set(self, url: str, value: str):
self.data[url] = value
self.save()
def save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps(self.data, ensure_ascii=False), encoding="utf-8")
def get_session():
s = requests.Session()
s.headers.update(HEADERS)
retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504])
s.mount("http://", HTTPAdapter(max_retries=retries))
s.mount("https://", HTTPAdapter(max_retries=retries))
return s
def fetch_text(url: str, session: requests.Session, cache: Cache, delay: float = 0.3) -> str:
cached = cache.get(url)
if cached is not None:
return cached
if delay > 0:
time.sleep(delay)
resp = session.get(url, timeout=20)
resp.raise_for_status()
if "dzkbw.com" in url:
text = resp.content.decode("gbk", errors="ignore")
else:
text = resp.text
cache.set(url, text)
return text
def dzkbw_decode(encoded: str) -> str:
n = len(encoded)
k = 3 + n % 4
acc = n * 7
half = n // 2
encoded = encoded[n - half:] + encoded[:n - half]
out = []
for i in range(1, n + 1):
ch = encoded[i - 1]
code = ord(ch) + acc % k + k // 2
out.append(chr(code))
acc = ord(out[-1])
return "".join(out)
def extract_content_id(chapter_html: str) -> str | None:
m = re.search(r']*?href="(/go/[^"]+)"', chapter_html, re.I)
if not m:
return None
# 必须直接解析原始 href,否则 url 参数里的 %23 会被提前解码为 #,导致查询字符串被截断。
href = m.group(1)
q = urllib.parse.parse_qs(urllib.parse.urlparse(href).query)
encoded = q.get("url", [""])[0]
if not encoded:
return None
encoded = urllib.parse.unquote(encoded)
decoded = dzkbw_decode(encoded)
cm = re.search(r"contentId=([0-9a-f\-]+)", decoded)
return cm.group(1) if cm else None
def smartedu_url(content_id: str, page_code: str) -> str:
page = page_code.zfill(3)
return (
f"{SMARTEDU_BASE}?contentType=assets_document"
f"&contentId={content_id}&catalogType=tchMaterial"
f"&subCatalog=tchMaterial&page={page}"
)
def safe_name(s: str) -> str:
return SAFE_RE.sub("_", s).strip(" ._")
def parse_home(html: str):
"""返回 [(stage, stage_slug, subject_slug, subject_name), ...]"""
# 支持按阶段找课本和年级找课本两个导航区
patterns = [
(r'按阶段找课本.*?', True),
(r'按年级找课本.*?', True),
]
seen = set()
items = []
for pat, use_in_list in patterns:
m = re.search(pat, html, re.S | re.I)
if not m:
continue
nav = m.group(0)
for a in re.finditer(
r']*>([^<]+)',
nav,
re.I,
):
stage_slug, subject_slug, title_attr, text = a.groups()
stage_map = {"xiaoxue": "小学", "chuzhong": "初中", "gaozhong": "高中"}
stage = stage_map[stage_slug]
subject_name = text.replace(stage, "").strip()
key = (stage, subject_slug)
if key in seen:
continue
seen.add(key)
items.append((stage, stage_slug, subject_slug, subject_name))
return items
def parse_subject_page(html: str, stage: str, subject_name: str, only_preferred: bool = True):
"""返回图书列表,按版本分组过滤。"""
sections = re.split(r'
]*>', html, flags=re.I)
books = []
for sec in sections[1:]:
header_match = re.match(r"([^<]+)
", sec, re.I)
if not header_match:
continue
version_heading = header_match.group(1).strip()
if only_preferred and not any(v in version_heading for v in PREFERRED_VERSIONS):
continue
ul_part = sec.split("", 1)[0]
for li in re.finditer(r"]*>(.*?)", ul_part, re.S | re.I):
li_html = li.group(1)
m1 = re.search(r']*>([^<]*)',
li_html,
re.I,
)
if not (m1 and m2):
continue
book_url = m1.group(1)
book_title = m2.group(1).strip()
gm = GRADE_RE.search(book_title)
grade = gm.group(2) if gm else "其他"
books.append(
{
"version_heading": version_heading,
"url": book_url,
"title": book_title,
"grade": grade,
}
)
return books
def parse_book_page(html: str, book_url: str):
title_match = re.search(r"([^<]+)", html, re.I)
book_title = title_match.group(1).strip() if title_match else ""
book_path = book_url.rstrip("/")
# 封面图:优先匹配本书路径下的 lazy 图片
cover_match = re.search(
rf'
]*?class=[\'"]lazy[\'"][^>]*?data-original="({re.escape(book_path)}/cover\.jpg)"[^>]*?>',
html,
re.I,
)
cover = cover_match.group(1) if cover_match else ""
# 目录:本书目录页链接统一为 /books/.../{book_id}/{code}.htm
toc = []
seen = set()
pattern = rf']*?href="({re.escape(book_path)}/([0-9]*)\.htm)"[^>]*>(.*?)'
for a in re.finditer(pattern, html, re.S | re.I):
href, code, txt = a.groups()
code = (code or "000").zfill(3)
if href in seen:
continue
seen.add(href)
title = re.sub(r"<[^>]+>", "", txt).strip()
if title:
toc.append(
{
"code": code,
"title": title,
"dzkbw_url": BASE_URL + href,
}
)
return {"title": book_title, "cover": cover, "toc": toc}
def search_ddg(query: str, max_results: int = 3):
headers = {
"User-Agent": HEADERS["User-Agent"],
"Accept": "text/html",
"Referer": "https://lite.duckduckgo.com/lite/",
}
try:
resp = requests.post(
DDG_URL, data={"q": query, "kl": "zh-CN"}, headers=headers, timeout=20
)
resp.raise_for_status()
except Exception as e:
print(f" [search error] {e}")
return []
results = []
for m in re.finditer(
r'([^<]+)',
resp.text,
re.I,
):
url = m.group(1)
title = re.sub(r"<[^>]+>", "", m.group(2)).strip()
if not title or not url.startswith("http"):
continue
results.append({"title": title, "url": url})
if len(results) >= max_results:
break
return results
def write_book_markdown(
output_root: Path,
stage: str,
grade: str,
subject_name: str,
book: dict,
info: dict,
supplements: dict,
):
folder = output_root / safe_name(stage) / safe_name(grade) / safe_name(subject_name)
folder.mkdir(parents=True, exist_ok=True)
version = safe_name(book["version_heading"].replace(stage, "").replace(subject_name, "").strip() or "未知版本")
filename = f"{version}_{safe_name(book['title'])}.md"
filepath = folder / filename
lines = []
lines.append(f"# {book['title']}")
lines.append("")
lines.append(f"- **学段**:{stage}")
lines.append(f"- **年级**:{book['grade']}")
lines.append(f"- **科目**:{subject_name}")
if book.get("version_heading"):
lines.append(f"- **版本**:{book['version_heading']}")
if book.get("url"):
lines.append(f"- **电子课本网目录**:{BASE_URL}{book['url']}")
else:
lines.append(f"- **资料来源**:网络搜索整理")
if info.get("cover"):
lines.append(f"- **封面图**:{BASE_URL}{info['cover']}")
lines.append("")
lines.append("## 目录与官方阅读链接")
lines.append("")
lines.append("| 序号 | 章节 | 电子课本网 | 智慧教育平台 |")
lines.append("|------|------|------------|--------------|")
for ch in info["toc"]:
smart = ch.get("smartedu_url", "")
dzkbw = ch.get("dzkbw_url", "")
dzkbw_link = f"[查看]({dzkbw})" if dzkbw else "-"
smart_link = f"[官方阅读]({smart})" if smart else "-"
lines.append(
f"| {ch['code']} | {ch['title']} | {dzkbw_link} | {smart_link} |"
)
lines.append("")
if supplements:
lines.append("## 网络补充资料")
lines.append("")
for ch_title, results in supplements.items():
lines.append(f"### {ch_title}")
if results:
for r in results:
lines.append(f"- [{r['title']}]({r['url']})")
else:
lines.append("- 未找到结果")
lines.append("")
filepath.write_text("\n".join(lines), encoding="utf-8")
return filepath
def main():
parser = argparse.ArgumentParser(description="构建义务教育阶段教材目录语料")
parser.add_argument("--output", default="corpus/义务教育教材", help="输出根目录")
parser.add_argument("--cache", default=".cache/dzkbw_cache.json", help="HTTP 缓存文件")
parser.add_argument("--delay", type=float, default=0.25, help="请求间隔(秒)")
parser.add_argument("--max-books-per-subject", type=int, default=0, help="每科目最多处理的书本数,0 为不限")
parser.add_argument("--only-preferred", action="store_true", default=True, help="仅保留主流版本")
parser.add_argument("--search", action="store_true", help="是否按章节搜索网络补充资料")
parser.add_argument("--search-chapters", type=int, default=5, help="每本书搜索网络补充资料的章节数")
parser.add_argument("--search-results", type=int, default=3, help="每章节保留的搜索结果数")
args = parser.parse_args()
output_root = Path(args.output).resolve()
output_root.mkdir(parents=True, exist_ok=True)
cache = Cache(Path(args.cache).resolve())
session = get_session()
print(f"输出目录:{output_root}")
print("正在解析 dzkbw.com 首页...")
home_html = fetch_text(BASE_URL + "/", session, cache, delay=args.delay)
subjects = parse_home(home_html)
print(f"发现义务教育阶段科目:{len(subjects)} 个")
total_books = 0
for stage, stage_slug, subject_slug, subject_name in subjects:
subject_url = f"{BASE_URL}/books/{stage_slug}-{subject_slug}/"
print(f"\n[{stage}] {subject_name} -> {subject_url}")
try:
subj_html = fetch_text(subject_url, session, cache, delay=args.delay)
except Exception as e:
print(f" 跳过(获取失败):{e}")
continue
books = parse_subject_page(subj_html, stage, subject_name, args.only_preferred)
if not books:
print(" 没有匹配到书本")
continue
if args.max_books_per_subject:
books = books[: args.max_books_per_subject]
for book in books:
print(f" - {book['title']} ({book['version_heading']})")
try:
book_html = fetch_text(
BASE_URL + book["url"], session, cache, delay=args.delay
)
info = parse_book_page(book_html, book["url"])
if not info["toc"]:
print(" 未解析到目录,跳过")
continue
first_chapter_html = fetch_text(
info["toc"][0]["dzkbw_url"], session, cache, delay=args.delay
)
content_id = extract_content_id(first_chapter_html)
if content_id:
for ch in info["toc"]:
ch["smartedu_url"] = smartedu_url(content_id, ch["code"])
supplements = {}
if args.search:
for ch in info["toc"][: args.search_chapters]:
q = f"{book['title']} {ch['title']}"
supplements[ch["title"]] = search_ddg(
q, max_results=args.search_results
)
if args.delay > 0:
time.sleep(args.delay)
filepath = write_book_markdown(
output_root, stage, book["grade"], subject_name, book, info, supplements
)
total_books += 1
print(f" 已生成:{filepath.relative_to(output_root)}")
except Exception as e:
print(f" 处理失败:{e}")
continue
print(f"\n完成,共生成 {total_books} 本书的索引。")
if __name__ == "__main__":
main()