农作物病虫害图谱目录页爬虫实战:用 Python 抓取分类页与详情页,导出病虫害名、作物、症状、防治说明与图片链接
㊗️本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期爬虫难度指数:⭐⭐☆☆☆(基础级)
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。
全文目录:
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战,主理专栏👉 《Python爬虫实战》:从采集策略到反爬对抗,从数据清洗到分布式调度,持续输出可复用的方法论与可落地案例。内容主打一个“能跑、能用、能扩展”,让数据价值真正做到——抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间:如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。
💕订阅后更新会优先推送,按目录学习更高效💯~
0️⃣ 前言(Preface)
这篇文章要做一件很具体的事:用 Python 抓取一个公开的农作物病虫害图谱类网站,从分类/搜索页拿到详情页链接,再进入详情页解析“病虫害名、作物、症状摘要、防治说明、图片链接”,最后导出为 CSV 与 JSON 文件。
读完这篇文章,你至少能获得三样东西:
第一,你会掌握“列表页 + 详情页”的二段式采集思路,而不是只停留在单页解析。
第二,你会得到一套可运行的 Python 项目结构,包含请求层、解析层、存储层、日志、重试、去重和命令行入口。
第三,你会知道在农作物病虫害这类知识型网页上,如何处理字段不齐、图片懒加载、页面轻度动态渲染、403/429、编码乱码等实际问题。
我个人比较喜欢这类案例。它不像电商价格那样变化剧烈,也不像登录站点那样容易踩边界;它更像一个信息整理工程:把散落在网页中的农业知识结构化,后续可以用于检索、数据分析、知识库建设,甚至可以作为图像识别数据标注前的目录基础。当然,这里只讨论公开网页的低频合规采集,不讨论绕过登录、付费墙、验证码或任何不合适的访问方式。
1️⃣ 摘要(Abstract)
本文以农作物病虫害图谱目录页为案例,使用 Python、requests、BeautifulSoup、lxml、tenacity、pandas 等工具,实现“分类页采集详情链接 → 详情页抽取字段 → 清洗去重 → 导出 CSV/JSON”的完整流程,最终产出一份结构化病虫害数据表。
读完后,你可以学到:
- 如何设计一个可维护的二段式爬虫,而不是把所有逻辑塞进一个脚本里。
- 如何从列表页提取详情链接,再到详情页解析病虫害名称、作物、症状摘要、防治说明和图片链接。
- 如何处理请求失败、字段缺失、URL 去重、图片地址规范化、输出编码和日志排错。
本文会尽量把代码写成真实项目的样子。你可以直接复制到本地运行,也可以把目标站点替换成其他农业知识站点,只需要调整解析器里的 CSS 选择器或 XPath 即可。
2️⃣ 背景与需求(Why)
2.1 为什么要爬农作物病虫害图谱
农作物病虫害信息通常分散在农业技术网站、科研机构网站、植保站页面、病虫害知识库、PDF 技术手册和图谱系统中。人工查询当然可以,但当我们要做批量整理时,人工复制粘贴会非常低效,而且容易漏字段、漏图片、漏来源链接。
把公开病虫害图谱页面结构化之后,可以做很多事情:
一是做信息聚合。比如把水稻、玉米、番茄、柑橘等作物相关病虫害汇总成统一表格,方便农业技术人员、内容编辑或数据分析人员查阅。
二是做数据分析。比如统计某种作物常见病害数量、不同病虫害涉及的作物分布、关键词高频症状、防治措施文本长度等。
三是做知识库建设。结构化字段可以进一步进入 SQLite、MySQL、Elasticsearch 或向量数据库,用于检索问答、后台管理系统或知识图谱原型。
四是做图片数据索引。图谱类网站常包含病斑、虫体、受害叶片、果实受害等图片。我们不一定要立即下载图片,但至少可以先保存图片链接、详情页来源和文字说明,为后续合法使用、人工审核和数据标注做准备。
需要特别说明:病虫害防治涉及农业生产安全,本文抓取的数据仅用于技术学习与信息整理,不应直接替代当地农技人员、植保专家或官方登记用药说明。实际生产中,防治措施要结合当地法规、作物生育期、农药登记情况、安全间隔期和田间实际诊断。
2.2 目标站点与目标字段
本文以公开的农作物病虫害图谱/查询类网页为示例。实际项目中,我建议先从下面这些页面形态入手:
- 病虫害列表页
- 病虫害搜索页
- 按作物分类的目录页
- 按病害、虫害、害螨、益虫分类的目录页
- 病虫害详情页
- 防治技术详情页
本文的目标字段如下:
| 字段名 | 含义 | 说明 |
|---|---|---|
| pest_name | 病虫害名 | 如稻热病、白叶枯病、二化螟、蚜虫等 |
| crop | 作物 | 如水稻、玉米、番茄、柑橘、茶等 |
| symptom_summary | 症状摘要 | 病斑、叶片受害、茎秆受害、虫害危害表现等 |
| control_method | 防治说明 | 农业防治、物理防治、生物防治、化学防治、综合防治等 |
| image_url | 图片链接 | 详情页中的主图或图集图片链接 |
| detail_url | 详情页链接 | 用于溯源和去重 |
| source_site | 来源站点 | 方便后续多站点合并 |
| crawled_at | 抓取时间 | 数据快照时间 |
用户要求的核心字段是“病虫害名、作物、症状摘要、防治说明、图片链接”。我额外加了 detail_url、source_site、crawled_at,是因为真实爬虫项目里必须考虑溯源、去重和数据版本管理。没有来源 URL 的爬虫数据,后期排查会非常痛苦。
3️⃣ 合规与注意事项
写爬虫之前,先把边界说清楚。技术本身没有问题,关键在于怎么用。
3.1 robots.txt 基本说明
robots.txt 是网站放在根目录下的爬虫访问声明文件,通常用于告诉搜索引擎和自动化程序哪些路径可以访问,哪些路径不希望访问。
一个典型的 robots.txt 可能长这样:
User-agent: *
Disallow: /admin/
Disallow: /login/
Allow: /
Crawl-delay: 5
含义大致是:
- User-agent: * 表示对所有爬虫生效。
- Disallow 表示不希望爬虫访问的路径。
- Allow 表示允许访问的路径。
- Crawl-delay 表示建议访问间隔。
在实际采集前,建议先访问:
https://目标域名/robots.txt
如果 robots.txt 明确禁止某些路径,就不要采集这些路径。如果目标站没有 robots.txt,也不等于可以高频抓取,只能说明没有通过这个文件表达规则,依然要保持低频、少量、合规和可追踪。
在本文代码中,我会把 robots 检查做成一个提示函数:它不会强行替你判断所有法律与许可问题,但会提醒你采集前先检查站点规则。
3.2 控制频率,不要攻击式并发
爬虫最容易出问题的地方不是解析,而是请求频率。很多人一上来就开几十个线程,短时间内访问几千次页面,这种做法既不专业,也容易给对方服务器造成负担。
本文建议:
- 默认每次请求之间 sleep 1 到 3 秒。
- 单机学习项目先不要上高并发。
- 请求失败时做指数退避,而不是立即疯狂重试。
- 只抓自己需要的字段,不做全站无差别扫描。
- 测试阶段先限制页数,例如只抓前 20 条详情。
- 每次抓取都保留日志,方便出现问题时停止或回滚。
我自己的习惯是:先用 5 到 10 条数据验证解析逻辑,再扩大到几十条,最后才考虑更多页面。爬虫不是越快越好,稳定、可控、可解释才是更好的工程标准。
3.3 不采集敏感信息,不绕过限制
本文只面向公开页面的技术学习,不涉及以下行为:
- 不采集个人隐私数据。
- 不采集账号、手机号、邮箱等敏感信息。
- 不绕过登录限制。
- 不绕过付费墙。
- 不破解验证码。
- 不伪装成真实用户进行高频访问。
- 不访问后台、管理端、接口密钥或非公开资源。
- 不对目标站点做压力测试。
如果目标页面需要登录才能看到,或者数据明显属于非公开范围,就不应继续抓取。技术上能不能做到,不等于应该做。
4️⃣ 技术选型与整体流程(What / How)
4.1 静态、动态、API 三种页面
写爬虫之前,先判断页面属于哪类。
第一类是静态页面。浏览器看到的内容,在 requests 获取的 HTML 里也能找到。这种页面最好处理,用 requests + BeautifulSoup / lxml 就够了。
第二类是动态页面。requests 获取到的 HTML 是空壳,真正内容由浏览器执行 JavaScript 后渲染出来。这类页面可以考虑 Playwright、Selenium,也可以在开发者工具里寻找接口。
第三类是 API 页面。网页内容其实来自 JSON 接口,只要找到接口参数,直接请求 API 就能拿到结构化数据。这类最干净,但也要注意接口是否公开、是否有频控、是否允许自动访问。
本文的案例属于“列表页 + 详情页”的二段式抓取。页面可能存在轻度动态渲染,因此我们采用一个比较稳妥的方案:
- 默认使用 requests 获取页面。
- 使用 BeautifulSoup + lxml 解析 HTML。
- 如果列表页拿不到详情链接,可以启用 Playwright 渲染兜底。
- 存储使用 CSV 和 JSON Lines,便于查看和后续导入数据库。
- 项目保留 SQLite 扩展位置,但起步不强依赖数据库。
4.2 整体流程
整体流程可以写成下面这样:
读取配置
↓
检查 robots.txt 与目标域名
↓
构造列表页 / 搜索页 URL
↓
请求列表页 HTML
↓
解析详情页链接
↓
详情页 URL 去重
↓
逐个请求详情页
↓
解析字段:病虫害名、作物、症状、防治、图片
↓
字段清洗与容错
↓
按 detail_url 去重
↓
导出 CSV / JSONL
↓
输出日志与统计信息
对应到代码模块:
crop_pest_spider/
├── config.py # 配置
├── fetcher.py # 请求层
├── parser.py # 解析层
├── storage.py # 存储层
├── models.py # 数据模型
├── utils.py # 工具函数
├── main.py # 入口文件
├── requirements.txt # 依赖
└── data/
├── raw/ # 原始 HTML,可选
└── output/ # 输出 CSV/JSONL
4.3 为什么选择 requests + BeautifulSoup + lxml
这个项目不一上来就用 Scrapy,原因很简单:本文重点是讲清楚二段式抓取,不希望框架本身分散注意力。
requests 适合处理请求、Session、Header、Timeout。
BeautifulSoup 适合容错解析,网页结构不规整时也比较舒服。
lxml 解析速度快,配合 BeautifulSoup 使用时可以兼顾性能和易读性。
tenacity 用于重试和退避,比自己手写 while 循环更干净。
pandas 用于导出 CSV,编码、列顺序和缺失值处理更方便。
如果后续要扩大规模,再迁移到 Scrapy;如果确认页面强依赖 JavaScript,再把列表页或详情页切到 Playwright。工程上没有必要一开始就把工具堆满,先用小而清楚的方案跑通闭环,通常更稳。
5️⃣ 环境准备与依赖安装
5.1 Python 版本
建议使用 Python 3.10 或以上版本。本文代码在 Python 3.10、3.11、3.12 下都可以运行。
查看版本:
python --version
如果你的系统里同时有多个 Python,可以使用:
python3 --version
5.2 创建虚拟环境
Windows:
mkdir crop_pest_spider
cd crop_pest_spider
python -m venv .venv
.venv\Scripts\activate
macOS / Linux:
mkdir crop_pest_spider
cd crop_pest_spider
python3 -m venv .venv
source .venv/bin/activate
5.3 安装依赖
新建 requirements.txt:
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.2.2
pandas==2.2.2
tenacity==8.5.0
python-dateutil==2.9.0.post0
tqdm==4.66.4
安装:
pip install -r requirements.txt
如果你要启用 Playwright 兜底,可以额外安装:
pip install playwright==1.45.0
python -m playwright install chromium
本文主代码不强制依赖 Playwright。只有当 requests 抓不到渲染后的列表时,才建议补上。
5.4 推荐项目结构
创建目录:
mkdir -p crop_pest_spider/data/raw
mkdir -p crop_pest_spider/data/output
touch crop_pest_spider/__init__.py
touch crop_pest_spider/config.py
touch crop_pest_spider/models.py
touch crop_pest_spider/utils.py
touch crop_pest_spider/fetcher.py
touch crop_pest_spider/parser.py
touch crop_pest_spider/storage.py
touch crop_pest_spider/main.py
Windows PowerShell 可以这样创建:
mkdir crop_pest_spider
mkdir crop_pest_spider\data
mkdir crop_pest_spider\data\raw
mkdir crop_pest_spider\data\output
New-Item crop_pest_spider\__init__.py
New-Item crop_pest_spider\config.py
New-Item crop_pest_spider\models.py
New-Item crop_pest_spider\utils.py
New-Item crop_pest_spider\fetcher.py
New-Item crop_pest_spider\parser.py
New-Item crop_pest_spider\storage.py
New-Item crop_pest_spider\main.py
6️⃣ 核心实现:请求层(Fetcher)
请求层要解决的问题不是“能不能请求成功”这么简单,而是要让请求行为稳定、克制、可追踪。
本文请求层包含以下能力:
- 设置 User-Agent。
- 设置 Referer。
- 设置 timeout。
- 使用 requests.Session 复用连接。
- 支持 Cookie,但默认不使用。
- 对 429、500、502、503、504 做重试。
- 使用指数退避。
- 每次请求之间随机 sleep。
- 保存少量原始 HTML,方便排错。
6.1 配置文件 config.py
# crop_pest_spider/config.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
DATA_DIR = BASE_DIR / "data"
RAW_DIR = DATA_DIR / "raw"
OUTPUT_DIR = DATA_DIR / "output"
RAW_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
SOURCE_SITE = "农业病虫害智能管理决策系统"
# 示例目标站点。实际使用时可替换为你确认允许采集的公开站点。
BASE_URL = "https://azai.tari.gov.tw"
# 搜索页模板:term 可以换成 水稻、玉米、番茄、柑桔 等关键词。
SEARCH_URL_TEMPLATE = BASE_URL + "/search.html?stype=0&term={keyword}"
# 默认关键词。建议先小批量测试,不要一上来全量扫描。
DEFAULT_KEYWORDS = [
"水稻",
"玉米",
"番茄",
]
# 请求头。UA 不要写得太离谱,保持普通浏览器风格即可。
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.7",
"Connection": "keep-alive",
}
REQUEST_TIMEOUT = 15
# 访问间隔。学习项目建议保守一点。
MIN_DELAY = 1.2
MAX_DELAY = 3.5
# 重试配置。
RETRY_TIMES = 3
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
# 测试阶段最多抓多少条详情。None 表示不限制。
DEFAULT_MAX_DETAILS = 30
CSV_FILE = OUTPUT_DIR / "crop_pest_records.csv"
JSONL_FILE = OUTPUT_DIR / "crop_pest_records.jsonl"
# 是否保存原始 HTML。调试选择器时很有用,正式大量运行时可关闭。
SAVE_RAW_HTML = True
6.2 数据模型 models.py
用 dataclass 管理字段,比直接传字典更清楚。
# crop_pest_spider/models.py
from dataclasses import dataclass, asdict
from typing import Optional
@dataclass
class PestRecord:
pest_name: str = ""
crop: str = ""
symptom_summary: str = ""
control_method: str = ""
image_url: str = ""
detail_url: str = ""
source_site: str = ""
crawled_at: str = ""
def to_dict(self) -> dict:
return asdict(self)
def is_valid(self) -> bool:
"""
最基本的数据有效性判断。
真实项目可以更严格,比如 pest_name 和 detail_url 必须存在。
"""
return bool(self.pest_name or self.detail_url)
6.3 工具函数 utils.py
# crop_pest_spider/utils.py
import hashlib
import re
import time
import random
import logging
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urljoin, urlparse
def setup_logger(name: str = "crop_pest_spider") -> logging.Logger:
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
if logger.handlers:
return logger
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
logger = setup_logger()
def random_sleep(min_delay: float, max_delay: float) -> None:
delay = random.uniform(min_delay, max_delay)
time.sleep(delay)
def normalize_space(text: str) -> str:
if not text:
return ""
text = text.replace("\xa0", " ")
text = re.sub(r"\s+", " ", text)
return text.strip()
def normalize_url(base_url: str, href: str) -> str:
if not href:
return ""
return urljoin(base_url, href.strip())
def now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def safe_filename_from_url(url: str, suffix: str = ".html") -> str:
parsed = urlparse(url)
raw = f"{parsed.netloc}_{parsed.path}_{parsed.query}"
name = hashlib.md5(raw.encode("utf-8")).hexdigest()
return f"{name}{suffix}"
def content_hash(*values: str) -> str:
raw = "||".join(v or "" for v in values)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def save_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
6.4 请求层 fetcher.py
# crop_pest_spider/fetcher.py
from typing import Optional
import requests
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from .config import (
DEFAULT_HEADERS,
REQUEST_TIMEOUT,
RETRY_TIMES,
RETRY_STATUS_CODES,
MIN_DELAY,
MAX_DELAY,
RAW_DIR,
SAVE_RAW_HTML,
)
from .utils import logger, random_sleep, safe_filename_from_url, save_text
class FetchError(Exception):
pass
class Fetcher:
def __init__(
self,
headers: Optional[dict] = None,
timeout: int = REQUEST_TIMEOUT,
min_delay: float = MIN_DELAY,
max_delay: float = MAX_DELAY,
save_raw_html: bool = SAVE_RAW_HTML,
):
self.session = requests.Session()
self.headers = headers or DEFAULT_HEADERS.copy()
self.timeout = timeout
self.min_delay = min_delay
self.max_delay = max_delay
self.save_raw_html = save_raw_html
self.session.headers.update(self.headers)
def set_cookie(self, cookie: str) -> None:
"""
如目标公开页面不需要 cookie,就不要设置。
如果你在合规场景下需要使用自己的会话 cookie,可以通过这里设置。
"""
if cookie:
self.session.headers.update({"Cookie": cookie})
def _should_retry_by_status(self, status_code: int) -> bool:
return status_code in RETRY_STATUS_CODES
@retry(
reraise=True,
stop=stop_after_attempt(RETRY_TIMES),
wait=wait_exponential(multiplier=1, min=2, max=12),
retry=retry_if_exception_type(FetchError),
)
def get(self, url: str, referer: Optional[str] = None) -> str:
headers = self.headers.copy()
if referer:
headers["Referer"] = referer
logger.info("GET %s", url)
try:
resp = self.session.get(
url,
headers=headers,
timeout=self.timeout,
)
except requests.RequestException as exc:
raise FetchError(f"request failed: {url}, error={exc}") from exc
if self._should_retry_by_status(resp.status_code):
raise FetchError(f"retryable status={resp.status_code}, url={url}")
if resp.status_code == 403:
raise FetchError(
f"403 Forbidden: {url}. "
f"建议降低频率、检查 robots.txt、确认页面是否允许公开访问。"
)
if resp.status_code == 404:
logger.warning("404 Not Found: %s", url)
return ""
if not resp.ok:
raise FetchError(f"unexpected status={resp.status_code}, url={url}")
# requests 通常会根据响应头判断编码;如果网页中文乱码,可手动兜底。
if not resp.encoding or resp.encoding.lower() == "iso-8859-1":
resp.encoding = resp.apparent_encoding
html = resp.text
if self.save_raw_html:
filename = safe_filename_from_url(url)
save_text(RAW_DIR / filename, html)
random_sleep(self.min_delay, self.max_delay)
return html
def close(self) -> None:
self.session.close()
这里有几个小细节值得解释。
首先,timeout 一定要写。没有 timeout 的 requests 在网络抖动时可能卡很久。
其次,失败重试只针对可重试状态,比如 429、500、502、503、504。403 不建议硬重试,因为它通常代表访问被拒绝,再重试也没有意义。
第三,Referer 在详情页请求时可以设置成列表页 URL,模拟正常页面跳转路径。但这不代表可以绕过限制,只是让请求更接近普通浏览行为。
第四,保存原始 HTML 对排错很重要。解析器失效时,先看 raw HTML,而不是凭空猜网页结构。
7️⃣ 核心实现:解析层(Parser)
解析层是这类项目最容易写乱的地方。我的建议是:列表页解析和详情页解析分开写,每个函数只做一件事。
7.1 解析策略
本文采用 BeautifulSoup + CSS 选择器。
列表页主要任务:
- 找到所有详情页链接。
- 过滤无关链接。
- 补全相对 URL。
- 去重。
- 可选提取列表页上的简短摘要。
详情页主要任务:
- 提取病虫害名。
- 提取作物。
- 提取症状摘要。
- 提取防治说明。
- 提取图片链接。
- 字段缺失时返回空字符串,而不是直接报错。
7.2 解析层 parser.py
# crop_pest_spider/parser.py
import re
from typing import List, Iterable
from urllib.parse import urlparse, parse_qs, unquote
from bs4 import BeautifulSoup
from .config import BASE_URL, SOURCE_SITE
from .models import PestRecord
from .utils import normalize_space, normalize_url, now_iso, logger
DETAIL_URL_PATTERNS = [
"/search/bug/detail",
"/search/bug/detail2",
]
def make_soup(html: str) -> BeautifulSoup:
return BeautifulSoup(html or "", "lxml")
def is_detail_url(url: str) -> bool:
if not url:
return False
return any(pattern in url for pattern in DETAIL_URL_PATTERNS)
def unique_keep_order(items: Iterable[str]) -> List[str]:
seen = set()
result = []
for item in items:
if not item:
continue
if item in seen:
continue
seen.add(item)
result.append(item)
return result
def parse_list_page(html: str, base_url: str = BASE_URL) -> List[str]:
"""
从列表页/搜索页提取详情页链接。
这个函数故意写得宽松一点,因为不同图谱站点的 class 名经常会变。
"""
soup = make_soup(html)
links = []
for a in soup.select("a[href]"):
href = a.get("href", "").strip()
full_url = normalize_url(base_url, href)
if is_detail_url(full_url):
links.append(full_url)
links = unique_keep_order(links)
logger.info("parsed %d detail links from list page", len(links))
return links
def text_by_selectors(soup: BeautifulSoup, selectors: List[str]) -> str:
"""
按多个选择器尝试取文本,谁先命中就用谁。
"""
for selector in selectors:
node = soup.select_one(selector)
if node:
text = normalize_space(node.get_text(" ", strip=True))
if text:
return text
return ""
def texts_by_selectors(soup: BeautifulSoup, selectors: List[str]) -> List[str]:
result = []
for selector in selectors:
for node in soup.select(selector):
text = normalize_space(node.get_text(" ", strip=True))
if text:
result.append(text)
return unique_keep_order(result)
def extract_by_label(soup: BeautifulSoup, labels: List[str], max_chars: int = 3000) -> str:
"""
尝试从包含固定标签的文本块中抽取字段。
例如页面里可能出现:
症状:叶片产生褐色病斑……
防治方法:选用抗病品种……
"""
full_text = normalize_space(soup.get_text("\n", strip=True))
if not full_text:
return ""
# 用换行更方便做标签定位
lines = [normalize_space(x) for x in full_text.split("\n") if normalize_space(x)]
for i, line in enumerate(lines):
for label in labels:
if line.startswith(label):
value = line.replace(label, "", 1)
value = value.lstrip(":: ")
if value:
return value[:max_chars]
# 如果标签单独成行,取后面几行作为内容
tail = " ".join(lines[i + 1 : i + 5])
return tail[:max_chars]
# 再做一次宽松正则
label_pattern = "|".join(re.escape(label.rstrip("::")) for label in labels)
pattern = rf"({label_pattern})[::\s]+(.{{1,{max_chars}}})"
match = re.search(pattern, full_text)
if match:
return normalize_space(match.group(2))[:max_chars]
return ""
def extract_name_from_url(detail_url: str) -> str:
"""
detail2?name=稻熱病&plant=水稻 这类 URL 可以直接从 query 中兜底提取名称。
"""
parsed = urlparse(detail_url)
query = parse_qs(parsed.query)
name_values = query.get("name") or []
if name_values:
return normalize_space(unquote(name_values[0]))
return ""
def extract_crop_from_url(detail_url: str) -> str:
parsed = urlparse(detail_url)
query = parse_qs(parsed.query)
plant_values = query.get("plant") or []
if plant_values:
return normalize_space(unquote(plant_values[0]))
return ""
def extract_images(soup: BeautifulSoup, detail_url: str, base_url: str = BASE_URL) -> List[str]:
"""
提取详情页图片。
兼容普通 src、懒加载 data-src、data-original。
"""
image_urls = []
for img in soup.select("img"):
src = (
img.get("src")
or img.get("data-src")
or img.get("data-original")
or img.get("data-lazy-src")
or ""
).strip()
if not src:
continue
# 过滤明显的 logo、icon、base64
lower = src.lower()
if lower.startswith("data:image"):
continue
if any(x in lower for x in ["logo", "icon", "facebook", "youtube", "line"]):
continue
image_urls.append(normalize_url(base_url, src))
return unique_keep_order(image_urls)
def crop_from_text_blocks(soup: BeautifulSoup) -> str:
"""
常见页面可能会写:
防治作物
水稻
或者:
寄主植物:水稻、玉米
"""
crop = extract_by_label(
soup,
labels=[
"防治作物",
"寄主植物",
"寄主植物/防治对象",
"作物",
"適用作物",
"适用作物",
],
max_chars=800,
)
if crop:
return crop
candidates = texts_by_selectors(
soup,
[
".crop",
".plant",
".host",
".tag",
".badge",
".breadcrumb li",
"table td",
"li",
],
)
keywords = ["水稻", "玉米", "番茄", "柑", "茶", "甘藷", "小麦", "大豆", "蔬菜", "瓜"]
picked = []
for text in candidates:
if any(k in text for k in keywords) and len(text) <= 120:
picked.append(text)
return ";".join(unique_keep_order(picked[:5]))
def parse_detail_page(html: str, detail_url: str, base_url: str = BASE_URL) -> PestRecord:
soup = make_soup(html)
# 1. 病虫害名
pest_name = text_by_selectors(
soup,
[
"h1",
"h2",
".title",
".detail-title",
".bug-title",
".pest-title",
".card-title",
],
)
if not pest_name:
pest_name = extract_by_label(
soup,
labels=[
"病蟲害名稱",
"病虫害名称",
"病蟲害名",
"病虫害名",
"名稱",
"名称",
],
max_chars=120,
)
if not pest_name:
pest_name = extract_name_from_url(detail_url)
# 清理标题里可能混入的站点名
pest_name = normalize_space(
pest_name
.replace("農業病蟲害智能管理決策系統", "")
.replace("农业病虫害智能管理决策系统", "")
)
# 2. 作物
crop = crop_from_text_blocks(soup)
if not crop:
crop = extract_crop_from_url(detail_url)
# 3. 症状摘要
symptom_summary = extract_by_label(
soup,
labels=[
"危害狀",
"危害症狀",
"危害症状",
"病徵",
"病征",
"症狀",
"症状",
"發生生態",
"发生生态",
"危害部位",
"危害特徵",
"危害特征",
],
max_chars=1200,
)
if not symptom_summary:
# 兜底:从正文里取较长段落
paragraphs = texts_by_selectors(
soup,
[
".content p",
".detail-content p",
".article p",
"main p",
"section p",
"p",
],
)
useful = [p for p in paragraphs if 30 <= len(p) <= 800]
symptom_summary = useful[0] if useful else ""
# 4. 防治说明
control_method = extract_by_label(
soup,
labels=[
"防治方法",
"防治措施",
"防治說明",
"防治说明",
"管理方法",
"綜合防治",
"综合防治",
"用藥資訊",
"用药信息",
"推薦防治",
"推荐防治",
],
max_chars=1800,
)
if not control_method:
# 兜底:收集包含“防治/用药/管理/药剂”等关键词的段落
paragraphs = texts_by_selectors(
soup,
[
".content p",
".detail-content p",
".article p",
"main p",
"section p",
"p",
"li",
"td",
],
)
picked = []
for p in paragraphs:
if any(k in p for k in ["防治", "用藥", "用药", "藥劑", "药剂", "管理", "施用"]):
picked.append(p)
control_method = ";".join(unique_keep_order(picked[:6]))[:1800]
# 5. 图片
images = extract_images(soup, detail_url=detail_url, base_url=base_url)
image_url = ";".join(images)
record = PestRecord(
pest_name=pest_name,
crop=crop,
symptom_summary=symptom_summary,
control_method=control_method,
image_url=image_url,
detail_url=detail_url,
source_site=SOURCE_SITE,
crawled_at=now_iso(),
)
return record
7.3 为什么解析器要写得“宽松”
真实网站经常改版。今天详情页标题是 h1,下个月可能改成 .detail-title;今天图片写在 src,明天可能改成 data-src;今天字段叫“症状”,另一个站点可能叫“危害症状”或“病征”。
所以解析器不要只写一个选择器:
soup.select_one(".title").text
这种写法在学习阶段看起来很干净,但实际维护成本很高。更稳的写法是维护一组候选选择器和一组候选标签,谁命中就用谁。
7.4 缺失字段怎么办
缺失字段不应该导致程序崩溃。比如有些病虫害详情页没有图片,有些详情页只有防治用药信息,有些列表页标题写得不完整。
本文策略是:
- 病虫害名优先从标题取,取不到再从标签取,再取不到从 URL query 取。
- 作物优先从正文标签取,取不到再从 URL 的 plant 参数取。
- 症状摘要取不到时,从正文第一段较长文本兜底。
- 防治说明取不到时,从包含“防治、用药、管理”等关键词的段落中拼接。
- 图片取不到时保持空字符串。
- detail_url 永远保留,方便后续人工回查。
这样得到的数据不是百分之百完美,但不会因为一两个页面结构异常导致整个任务失败。爬虫项目的第一阶段目标是“稳定产出可回查的数据”,第二阶段才是“逐步提高字段质量”。
8️⃣ 数据存储与导出(Storage)
本文选择 CSV + JSON Lines 作为起步格式。
CSV 适合用 Excel、WPS、LibreOffice 或 pandas 直接查看。
JSON Lines 适合后续导入数据库、搜索引擎或队列系统,每行一个 JSON 对象,不怕单个字段里出现换行。
8.1 字段映射表
| 输出字段 | Python 字段 | 类型 | 示例值 |
|---|---|---|---|
| 病虫害名 | pest_name | str | 稻热病 |
| 作物 | crop | str | 水稻 |
| 症状摘要 | symptom_summary | str | 叶片出现病斑,严重时影响抽穗和结实 |
| 防治说明 | control_method | str | 选用抗病品种,加强田间管理,必要时按登记说明用药 |
| 图片链接 | image_url | str | https://example.com/images/rice_blast.jpg |
| 详情页 | detail_url | str | https://example.com/search/bug/detail?id=1 |
| 来源站点 | source_site | str | 农业病虫害智能管理决策系统 |
| 抓取时间 | crawled_at | str | 2026-06-09T10:30:00+08:00 |
8.2 去重策略
本项目采用两层去重。
第一层是 URL 去重。详情页 URL 相同的记录只保留一条。
第二层是内容 hash 去重。如果不同 URL 的 pest_name、crop、symptom_summary 内容完全一致,可以认为是重复内容。本文示例先使用 URL 去重,内容 hash 留作扩展。
真实项目中,我建议这样做:
- 对 detail_url 做唯一约束。
- 对 pest_name + crop 做辅助去重。
- 对正文内容生成 hash,发现疑似重复时人工复核。
- 不要只用 pest_name 去重,因为同一个病虫害可能对应多个作物。
8.3 存储层 storage.py
# crop_pest_spider/storage.py
import json
from pathlib import Path
from typing import List, Iterable
import pandas as pd
from .models import PestRecord
from .utils import logger, content_hash
FIELD_ORDER = [
"pest_name",
"crop",
"symptom_summary",
"control_method",
"image_url",
"detail_url",
"source_site",
"crawled_at",
]
CHINESE_COLUMNS = {
"pest_name": "病虫害名",
"crop": "作物",
"symptom_summary": "症状摘要",
"control_method": "防治说明",
"image_url": "图片链接",
"detail_url": "详情页",
"source_site": "来源站点",
"crawled_at": "抓取时间",
}
def dedupe_by_url(records: Iterable[PestRecord]) -> List[PestRecord]:
seen = set()
result = []
for record in records:
key = record.detail_url or content_hash(
record.pest_name,
record.crop,
record.symptom_summary,
)
if key in seen:
continue
seen.add(key)
result.append(record)
return result
def save_jsonl(records: List[PestRecord], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
for record in records:
f.write(json.dumps(record.to_dict(), ensure_ascii=False) + "\n")
logger.info("saved jsonl: %s, rows=%d", path, len(records))
def save_csv(records: List[PestRecord], path: Path, chinese_header: bool = True) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
rows = [record.to_dict() for record in records]
df = pd.DataFrame(rows)
for field in FIELD_ORDER:
if field not in df.columns:
df[field] = ""
df = df[FIELD_ORDER]
if chinese_header:
df = df.rename(columns=CHINESE_COLUMNS)
# utf-8-sig 方便 Excel 直接打开不乱码。
df.to_csv(path, index=False, encoding="utf-8-sig")
logger.info("saved csv: %s, rows=%d", path, len(records))
9️⃣ 运行方式与结果展示
9.1 入口文件 main.py
# crop_pest_spider/main.py
import argparse
from typing import List
from urllib.parse import quote
from tqdm import tqdm
from .config import (
BASE_URL,
SEARCH_URL_TEMPLATE,
DEFAULT_KEYWORDS,
DEFAULT_MAX_DETAILS,
CSV_FILE,
JSONL_FILE,
)
from .fetcher import Fetcher, FetchError
from .parser import parse_list_page, parse_detail_page
from .storage import dedupe_by_url, save_csv, save_jsonl
from .utils import logger, normalize_url
def build_search_urls(keywords: List[str]) -> List[str]:
urls = []
for keyword in keywords:
encoded = quote(keyword)
urls.append(SEARCH_URL_TEMPLATE.format(keyword=encoded))
return urls
def collect_detail_urls(fetcher: Fetcher, keywords: List[str]) -> List[str]:
all_detail_urls = []
search_urls = build_search_urls(keywords)
for search_url in search_urls:
try:
html = fetcher.get(search_url, referer=BASE_URL)
except FetchError as exc:
logger.warning("failed to fetch list page: %s", exc)
continue
detail_urls = parse_list_page(html, base_url=BASE_URL)
logger.info("keyword page=%s, detail_urls=%d", search_url, len(detail_urls))
all_detail_urls.extend(detail_urls)
# URL 去重,保持顺序
seen = set()
unique_urls = []
for url in all_detail_urls:
url = normalize_url(BASE_URL, url)
if url in seen:
continue
seen.add(url)
unique_urls.append(url)
return unique_urls
def crawl_details(fetcher: Fetcher, detail_urls: List[str]) -> list:
records = []
for detail_url in tqdm(detail_urls, desc="Crawling detail pages"):
try:
html = fetcher.get(detail_url, referer=BASE_URL)
except FetchError as exc:
logger.warning("failed to fetch detail page: %s", exc)
continue
if not html:
continue
record = parse_detail_page(html, detail_url=detail_url, base_url=BASE_URL)
if record.is_valid():
records.append(record)
else:
logger.warning("invalid record, url=%s", detail_url)
return records
def main() -> None:
parser = argparse.ArgumentParser(
description="Crop pest atlas spider: list page + detail page crawler."
)
parser.add_argument(
"--keywords",
nargs="*",
default=DEFAULT_KEYWORDS,
help="Search keywords, e.g. 水稻 玉米 番茄",
)
parser.add_argument(
"--max-details",
type=int,
default=DEFAULT_MAX_DETAILS,
help="Max detail pages to crawl. Use 0 for no limit.",
)
parser.add_argument(
"--csv",
default=str(CSV_FILE),
help="CSV output path.",
)
parser.add_argument(
"--jsonl",
default=str(JSONL_FILE),
help="JSONL output path.",
)
args = parser.parse_args()
keywords = args.keywords
max_details = args.max_details
logger.info("start crawling, keywords=%s", keywords)
fetcher = Fetcher()
try:
detail_urls = collect_detail_urls(fetcher, keywords)
logger.info("collected detail urls=%d", len(detail_urls))
if max_details and max_details > 0:
detail_urls = detail_urls[:max_details]
logger.info("limited detail urls=%d", len(detail_urls))
records = crawl_details(fetcher, detail_urls)
records = dedupe_by_url(records)
save_csv(records, path=args.csv)
save_jsonl(records, path=args.jsonl)
logger.info("done, records=%d", len(records))
finally:
fetcher.close()
if __name__ == "__main__":
main()
9.2 运行命令
在项目根目录执行:
python -m crop_pest_spider.main --keywords 水稻 玉米 --max-details 20
指定输出路径:
python -m crop_pest_spider.main \
--keywords 水稻 玉米 番茄 \
--max-details 50 \
--csv crop_pest_spider/data/output/rice_corn_tomato_pests.csv \
--jsonl crop_pest_spider/data/output/rice_corn_tomato_pests.jsonl
Windows PowerShell:
python -m crop_pest_spider.main --keywords 水稻 玉米 --max-details 20
9.3 输出位置
默认输出:
crop_pest_spider/data/output/crop_pest_records.csv
crop_pest_spider/data/output/crop_pest_records.jsonl
如果开启了保存原始 HTML,会看到:
crop_pest_spider/data/raw/
里面是按 URL hash 命名的 HTML 文件。解析失败时,优先打开这些文件检查页面结构。
9.4 示例结果
CSV 结果大致如下:
| 病虫害名 | 作物 | 症状摘要 | 防治说明 | 图片链接 |
|---|---|---|---|---|
| 稻热病 | 水稻 | 叶片、节位、穗颈等部位可能出现病斑,严重时影响植株生长和结实。 | 选用抗病品种,合理施肥,注意田间湿度管理,必要时按当地登记药剂说明进行防治。 | https://example.com/rice_blast.jpg |
| 白叶枯病 | 水稻 | 叶缘或叶尖出现黄白色条斑,后期病斑扩大,严重时叶片枯白。 | 加强种子处理和水肥管理,避免串灌漫灌,发病初期及时采取综合防治措施。 | https://example.com/bacterial_leaf_blight.jpg |
| 二化螟 | 水稻 | 幼虫蛀入茎秆,造成枯心、白穗等危害表现。 | 清理田间残株,保护天敌,结合虫情监测选择合适时期防治。 | https://example.com/stem_borer.jpg |
| 蚜虫 | 玉米 | 群集在叶片、嫩茎等部位吸食汁液,可能造成叶片卷曲、生长受阻。 | 合理密植,保护天敌,虫量较高时按推荐方法处理。 | https://example.com/aphid.jpg |
| 斜纹夜蛾 | 番茄 | 幼虫取食叶片、花和果实,严重时造成缺刻、孔洞或果面损伤。 | 结合诱捕、人工摘除卵块、保护天敌和适期防治进行综合管理。 | https://example.com/spodoptera_litura.jpg |
上面是展示格式示例。实际字段内容以目标页面解析结果为准,不建议凭空补防治建议到正式数据里。正式运行时,程序会把抓到的原文片段写入表格。
🔟 常见问题与排错
10.1 403 怎么办
403 表示服务器拒绝访问。常见原因有:
- 请求头过于简单。
- 访问频率太高。
- 目标路径不希望被自动访问。
- 页面需要登录或特定会话。
- IP 被临时限制。
建议处理顺序:
第一,先降低频率,把 MIN_DELAY 和 MAX_DELAY 调大。
MIN_DELAY = 3
MAX_DELAY = 8
第二,检查 robots.txt 和网站声明,确认目标路径是否适合采集。
第三,补充正常 UA、Accept-Language、Referer,但不要伪造登录状态,也不要绕过限制。
第四,减少抓取规模。先抓 5 条详情页,不要一上来批量跑。
第五,如果页面明确不允许访问,就停止采集。
代理不是万能药。学习项目里不建议一遇到 403 就上代理池,这通常会把问题复杂化,也容易偏离合规边界。
10.2 429 怎么办
429 表示 Too Many Requests,请求太多了。处理方法是:
- 降低访问频率。
- 增加随机 sleep。
- 减少并发。
- 开启指数退避。
- 分批次运行。
- 避免重复抓同一批 URL。
本文的 tenacity 重试已经会对 429 做退避,但如果频繁出现 429,根本原因还是访问节奏太快。
可以把配置改成:
MIN_DELAY = 5
MAX_DELAY = 12
RETRY_TIMES = 2
不要为了追求速度,把对方服务器当成压测对象。爬虫写得慢一点,往往寿命更长。
10.3 HTML 抓到空壳怎么办
如果 requests 抓到的 HTML 里没有列表数据,浏览器里却能看到内容,通常说明页面是 JavaScript 动态渲染。
排查方法:
第一,保存 raw HTML,搜索病虫害名称。如果 HTML 里完全没有数据,大概率是动态渲染。
第二,打开浏览器开发者工具 Network 面板,刷新页面,查看 XHR / Fetch 请求,看看是否有 JSON 接口。
第三,如果能找到公开 JSON 接口,并且接口不需要登录、不涉及敏感信息,可以考虑请求接口。
第四,如果找不到接口,但页面公开且允许访问,可以用 Playwright 渲染页面后再解析。
下面是一个 Playwright 兜底示例:
# optional_playwright_fetch.py
from playwright.sync_api import sync_playwright
def render_html(url: str, wait_ms: int = 3000) -> str:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0 Safari/537.36"
)
)
page.goto(url, wait_until="networkidle", timeout=30000)
page.wait_for_timeout(wait_ms)
html = page.content()
browser.close()
return html
if __name__ == "__main__":
url = "https://azai.tari.gov.tw/search.html?stype=0&term=%E6%B0%B4%E7%A8%BB"
html = render_html(url)
print(html[:1000])
Playwright 不是用来绕过限制的,它只是浏览器自动化工具。能不用就不用,因为它更慢、更重,也更容易给服务器造成额外压力。
10.4 解析报错怎么办
解析报错通常有三类。
第一类是选择器失效。比如原来标题是 .title,现在改成 .page-title。解决方式是打开 raw HTML,重新定位字段。
第二类是字段不存在。比如某个页面没有图片,soup.select_one("img") 返回 None。解决方式是所有字段都要做空值判断。
第三类是页面结构不统一。比如病害详情和虫害详情模板不同。解决方式是按类型拆解析器,或者增加多组候选选择器。
不建议这样写:
title = soup.select_one(".title").text.strip()
更建议这样写:
node = soup.select_one(".title")
title = node.get_text(strip=True) if node else ""
10.5 编码乱码怎么办
中文网页乱码很常见。requests 会根据响应头猜编码,但不一定准确。
可以这样处理:
resp = requests.get(url, headers=headers, timeout=15)
if not resp.encoding or resp.encoding.lower() == "iso-8859-1":
resp.encoding = resp.apparent_encoding
html = resp.text
CSV 乱码则通常是 Excel 打开方式的问题。建议导出时使用 utf-8-sig:
df.to_csv("output.csv", index=False, encoding="utf-8-sig")
这样 Windows Excel 通常能直接识别中文。
10.6 图片链接抓不到怎么办
图片抓不到的原因可能有:
- 图片使用懒加载,真实地址在 data-src。
- 图片是相对路径,需要 urljoin 补全。
- 图片通过 CSS background-image 加载。
- 图片需要浏览器渲染后才出现。
- 页面用了图集插件,图片地址在 script 里。
本文已经兼容了常见写法:
src = (
img.get("src")
or img.get("data-src")
or img.get("data-original")
or img.get("data-lazy-src")
or ""
)
如果图片写在 CSS 里,可以补一个正则:
import re
def extract_background_images(html: str, base_url: str) -> list:
urls = []
pattern = r"background-image\s*:\s*url\(['\"]?(.*?)['\"]?\)"
for match in re.findall(pattern, html, flags=re.I):
urls.append(urljoin(base_url, match))
return urls
10.7 列表页没有详情链接怎么办
如果 parse_list_page 返回 0,有几个可能:
- 页面是动态渲染,requests HTML 没有真实列表。
- 详情链接不是
/search/bug/detail这种模式。 - 链接在按钮事件、script 或 JSON 里。
- 搜索关键词没有结果。
- 页面需要先点“直接进入首页”之类的入口。
排查顺序:
- 打开
data/raw里的 HTML。 - 搜索关键词,比如“水稻”“稻热病”。
- 搜索
href=,看是否有详情链接。 - 搜索
detail、bug、id=。 - 如果 raw HTML 没数据,就用浏览器开发者工具看接口。
- 如果是 JS 渲染,考虑 Playwright 兜底。
1️⃣1️⃣ 进阶优化
11.1 并发抓取
当你确认目标站点允许、请求频率合适、数据量也确实较大时,可以考虑并发。但并发不是越高越好。
线程池示例:
# crop_pest_spider/concurrent_runner.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from .fetcher import Fetcher, FetchError
from .parser import parse_detail_page
from .storage import dedupe_by_url, save_csv, save_jsonl
from .config import BASE_URL, CSV_FILE, JSONL_FILE
from .utils import logger
def fetch_one(detail_url: str):
fetcher = Fetcher(min_delay=1.5, max_delay=4.0)
try:
html = fetcher.get(detail_url, referer=BASE_URL)
if not html:
return None
return parse_detail_page(html, detail_url=detail_url, base_url=BASE_URL)
except FetchError as exc:
logger.warning("failed: %s, error=%s", detail_url, exc)
return None
finally:
fetcher.close()
def crawl_concurrent(detail_urls: list, max_workers: int = 3):
records = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(fetch_one, url): url
for url in detail_urls
}
for future in as_completed(future_map):
url = future_map[future]
try:
record = future.result()
except Exception as exc:
logger.warning("unexpected error: %s, url=%s", exc, url)
continue
if record and record.is_valid():
records.append(record)
records = dedupe_by_url(records)
save_csv(records, CSV_FILE)
save_jsonl(records, JSONL_FILE)
return records
我个人建议学习阶段 max_workers 不要超过 3。农业知识站点通常不是为高并发抓取设计的,低频慢跑更稳。
11.2 断点续跑
断点续跑的思路是保存已抓 URL。下次运行时,跳过已抓过的详情页。
# crop_pest_spider/checkpoint.py
from pathlib import Path
class Checkpoint:
def __init__(self, path: Path):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
self.done = self._load()
def _load(self) -> set:
if not self.path.exists():
return set()
return set(
line.strip()
for line in self.path.read_text(encoding="utf-8").splitlines()
if line.strip()
)
def contains(self, url: str) -> bool:
return url in self.done
def add(self, url: str) -> None:
if url in self.done:
return
with self.path.open("a", encoding="utf-8") as f:
f.write(url + "\n")
self.done.add(url)
使用方式:
from pathlib import Path
from crop_pest_spider.checkpoint import Checkpoint
checkpoint = Checkpoint(Path("crop_pest_spider/data/output/done_urls.txt"))
for detail_url in detail_urls:
if checkpoint.contains(detail_url):
continue
# 抓取详情页
# ...
checkpoint.add(detail_url)
断点续跑是非常实用的。爬虫跑到一半断网、电脑休眠、页面报错,都不至于从头再来。
11.3 日志与监控
日志至少要记录:
- 开始时间。
- 关键词。
- 列表页数量。
- 详情页数量。
- 成功条数。
- 失败 URL。
- 失败原因。
- 输出文件路径。
更进一步,可以统计成功率:
total = len(detail_urls)
success = len(records)
failed = total - success
rate = success / total * 100 if total else 0
logger.info(
"summary: total=%d, success=%d, failed=%d, success_rate=%.2f%%",
total,
success,
failed,
rate,
)
如果后续部署到服务器,可以把日志写入文件:
import logging
from pathlib import Path
log_path = Path("crop_pest_spider/data/output/spider.log")
file_handler = logging.FileHandler(log_path, encoding="utf-8")
logger.addHandler(file_handler)
11.4 定时任务
如果你只是做一次数据整理,不需要定时任务。
如果你要定期更新,可以用 cron。
Linux crontab 示例:
crontab -e
写入:
0 3 * * 1 cd /path/to/project && /path/to/project/.venv/bin/python -m crop_pest_spider.main --keywords 水稻 玉米 --max-details 50 >> spider.log 2>&1
表示每周一凌晨 3 点运行一次。
Windows 可以用“任务计划程序”,动作里填:
程序:C:\path\to\project\.venv\Scripts\python.exe
参数:-m crop_pest_spider.main --keywords 水稻 玉米 --max-details 50
起始于:C:\path\to\project
11.5 SQLite 存储扩展
CSV 适合起步,但如果你要做增量更新,SQLite 更舒服。
# crop_pest_spider/sqlite_storage.py
import sqlite3
from pathlib import Path
from typing import List
from .models import PestRecord
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS crop_pest_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pest_name TEXT,
crop TEXT,
symptom_summary TEXT,
control_method TEXT,
image_url TEXT,
detail_url TEXT UNIQUE,
source_site TEXT,
crawled_at TEXT
);
"""
INSERT_SQL = """
INSERT OR REPLACE INTO crop_pest_records (
pest_name,
crop,
symptom_summary,
control_method,
image_url,
detail_url,
source_site,
crawled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?);
"""
def save_to_sqlite(records: List[PestRecord], db_path: Path) -> None:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
try:
conn.execute(CREATE_TABLE_SQL)
rows = [
(
r.pest_name,
r.crop,
r.symptom_summary,
r.control_method,
r.image_url,
r.detail_url,
r.source_site,
r.crawled_at,
)
for r in records
]
conn.executemany(INSERT_SQL, rows)
conn.commit()
finally:
conn.close()
查询:
import sqlite3
conn = sqlite3.connect("crop_pest_spider/data/output/crop_pest.db")
for row in conn.execute("SELECT pest_name, crop, detail_url FROM crop_pest_records LIMIT 10"):
print(row)
conn.close()
11.6 迁移到 Scrapy
当你遇到这些需求时,可以考虑 Scrapy:
- URL 数量很多。
- 需要统一调度。
- 需要下载中间件。
- 需要自动限速。
- 需要失败重试队列。
- 需要更规范的数据管道。
- 需要和数据库深度集成。
Scrapy 的结构大概是:
scrapy_project/
├── spiders/
│ └── crop_pest.py
├── items.py
├── pipelines.py
├── middlewares.py
└── settings.py
Scrapy Spider 伪代码:
import scrapy
class CropPestItem(scrapy.Item):
pest_name = scrapy.Field()
crop = scrapy.Field()
symptom_summary = scrapy.Field()
control_method = scrapy.Field()
image_url = scrapy.Field()
detail_url = scrapy.Field()
source_site = scrapy.Field()
crawled_at = scrapy.Field()
class CropPestSpider(scrapy.Spider):
name = "crop_pest"
allowed_domains = ["azai.tari.gov.tw"]
start_urls = [
"https://azai.tari.gov.tw/search.html?stype=0&term=%E6%B0%B4%E7%A8%BB"
]
custom_settings = {
"DOWNLOAD_DELAY": 3,
"RANDOMIZE_DOWNLOAD_DELAY": True,
"CONCURRENT_REQUESTS": 2,
"ROBOTSTXT_OBEY": True,
"FEED_EXPORT_ENCODING": "utf-8-sig",
}
def parse(self, response):
for href in response.css("a::attr(href)").getall():
if "/search/bug/detail" in href:
yield response.follow(href, callback=self.parse_detail)
def parse_detail(self, response):
yield CropPestItem(
pest_name=response.css("h1::text, h2::text, .title::text").get(default="").strip(),
crop="",
symptom_summary="",
control_method="",
image_url=";".join(response.css("img::attr(src)").getall()),
detail_url=response.url,
source_site="农业病虫害智能管理决策系统",
crawled_at="",
)
Scrapy 更适合规模化,但初学者最好先用 requests 把流程吃透,再迁移。框架不是捷径,它只是把成熟爬虫里常见的模块封装好了。
1️⃣2️⃣ 总结与延伸阅读
这篇文章完成了一个农作物病虫害图谱目录页的二段式采集案例。
我们从需求开始,明确要抓的字段是:
- 病虫害名
- 作物
- 症状摘要
- 防治说明
- 图片链接
然后搭建了一个可复现的 Python 项目,拆成请求层、解析层、存储层、入口文件和工具函数。请求层处理 headers、referer、timeout、session、重试和退避;解析层处理列表页详情链接、详情页字段抽取、图片链接提取和缺失字段容错;存储层处理 CSV、JSONL、字段顺序、中文表头和 URL 去重。
这个项目最核心的价值,不在于某个选择器写得多漂亮,而在于它把爬虫工程里真正容易出问题的部分都留了位置:
- 请求失败怎么办。
- 页面结构变了怎么办。
- 字段缺失怎么办。
- 图片懒加载怎么办。
- 列表页动态渲染怎么办。
- 重复 URL 怎么办。
- 数据怎么回查。
- 怎么控制访问频率。
- 怎么从一次性脚本升级成可维护项目。
下一步可以继续做三件事。
第一,接入 Playwright。用于处理列表页必须浏览器渲染的场景,但要保持低频访问。
第二,迁移到 Scrapy。当 URL 数量变多、任务需要稳定调度时,Scrapy 的下载器、中间件、管道和日志会更省心。
第三,做数据质量增强。比如按作物标准化名称,按病害/虫害分类,提取防治措施关键词,给图片建立本地索引,或者把详情页文本进入 Elasticsearch 做检索。
我一直觉得,爬虫最有意思的地方不只是“把网页抓下来”,而是把网页背后的知识结构整理出来。农作物病虫害图谱这个案例就很典型:网页是半结构化的,字段并不总是规整,但只要请求克制、解析稳健、存储清楚,就能把原本分散的信息变成一份可以分析、可以检索、可以复用的数据资产。
最后再强调一次:本文代码用于公开网页的数据整理学习。实际采集前,请检查目标站点规则,控制访问频率,不采集敏感信息,不绕过登录或付费限制。技术分享的边界守住了,项目才能跑得久,也更值得复用。
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~
✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 “谁使用,谁负责” 。如不同意,请立即停止使用并删除本项目。!!!
更多推荐
所有评论(0)