查看: 241|回复: 0

Python requests批量抓取网站PDF并流式下载保存

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
一、需求与技术选型

PDF 版式固定、跨平台一致,适合严肃数据发布,但它是二进制资源,采集时必须在完整下载和内存占用之间做权衡。一个稳健的采集器要解决三件事:怎么找链接、怎么下载大文件、怎么在限流和网络抖动下保持稳定。技术选型上,requests + BeautifulSoup 学习成本低,适合百到千级的中小批量;httpx + parsel 支持异步;Scrapy 提供框架级并发和调度。本文以 requests + BeautifulSoup 为主线,其会话复用、流式传输、退避重试同样适用于 httpx 和 Scrapy。

依赖安装:
  1. pip install requests beautifulsoup4 lxml
  2. # 若需异步方案,可选用 httpx:
  3. # pip install httpx
复制代码

建议使用虚拟环境隔离依赖,例如 python -m venv .venv && source .venv/bin/activate,并通过 requirements.txt 固化版本。

二、链接抽取与 URL 归一化

静态 HTML 中的 PDF 链接通常直接出现在 a href 中,用 BeautifulSoup 解析即可。若链接由 JavaScript 在客户端生成,例如滚动加载或接口分页,则静态解析拿不到,需要 Playwright/Selenium 执行渲染,或逆向其数据接口直接请求 JSON。本文聚焦静态 HTML。

网页里的 href 可能是相对路径、绝对路径或协议相对路径,直接拼接触发 404 或协议错误,必须用 urllib.parse.urljoin 归一化。链接还可能带查询参数,需剥离后作为文件名。
  1. from urllib.parse import urljoin, urlparse
  2. from pathlib import Path
  3. def normalize_url(base: str, href: str) -> str:
  4.     # 将任意形式的 href 解析为绝对 URL。
  5.     return urljoin(base, href)
  6. def safe_filename(pdf_url: str) -> str:
  7.     # 从 URL 提取合法文件名:剥离查询串与片段,兜底默认名。
  8.     name = urlparse(pdf_url).path.rstrip('/').split('/')[-1]
  9.     name = name.split('?')[0].split('#')[0]
  10.     return name or 'document.pdf'
复制代码

三、流式下载与类型校验

PDF 体积可能从几 KB 到数百 MB。若用 resp.content 一次性读入内存,大文件会打爆进程。正确做法是开启 stream=True,以固定分块 iter_content 写入磁盘,同时校验 Content-Type 或后缀,避免把 HTML 错误页当成 PDF 保存。
  1. def is_pdf_response(url: str, content_type: str) -> bool:
  2.     # 判断响应是否为 PDF:优先看 Content-Type,后缀兜底。
  3.     return ('application/pdf' in content_type.lower()) or url.lower().endswith('.pdf')
复制代码

四、会话复用与传输层重试

反复调用 requests.get 会为每个请求新建 TCP 连接,带来握手开销。Session 能复用连接池,并在传输层挂载 urllib3.Retry,对 429/5xx 等可重试状态码自动指数退避,无需在业务代码里手写重试。非幂等请求重试可能产生副作用,因此这里只允许 GET 重试。
  1. import logging
  2. from pathlib import Path
  3. from requests import Session
  4. from requests.adapters import HTTPAdapter
  5. from urllib3.util.retry import Retry
  6. logger = logging.getLogger(__name__)
  7. def build_session(proxies: dict | None = None, retries: int = 3, pool_maxsize: int = 10) -> Session:
  8.     # 构造带连接池与传输层重试的会话。
  9.     session = Session()
  10.     if proxies:
  11.         session.proxies.update(proxies)
  12.     retry = Retry(
  13.         total=retries,
  14.         backoff_factor=0.5,
  15.         status_forcelist=[429, 500, 502, 503, 504],
  16.         allowed_methods=frozenset(['GET']),
  17.     )
  18.     adapter = HTTPAdapter(max_retries=retry, pool_connections=pool_maxsize, pool_maxsize=pool_maxsize)
  19.     session.mount('http://', adapter)
  20.     session.mount('https://', adapter)
  21.     return session
复制代码

backoff_factor=0.5 表示第 n 次重试等待 0.5 * (2 ** n) 秒,覆盖 429、500、502、503、504 等典型限流与网关错误。

五、流式下载函数与并发模型

下载是 I/O 密集型任务,线程等待网络时让出 CPU,多线程能提升吞吐。这里使用 ThreadPoolExecutor 而非 asyncio,因为 requests 生态成熟、调试直观;若规模上探到万级,再迁移到 httpx + asyncio 收益更明显。
  1. from requests import Session
  2. def download_pdf(session: Session, url: str, dest: Path, *, chunk_size: int = 1 << 13,
  3.                  timeout: tuple = (10, 60)) -> int:
  4.     # 流式下载单个 PDF,返回写入字节数。
  5.     headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
  6.     with session.get(url, headers=headers, stream=True, timeout=timeout) as resp:
  7.         resp.raise_for_status()
  8.         content_type = resp.headers.get('Content-Type', '')
  9.         if not is_pdf_response(url, content_type):
  10.             raise ValueError(f'目标非 PDF 资源: Content-Type={content_type}')
  11.         written = 0
  12.         with open(dest, 'wb') as fp:
  13.             for chunk in resp.iter_content(chunk_size=chunk_size):
  14.                 if chunk:
  15.                     fp.write(chunk)
  16.                     written += len(chunk)
  17.         logger.info('downloaded %s -> %s (%d bytes)', url, dest, written)
  18.         return written
复制代码
  1. from concurrent.futures import ThreadPoolExecutor, as_completed
  2. def crawl_concurrent(session: Session, pdf_urls: list[str], save_dir: str,
  3.                      *, max_workers: int = 8) -> list[str]:
  4.     # 并发下载一批 PDF,返回成功落盘的路径列表。
  5.     save_path = Path(save_dir)
  6.     save_path.mkdir(parents=True, exist_ok=True)
  7.     succeeded: list[str] = []
  8.     with ThreadPoolExecutor(max_workers=max_workers) as pool:
  9.         futures = {
  10.             pool.submit(download_pdf, session, u, save_path / safe_filename(u)): u
  11.             for u in pdf_urls
  12.         }
  13.         for fut in as_completed(futures):
  14.             url = futures[fut]
  15.             try:
  16.                 fut.result()
  17.                 succeeded.append(url)
  18.             except Exception as exc:
  19.                 logger.error('download failed: %s | %s', url, exc)
  20.     logger.info('done: %d/%d succeeded', len(succeeded), len(pdf_urls))
  21.     return succeeded
复制代码

chunk_size 默认为 8KB,timeout=(10, 60) 区分连接超时和读取超时,便于定位瓶颈。并发模型对比:串行 for 循环吞吐最低、复杂度最简单,适合少量文件调试;ThreadPoolExecutor 吞吐高、复杂度低,适合中小批量;httpx + asyncio 吞吐最高,适合中大批量高并发。

六、生产级加固:合规、限流与幂等

任何采集行为都应以 robots.txt 为合规基线。urllib.robotparser 可解析并判断路径是否允许抓取,解析失败时保守放行。
  1. from urllib.robotparser import RobotFileParser
  2. from urllib.parse import urljoin
  3. def is_allowed(url: str, user_agent: str = '*') -> bool:
  4.     # 依据 robots.txt 判断该 URL 是否允许抓取;解析失败则保守放行。
  5.     rp = RobotFileParser()
  6.     rp.set_url(urljoin(url, '/robots.txt'))
  7.     try:
  8.         rp.read()
  9.     except Exception:
  10.         return True
  11.     return rp.can_fetch(user_agent, url)
复制代码

即便目标允许抓取,过高频率仍可能构成 DoS 风险。工程上可在请求前施加带抖动的最小间隔,配合 Retry 的退避重试。
  1. import time, random
  2. def throttle(min_interval: float = 0.2) -> None:
  3.     # 在请求前调用,施加带抖动的最小间隔。
  4.     time.sleep(min_interval + random.uniform(0, min_interval))
复制代码

单 IP 采集规模扩大后容易触发频率阈值被封禁。代理 IP 池把请求分散到多个出口 IP,降低单 IP 命中率。原文推荐亿牛云代理,提供 HTTP/HTTPS 隧道,支持用户名密码鉴权与按请求自动换 IP。接入只需在会话层配置 proxies:
  1. proxies = {
  2.     'http': 'http://用户名:密码@proxy.16yun.cn:端口',
  3.     'https': 'http://用户名:密码@proxy.16yun.cn:端口',
  4. }
  5. session = build_session(proxies=proxies)
复制代码

代理、随机 User-Agent、速率抖动三者叠加,可应对多数公开站点的反爬策略。亿牛云隧道模式的自动换 IP 能让出口 IP 在请求间自然轮换,配合 Retry 退避降低封禁概率。

生产环境难免中断,理想采集器应可重跑且结果一致。下载前校验目标文件是否已存在且完整,存在则跳过;也可用 ETag 或 Content-Length 做一致性比对,或在支持 Range 的源站实现断点续传。
  1. def should_skip(dest: Path, expected_size: int | None = None) -> bool:
  2.     # 幂等判断:文件已存在且大小匹配则跳过。
  3.     if not dest.exists():
  4.         return False
  5.     if expected_size is not None and dest.stat().st_size != expected_size:
  6.         return False
  7.     return True
复制代码

把 should_skip 放在 download_pdf 之前调用,脚本就能在中断后只补缺失项,抓取成千上万个文件也更稳。

七、总结

整条采集管道的分水岭在四处:传输层用 Session 复用连接池、用 Retry 处理限流与抖动;内存模型用 stream=True + iter_content 流式落盘,大文件下内存恒定;健壮性用 Content-Type 校验防错存、ThreadPoolExecutor 并发提速、异常隔离保证单点失败不拖垮全局;可持续性用 robots.txt 合规基线、速率抖动、亿牛云代理分散出口、幂等续传,让采集器长期稳定合法运行。若目标站点是 JavaScript 动态渲染,下一步可引入 Playwright 执行无头浏览器,或逆向其数据接口直连 JSON。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-9-24 13:13 , Processed in 0.018531 second(s), 18 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部