☰
Python专利爬虫:列表翻页、详情抽取与PDF批量下载
2026/10/26 5:09:05 网站建设 项目流程

简介:面向知识产权研究、数据分析与法律服务场景的Python专利采集项目,适合已掌握requests、BeautifulSoup等基础,希望进阶到分层架构与请求调度实践的中高级爬虫学习者。项目以中介者模式协调抓取组件,通过控制请求间隔、异常处理与并发压力,降低目标站点无响应或封禁风险,并覆盖专利元数据采集、清洗与Excel结果导出的完整链路。压缩包共56个文件,约17.36MB,以23个py源码和22个pyc字节码为主,辅以8个xml配置、1个md说明及iml、exe等运行辅助文件;service、controller、util、entity、enums等目录分别承载业务逻辑、进度控制、通用工具、数据模型与常量定义。已有5499人学习下载,可据此理解README所述流程,拆解请求调度、等待引擎、文件与表格处理等模块,并借鉴其异常处理与结构组织思路,用于专利检索、趋势分析和竞品监控等课程设计或小型数据项目。

1. 从一条条点 PDF,到一条命令跑完一批:这个专利爬虫解决什么

专利检索页有个很现实的问题:列表一页 20 条,翻到第八页想批量拿 PDF,只能一条条点开、一条条另存,文件名还得自己拼。这份 python 专利爬虫下载项目针对的就是这段重复劳动——给它一组关键词或申请号,它把列表页翻完、详情页字段抽干净、PDF 落到本地目录,跑到一半断了能接着往下跑。技术上是很典型的爬虫 python 组合:requests 管请求,lxml 管解析,SQLite 管去重和断点,pandas 管导出。适合三类人:做专利分析的运营和研究员、需要批量建库的学生、以及想拿一个真实站点练手的后端新手。它不追求大而全的分布式,代码量控制在一千行出头,是那种「下午能读完、晚上能改」的体量。

2. 目录结构与技术选型:请求、解析、落盘三层怎么拆

项目拆模块的思路很朴素:一个模块只干一件事,相邻模块之间只传普通字典,不传 Response 对象,也不传解析器实例。这么做的直接好处是调试成本低——列表页解析错了,改parser_list.py就行,不用动下载逻辑;换成另一个检索站点,也只需要替换解析层,请求层和存储层原样复用。整套代码分四个包:crawler负责跟站点打交道,storage负责落库和导出,utils放限速和日志这类横切能力,config放配置,main.py只做参数解析和流程编排。没有框架,没有 ORM,也没有任务队列——单人跑几十万条数据,这套就够了,多引入一层反而增加排查难度。

2.1 目录清单与每个文件的职责

patent_crawler/ ├── config/ │ ├── settings.yaml # 站点地址、并发、超时、输出目录 │ └── selectors.yaml # 各站点的 XPath 集合,换站点只改这里 ├── crawler/ │ ├── fetcher.py # 会话复用、重试、编码修正 │ ├── parser_list.py # 列表页:条目 + 下一页 │ ├── parser_detail.py # 详情页:字段抽取 + PDF 地址定位 │ ├── downloader.py # PDF 流式下载、文件头校验、原子落盘 │ └── pipeline.py # 编排:翻页 → 详情 → 入库 → 排队下载 ├── storage/ │ ├── db.py # SQLite 建表、去重、断点标记 │ └── exporter.py # 导出 CSV / JSON ├── utils/ │ ├── ratelimit.py # 最小间隔 + 随机抖动 │ └── logger.py # 按天切分,写文件同时打屏 ├── data/ # 默认输出目录(pdf/ 和 export/ 两个子目录) ├── main.py └── requirements.txt

config和代码分离是这份源码里最值得学的设计。selectors.yaml把 XPath 全部抽出来,一个站点一段,改版时不用重新读一遍 py 文件,直接对着浏览器复制新 XPath 就行。fetcher.py里没有任何 XPath,parser_list.py里没有任何requests调用,两个模块的边界非常清晰。

2.2 依赖装好之后,配置项决定跑得快还是被封

依赖只有五个,全部是纯 Python 或带预编译轮子的包,装起来不会卡在编译上:

python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # requirements.txt 大致是: # requests / lxml / PyYAML / pandas / tqdm

真正影响运行效果的是settings.yaml,几个参数的含义和调法值得单独说:

site: base_url: "https://patent.example.com" list_path: "/search?q={kw}&page={page}" detail_prefix: "https://patent.example.com" request: timeout: 15 retries: 3 min_interval: 1.2 # 两次请求之间的最小间隔,秒 jitter: 0.8 # 在该值范围内随机加延迟,避免固定节奏 page_size: 20 download: save_dir: "./data/pdf" max_workers: 3 # PDF 下载并发,别和请求间隔冲突 max_retry: 2 crawl: max_page: 50 # 单个关键词最多翻多少页,兜底防死循环

min_interval是最需要按站点调的一个数:列表页通常松一些,1.2 秒能撑住;详情页如果返回体大、后端慢,就加到 2 秒以上。max_workers建议保持 3 以内,下载并发拉高以后请求间隔就失去意义了,站点照样能看出异常。max_page是后悔药——列表页的「下一页」按钮在某几页会一直存在(返回的还是最后一页内容),没有这个上限,脚本会空转到天亮。改完配置直接跑:

python main.py --keyword "锂电池 隔膜" --pages 20 --out ./data

命令行参数优先级高于 yaml,临时试跑时不必回去改配置文件。

2.3 三张表撑起去重、断点和下载队列

很多人写爬虫不落库,全存内存,跑到一半异常退出就白干。这个项目在第一次运行时建三张表,分别解决三个问题:patent存字段并靠主键去重,pdf_task当下载队列用,crawl_page记录哪一页已经翻过。

# storage/db.py SCHEMA = """ CREATE TABLE IF NOT EXISTS patent ( patent_id TEXT PRIMARY KEY, -- 申请号或公开号,全库唯一 title TEXT, apply_no TEXT, pub_no TEXT, apply_date TEXT, applicant TEXT, inventor TEXT, abstract TEXT, status TEXT, pdf_url TEXT, detail_url TEXT, fetched_at TEXT ); CREATE TABLE IF NOT EXISTS pdf_task ( patent_id TEXT PRIMARY KEY, pdf_url TEXT, file_path TEXT, state TEXT DEFAULT 'pending', -- pending / done / failed retry INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS crawl_page ( keyword TEXT, page_no INTEGER, PRIMARY KEY (keyword, page_no) -- 联合主键,天然防重复 ); """ def save_patent(conn, item): conn.execute(""" INSERT INTO patent (patent_id, title, apply_no, pub_no, apply_date, applicant, inventor, abstract, status, pdf_url, detail_url, fetched_at) VALUES (:patent_id, :title, :apply_no, :pub_no, :apply_date, :applicant, :inventor, :abstract, :status, :pdf_url, :detail_url, :fetched_at) ON CONFLICT(patent_id) DO UPDATE SET title = excluded.title, abstract = excluded.abstract, status = excluded.status """, item)

ON CONFLICT ... DO UPDATE这段是关键:同一件专利可能因为不同关键词被命中两次,第二次进来时只更新可变字段(标题、摘要、法律状态),不动fetched_at之外的东西,也不会插出重复行。pdf_task用state字段做状态机,中断重启后只捞state != 'done'的记录继续,这就是断点续传的落地点。crawl_page配合SELECT 1 FROM crawl_page WHERE keyword=? AND page_no=?实现翻页级别的跳过,比记「当前第几页」这种整数靠谱得多——中途手动补抓第 5 页,也不会把后面的进度搞乱。

3. 抓取链路:请求节流、列表页解析与翻页终止

这一层是整个项目最容易翻车的地方。逻辑本身不复杂,麻烦都出在边界情况:编码混用、相对链接、下一页按钮消失、返回 200 但内容是错误页。代码写得糙一点,跑前 200 条没问题,第 201 条开始全是脏数据,这时候再回头查就很痛苦了。

3.1 会话复用与重试:fetcher.py 干了三件事

# crawler/fetcher.py import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry DEFAULT_HEADERS = { "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0 Safari/537.36"), "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "keep-alive", } class Fetcher: def __init__(self, timeout=15, retries=3): self.timeout = timeout self.session = requests.Session() retry = Retry( total=retries, backoff_factor=1.5, # 1.5s / 3s / 6s 递增 status_forcelist=(429, 500, 502, 503, 504), allowed_methods=("GET",), ) adapter = HTTPAdapter(max_retries=retry, pool_maxsize=16) self.session.mount("http://", adapter) self.session.mount("https://", adapter) self.session.headers.update(DEFAULT_HEADERS) def get(self, url, **kwargs): kwargs.setdefault("timeout", self.timeout) resp = self.session.get(url, **kwargs) resp.raise_for_status() # 中文专利站点常见 UTF-8 / GBK 混用,交给 chardet 猜比写死靠谱 resp.encoding = resp.apparent_encoding return resp

第一件事是复用Session,TCP 连接和 Cookie 都保留在会话里,翻几十页比每次新建连接快不少,也更像正常浏览。第二件事是重试策略:backoff_factor=1.5意味着第一次重试等 1.5 秒,第二次 3 秒,第三次 6 秒;status_forcelist里放的是 429 和 5xx,这类错误重试有意义,404 重试纯属浪费。第三件事是编码修正——apparent_encoding底层调 chardet,会比resp.encoding默认值准,但代价是每次多扫一遍字节流;如果确认站点是 UTF-8,直接写死resp.encoding = "utf-8"更快。pool_maxsize=16是连接池上限,比max_workers开大一点留余量就行。

3.2 限速器:最小间隔加抖动,别用固定 sleep

# utils/ratelimit.py import time import random class RateLimiter: """最小间隔 + 随机抖动;单调时钟不受系统改时间影响""" def __init__(self, min_interval=1.2, jitter=0.8): self.min_interval = min_interval self.jitter = jitter self._last = 0.0 def wait(self): now = time.monotonic() gap = now - self._last target = self.min_interval + random.uniform(0, self.jitter) if gap < target: time.sleep(target - gap) self._last = time.monotonic()

用time.monotonic()而不是time.time(),是因为后者在系统对时、容器时钟漂移时会跳变,跳一次就可能算出负间隔或者睡上几个小时。wait()只有在距上次请求不足目标间隔时才睡眠,gap大于目标值时直接放行——这比无条件sleep(1.2)合理,因为详情页解析本身也要耗时,无条件睡等于把间隔叠加成了两倍。抖动的作用是不让请求时间点呈现等距分布,1.2 到 2.0 之间的随机值落在日志里看着就像正常用户操作节奏。调参经验:先在min_interval=2.0下跑 50 页,看有没有 429;没有就往下压到 1.5、1.2,压到出现偶发 429 再回退一档。

3.3 列表页解析:条目、相对链接和翻页终止条件

# crawler/parser_list.py from lxml import etree from urllib.parse import urljoin XPATH_ROW = '//table[@id="resultTable"]/tbody/tr' XPATH_NEXT = '//a[contains(@class,"next")]/@href' def parse_list(html, page_url): tree = etree.HTML(html) if tree is None: # 返回的是错误页或空响应 return [], None rows = [] for tr in tree.xpath(XPATH_ROW): link = tr.xpath('./td[2]/a/@href') if not link: continue rows.append({ "patent_id": tr.xpath('./td[1]/text()')[0].strip(), # 标题里常嵌 <em> 高亮标签,用 //text() 再拼接才拿得全 "title": "".join(tr.xpath('./td[2]/a//text()')).strip(), "detail_url": urljoin(page_url, link[0]), # 相对路径统一转绝对 }) next_href = tree.xpath(XPATH_NEXT) return rows, (urljoin(page_url, next_href[0]) if next_href else None)

三个细节值得抠。urljoin处理相对链接:站点有的写/detail/123,有的写detail/123,有的直接给绝对地址,urljoin三种都能正确拼,比手写if startswith("http")稳妥。标题用"".join(xpath('//text()'))而不是text(),是因为关键词会被标成<em>,只取text()会漏掉命中词,导出的 CSV 里标题缺字。翻页终止靠两个条件叠加:parse_list返回空rows,或者返回的next_href是None;再叠上max_page配置做第三道保险。另外注意etree.HTML传入空字符串会返回None,不判空直接 XPath 会抛AttributeError,这种异常在日志里毫无信息量,加一行判空能省不少排查时间。

4. 详情页解析与 PDF 落盘:字段抽取、文件校验、断点顺序

列表页只给标题和链接,真正有价值的信息——申请号、申请日、申请人、摘要、法律状态、PDF 地址——都在详情页。这一步的难点不是 XPath 写不出来,而是抽取结果不稳定:有的条目缺摘要,有的申请人有好几个用分号隔开,有的 PDF 按钮是 JS 触发、地址藏在># crawler/parser_detail.py from lxml import etree import re FIELD_MAP = { "apply_no": '//span[@itemprop="applicationNumber"]/text()', "pub_no": '//span[@itemprop="publicationNumber"]/text()', "apply_date": '//span[@itemprop="applicationDate"]/text()', "applicant": '//span[@itemprop="applicant"]//text()', "inventor": '//span[@itemprop="inventor"]//text()', "abstract": '//div[contains(@class,"abstract")]//text()', "status": '//span[contains(@class,"legal-status")]/text()', } def clean(text): if not text: return "" text = text.replace("\xa0", " ").replace("\u3000", " ") return re.sub(r"\s+", " ", text).strip() def parse_detail(html, url): tree = etree.HTML(html) data = {"detail_url": url} for key, xp in FIELD_MAP.items(): data[key] = clean(" ".join(tree.xpath(xp))) if tree is not None else "" # PDF 地址优先取 data 属性,其次退回 <a href>,两条路都要留 pdf = tree.xpath('//a[@id="pdfBtn"]/@data-href') if tree is not None else [] if not pdf: pdf = tree.xpath('//a[contains(@href,".pdf")]/@href') if tree is not None else [] data["pdf_url"] = pdf[0] if pdf else "" data["patent_id"] = data["apply_no"] or data["pub_no"] or url.rstrip("/").split("/")[-1] return data

clean()里那两个替换是血泪经验:专利页面的摘要字段大量使用\xa0(不换行空格)和全角空格做缩进,直接入库后导出 CSV,Excel 里看着像有内容,实际检索时匹配不上。re.sub(r"\s+", " ", text)把连续空白压成一个空格,正文不会被破坏,但列表式的权利要求字段需要保留换行时,得另写一个clean_lines()只压空格不动\n。patent_id的兜底顺序也重要:优先申请号,其次公开号,最后才用 URL 尾段——URL 尾段在站点改版时可能变成不透明的 token,用它当主键会让去重完全失效。下面是selectors.yaml的一个片段,实际换站点时只改这里:

patent.example.com: list_row: '//table[@id="resultTable"]/tbody/tr' next: '//a[contains(@class,"next")]/@href' detail: apply_no: '//span[@itemprop="applicationNumber"]/text()' abstract: '//div[contains(@class,"abstract")]//text()' pdf: '//a[@id="pdfBtn"]/@data-href'

4.2 PDF 下载:流式、校验、原子替换三件套

# crawler/downloader.py import os PDF_MAGIC = b"%PDF-" def download_pdf(session, pdf_url, save_dir, patent_id, timeout=60): os.makedirs(save_dir, exist_ok=True) final_path = os.path.join(save_dir, f"{patent_id}.pdf") tmp_path = final_path + ".part" with session.get(pdf_url, stream=True, timeout=timeout) as resp: resp.raise_for_status() ctype = resp.headers.get("Content-Type", "").lower() if "pdf" not in ctype and "octet-stream" not in ctype: raise ValueError(f"响应不是 PDF: {ctype}") with open(tmp_path, "wb") as f: for chunk in resp.iter_content(chunk_size=64 * 1024): if chunk: f.write(chunk) with open(tmp_path, "rb") as f: head = f.read(5) if head != PDF_MAGIC: os.remove(tmp_path) raise ValueError("文件头校验失败,拿到的多半是错误页") os.replace(tmp_path, final_path) # 原子替换,不会留半截文件 return final_path

stream=True加iter_content是必须的:有些专利全文几十兆,不流式读会整个塞进内存,跑几十个之后内存就爆了。Content-Type检查是第一道闸,文件头校验是第二道——很多站点在无权限或超频时会返回一个 HTML 提示页,状态码还是 200,只靠raise_for_status()完全拦不住,最后你得到一堆后缀是.pdf其实是网页的文件。先写.part临时文件、校验通过后os.replace原子替换,保证目录里永远不会出现半截文件;os.replace在同一文件系统内是原子操作,Windows 上也能覆盖已存在的目标文件,比os.remove+os.rename两步安全。

4.3 编排顺序:先入库再排队,下载失败不阻塞抓取

# crawler/pipeline.py(核心片段) def run(keyword, max_page, fetcher, limiter, conn, db): for page in range(1, max_page + 1): if db.page_done(conn, keyword, page): continue limiter.wait() html = fetcher.get(build_list_url(keyword, page)).text rows, next_href = parse_list(html, build_list_url(keyword, page)) if not rows: break for row in rows: if db.patent_exists(conn, row["patent_id"]): continue # 已抓过,直接跳过详情页 limiter.wait() detail = parse_detail(fetcher.get(row["detail_url"]).text, row["detail_url"]) db.save_patent(conn, {**row, **detail, "fetched_at": now()}) db.enqueue_pdf(conn, detail["patent_id"], detail["pdf_url"]) db.mark_page_done(conn, keyword, page) conn.commit() # 一页一提交,崩了最多回退一页 if not next_href: break

这段的顺序是有讲究的:page_done判断放最前面,重复跑同一个关键词时直接跳过整页;patent_exists在抓详情页之前判断,省掉大量无谓请求;save_patent和enqueue_pdf在同一个事务里,保证「有字段记录但没有下载任务」这种情况不会发生;commit()放在循环末尾按页提交,而不是每条提交——每条提交在机械硬盘上会把速度拖慢一个数量级,全程不提交又会丢进度。PDF 下载由单独的函数从pdf_task里捞pending记录来做,抓取和下载解耦,某个 PDF 一直失败也不会卡住列表翻页,失败的重试两次后置为failed,跑完统一看日志补。

5. 避坑与排查:五个真实踩过的坑

下面五条都是跑起来之后才会遇到的,按「现象 → 原因 → 解决」记,遇到同类问题可以直接对号入座。

5.1 抓了两千条,摘要字段全是空

现象:patent表里abstract列全为空字符串,但浏览器打开详情页明明有摘要。

原因:摘要容器用的是<div class="abstract-content">,而 XPath 写的是精确匹配@class="abstract"。站点改版时把类名从abstract改成了abstract-content,精确匹配直接失效,tree.xpath返回空列表,clean("")又规规矩矩返回"",全程没有任何报错。

解决:容器类名一律用contains(@class,"abstract")而不是=,改版后大概率还能命中。更彻底的办法是在parse_detail里加一个「关键字段非空」的断言,抽完之后如果abstract和apply_no同时为空,就把这条记录和 HTML 片段写进data/failed/目录,跑完批量看失败样本,问题一眼就能定位。

5.2 PDF 目录里混进一堆打不开的文件

现象:文件大小几百字节到几十 KB 不等,双击打不开,用 PDF 阅读器提示格式错误。

原因:请求被限速或触发验证时,站点返回的是 HTML 提示页,状态码 200,raise_for_status()不抛异常,脚本照样把响应体写成了.pdf。

解决:Content-Type检查和%PDF-文件头校验两道都要留,缺一不可——有的站点Content-Type写的是application/octet-stream,只能靠文件头;有的站点返回真 PDF 但前面带了 BOM 或空行,文件头读前 5 字节就可能失败,这种情况改成读前 1024 字节再find(b"%PDF-")更宽容。已经下坏的历史文件用一条命令清理重下:

find ./data/pdf -name "*.pdf" -size -20k -print -delete

5.3 翻页翻到第 30 页开始无限循环

现象:日志里第 30 页之后的 URL 一直在变,但解析出的patent_id全是重复的,patent_exists全部命中,脚本空转。

原因:站点的下一页按钮在超出结果范围后并不消失,而是保留链接指向最后一页,next_href永远非空。

解决:max_page兜底是第一步,但治标不治本。更可靠的是加「连续 N 页零新增」的退出条件:维护一个计数器,某页解析出的条目全部命中patent_exists就+1,连续 3 页如此直接break。同时把crawl_page的page_no上限也写进配置,两条腿走路。

5.4 中断重启后,前面抓过的又重抓一遍

现象:Ctrl+C 之后重新执行同一条命令,日志从第 1 页开始刷,但详情页请求明显变少了。

原因:crawl_page的mark_page_done写在了for row循环内部,异常退出时当前页只标记了一部分,重启后这一页从头发起;详情页有patent_exists挡着,所以看着「请求变少但页码回退」。

解决:mark_page_done和commit()一起放到整页处理完之后,保证一页要么完整标记、要么完全不标记。另一种做法是不依赖crawl_page,直接靠patent表的patent_id去重,逻辑更简单——代价是每页都要重新请求列表页,流量换简单性,看你怎么取舍。

5.5 导出 CSV 用 Excel 打开,申请号变成科学计数法

现象:202310123456.7这种申请号在 Excel 里显示成2.02E+11,末位还对不上。

原因:pandas 的to_csv写出的是纯文本,Excel 打开时按内容自动推断类型,长数字串被当成浮点数。

解决:导出时给apply_no、pub_no这类纯数字字段加前缀单引号,或者直接输出.xlsx并把对应列设成文本格式。项目里exporter.py的做法是保留 CSV,同时在字段前拼一个不可见的\t——Excel 见到制表符就当文本处理,pandas 读回来strip()一下就干净了。另外统一用encoding="utf-8-sig"写文件,不然 Excel 打开中文全是乱码,这个坑几乎每个中文数据项目都要踩一次。

6. 二次开发:换站点适配器、做增量更新、验字段完整率

接手这份代码之后最常改的三件事,按改动量从小到大排。第一件是加字段。FIELD_MAP是字典,加一行 XPath 就行,patent表用ALTER TABLE patent ADD COLUMN xxx TEXT补列,save_patent的 SQL 里加上占位符。注意 SQLite 的ALTER TABLE不支持一次加多列,也不支持删列(老版本),字段试验阶段建议先落到extra_json一列里,稳定了再提升为独立列,避免反复重建表。

第二件是换站点。别去改parser_list.py和parser_detail.py里的常量,正确姿势是抽一个适配器类,每个站点一个子类,只覆盖 XPath 和 PDF 定位逻辑:

# crawler/sites/base.py class SiteAdapter: name = "base" def list_url(self, keyword, page): raise NotImplementedError def parse_list(self, html, url): raise NotImplementedError def parse_detail(self, html, url): raise NotImplementedError # crawler/sites/site_a.py class SiteA(SiteAdapter): name = "site_a" def list_url(self, keyword, page): return f"https://patent.example.com/search?q={quote(keyword)}&page={page}" def parse_detail(self, html, url): data = super().parse_detail(html, url) data["pdf_url"] = data["pdf_url"] or self._fallback_pdf(html) return data

主流程只依赖SiteAdapter接口,main.py用--site site_a选实现。我一般会在sites/下留一个_template.py,新站点复制一份改 XPath,二十分钟能接一个。第三件是增量更新。全量重跑几十万条不现实,靠apply_date做水位线最省事:每次跑之前SELECT MAX(apply_date) FROM patent,列表页按申请日倒序抓,遇到早于水位线的条目连续出现 20 条就停。注意水位线要用「最大申请日」而不是「最后抓取时间」,因为列表排序未必严格按时间,用抓取时间会漏掉补录的老专利。

改完之后怎么验证没写崩?别只看「跑完了没报错」。抽 20 条手工打开详情页逐字段比对,再跑一条完整率 SQL:

SELECT COUNT(*) AS total, ROUND(1.0 * SUM(CASE WHEN abstract IS NULL OR abstract = '' THEN 1 ELSE 0 END) / COUNT(*), 3) AS abstract_miss, ROUND(1.0 * SUM(CASE WHEN applicant IS NULL OR applicant = '' THEN 1 ELSE 0 END) / COUNT(*), 3) AS applicant_miss, ROUND(1.0 * SUM(CASE WHEN pdf_url IS NULL OR pdf_url = '' THEN 1 ELSE 0 END) / COUNT(*), 3) AS pdf_miss FROM patent;

经验阈值:abstract_miss超过 5% 就是 XPath 出问题了,不是数据本身缺失;pdf_miss高但abstract_miss正常,说明下载按钮的定位逻辑要单独查。我吃过一次亏——改完 XPath 只跑了 3 页就收工,第二天发现第 4 页开始字段全空,原因是那几页走了不同的模板。从那以后我每次改解析逻辑,都强制跑一遍「前 3 页 + 随机 5 页 + 最后 3 页」这三个区间的完整率对比,三处都正常才算过。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询