"""遍历 herb_list.csv,爬取每味中药的详情,保存到 中药/中药名.txt。
支持断点续爬:已经存在对应 txt 的中药直接跳过,中断后重跑即可继续。
页面结构(2026-07 实测):
div.p_content 下有 3~4 个 div.card-panel,每个 panel 一个 h2 大节:
名称 / xx的种植和炮制 / xx的效果 / xx的药方(部分药材没有药方)
"种植和炮制" 和 "效果" 两节内部再用 h3 分小节(来源/性味/炮制/性状/
功效/经脉/主治/用法用量/注意禁忌),h3 集合每味药都不一样,所以按
文档顺序动态解析,不写死字段名。
用法(在项目根目录执行):
python -m _001__clawler.02_herb_detail # 跑全部,5 线程
python -m _001__clawler.02_herb_detail --limit 5 # 只跑 5 条看效果
python -m _001__clawler.02_herb_detail --workers 10 # 提到 10 线程
python -m _001__clawler.02_herb_detail --workers 1 # 退回单线程
"""
import argparse
import csv
import os
import random
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from bs4 import BeautifulSoup
from common.path_utils import get_file_path
INPUT_CSV = get_file_path("_001__clawler/herb_list.csv")
OUTPUT_DIR = get_file_path("_001__clawler/中药")
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"
)
}
# requests.Session 不是线程安全的,每个线程各用一个(连接复用也还在)
_local = threading.local()
# print 在多线程下会串行,加锁保证一条日志不被撕开
_print_lock = threading.Lock()
def log(message):
with _print_lock:
print(message, flush=True)
def get_session():
session = getattr(_local, "session", None)
if session is None:
session = requests.Session()
session.headers.update(HEADERS)
_local.session = session
return session
def fetch_html(url, retries=3, timeout=30):
"""下载页面 html,失败时退避重试。"""
for attempt in range(1, retries + 1):
try:
response = get_session().get(url, timeout=timeout)
response.raise_for_status()
response.encoding = "utf-8"
return response.text
except requests.RequestException as exc:
log(f" 第 {attempt}/{retries} 次请求失败: {exc}")
if attempt == retries:
return None
time.sleep(2 * attempt)
def parse_detail(html):
"""解析详情页,返回 [(标题, [段落, ...]), ...],标题层级用 h2/h3 区分。
跳过两类噪音:
1. 图片 figure,其文字内容是无意义的 "1 #"
2. ol.p_alternative_list,别名已经在上一个 <p> 里以顿号形式出现过
"""
soup = BeautifulSoup(html, "html.parser")
content = soup.find("div", class_="p_content")
if content is None:
return None
sections = []
for panel in content.find_all("div", class_="card-panel"):
# 药方那一节的正文在 ul > li 里,不是 p,所以 li 也要收
for element in panel.find_all(["h2", "h3", "p", "li"]):
if element.name == "p":
# 图片块和别名 ol 都挂在 <p> 里面,先剔掉
if element.find("div", class_="p_image_center") or element.find("ol"):
continue
elif element.name == "li":
# 别名列表 ol.p_alternative_list 的 li 是重复内容,跳过
if element.find_parent("ol", class_="p_alternative_list"):
continue
text = element.get_text(" ", strip=True)
if not text:
continue
if element.name in ("h2", "h3"):
# h2 后面多了空格的情况(如 "毛车藤 的效果")顺手清掉
sections.append((element.name, " ".join(text.split()), []))
elif sections:
sections[-1][2].append(" ".join(text.split()))
return sections
def format_detail(name, url, category, sections):
"""把解析结果拼成可读的 txt 文本。"""
lines = [f"中药名:{name}", f"分类:{category}", f"链接:{url}", ""]
for level, title, paragraphs in sections:
lines.append(("## " if level == "h2" else "### ") + title)
lines.extend(paragraphs)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def crawl_one(herb, min_delay, max_delay):
"""抓一味药并落盘。返回 True 成功 / False 失败。由线程池并发调用。"""
name, url = herb["中药名"], herb["链接"]
output_path = os.path.join(OUTPUT_DIR, f"{name}.txt")
html = fetch_html(url)
if html is None:
return False
sections = parse_detail(html)
if not sections:
log(f" 解析失败,跳过 {name}(页面结构可能不同)")
return False
text = format_detail(name, url, herb["分类"], sections)
# 先写临时文件再改名,避免中断时留下半个文件被误判成已完成。
# 带线程名后缀,防止两个线程刚好撞到同名临时文件。
temp_path = f"{output_path}.{threading.get_ident()}.part"
with open(temp_path, "w", encoding="utf-8") as f:
f.write(text)
os.replace(temp_path, output_path)
# 每个线程自己睡,等效于把整体 QPS 压到 workers / 平均延时
time.sleep(random.uniform(min_delay, max_delay))
return True
def crawl(limit=0, workers=5, min_delay=1.0, max_delay=2.0):
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(INPUT_CSV, encoding="utf-8-sig") as f:
herbs = list(csv.DictReader(f))
# 断点续爬:先把已抓过的过滤掉,剩下的才交给线程池
todo = [
herb
for herb in herbs
if not os.path.exists(os.path.join(OUTPUT_DIR, f"{herb['中药名']}.txt"))
]
skipped = len(herbs) - len(todo)
if limit:
todo = todo[:limit]
print(f"共 {len(herbs)} 条,已存在 {skipped} 条,本次待抓 {len(todo)} 条")
print(f"并发线程数: {workers}")
done = failed = 0
if todo:
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {
pool.submit(crawl_one, herb, min_delay, max_delay): herb
for herb in todo
}
for finished, future in enumerate(as_completed(futures), start=1):
herb = futures[future]
try:
ok = future.result()
except Exception as exc: # 单条异常不该拖垮整轮
log(f" {herb['中药名']} 异常: {exc}")
ok = False
if ok:
done += 1
else:
failed += 1
log(f"[{finished}/{len(todo)}] {herb['中药名']} {'✓' if ok else '✗'}")
print(f"\n完成 {done} 条,跳过(已存在){skipped} 条,失败 {failed} 条")
print(f"输出目录: {OUTPUT_DIR}")
def main():
parser = argparse.ArgumentParser(description="爬取中药详情")
parser.add_argument(
"--limit",
type=int,
default=0,
help="本次最多新抓多少条,0 表示不限制(默认 0)",
)
parser.add_argument(
"--workers",
type=int,
default=5,
help="并发线程数(默认 5,设 1 就是单线程)",
)
parser.add_argument(
"--min-delay",
type=float,
default=1.0,
help="每个线程每条之间的最小延时秒数(默认 1.0)",
)
parser.add_argument(
"--max-delay",
type=float,
default=2.0,
help="每个线程每条之间的最大延时秒数(默认 2.0)",
)
args = parser.parse_args()
if args.workers < 1:
parser.error("--workers 至少为 1")
crawl(
limit=args.limit,
workers=args.workers,
min_delay=args.min_delay,
max_delay=args.max_delay,
)
if __name__ == "__main__":
main()