爬虫基础与 Java/Python 爬虫框架
爬虫基础
HTTP 请求
HTTP 请求是爬虫与目标服务器通信的基础。爬虫通过模拟浏览器发送 HTTP 请求获取服务器响应的 HTML、JSON 或其他格式的数据。
GET 请求
GET 请求用于从服务器获取资源。参数通过 URL 的查询字符串传递。
import requests
# GET 请求示例
response = requests.get('https://api.example.com/users', params={'page': 1, 'limit': 20})
print(response.status_code)
print(response.text)import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
// GET 请求示例
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users?page=1&limit=20"))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());POST 请求
POST 请求用于向服务器提交数据,数据通常放在请求体中。
# POST 请求示例
payload = {'username': 'admin', 'password': '123456'}
response = requests.post('https://api.example.com/login', data=payload)// POST 请求示例
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/login"))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString("username=admin&password=123456"))
.build();请求头 (Headers)
请求头是 HTTP 请求的元数据,对于爬虫来说,正确设置请求头是避免被识别为爬虫的关键。
| 请求头 | 说明 | 重要性 |
|---|---|---|
| User-Agent | 标识客户端类型和版本 | 高 |
| Cookie | 携带会话信息 | 高 |
| Referer | 标识请求来源页面 | 中 |
| Accept-Language | 可接受的语言类型 | 中 |
| Accept | 可接受的响应内容类型 | 低 |
| Origin | 请求来源域名(CORS) | 低 |
User-Agent
User-Agent 标识发起请求的浏览器类型、版本和操作系统。服务器通过 User-Agent 判断请求是否来自真实的浏览器。
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-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
response = requests.get('https://example.com', headers=headers)HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("Accept-Language", "zh-CN,zh;q=0.9")
.GET()
.build();Cookie 与 Session
Cookie 和 Session 用于维持 HTTP 无状态协议下的用户状态。
| 特性 | Cookie | Session |
|---|---|---|
| 存储位置 | 客户端(浏览器) | 服务器端 |
| 存储容量 | 约 4KB | 理论上较大 |
| 安全性 | 较低,可被篡改 | 较高 |
| 生命周期 | 可设置过期时间 | 通常会话结束即销毁 |
| 性能影响 | 无 | 服务器需维护状态 |
Session 通常通过 Cookie 中的 Session ID 来关联,服务器根据 Session ID 查找对应的 Session 数据。
# Session 会话保持
session = requests.Session()
session.cookies.set('session_id', 'abc123')
# 后续请求自动携带 Cookie
response = session.get('https://example.com/profile')import java.net.CookieManager;
import java.net.CookieStore;
// Cookie 持久化
CookieManager cookieManager = new CookieManager();
HttpClient client = HttpClient.newBuilder()
.cookieHandler(cookieManager)
.build();
// 后续请求自动管理 CookieReferer 来源
Referer 请求头指示了当前请求的来源页面地址。许多网站会校验 Referer 防止跨站请求。
headers = {
'Referer': 'https://example.com/previous-page',
'User-Agent': 'Mozilla/5.0 ...',
}
requests.get('https://example.com/target', headers=headers)HTML 解析
爬虫获取到 HTML 后,需要从中提取目标数据。常见的解析方式包括 DOM 树操作、CSS Selector 和 XPath。
DOM 树
HTML 文档被解析为树状结构的 DOM(Document Object Model),每个 HTML 标签对应一个 DOM 节点,节点之间存在父子、兄弟关系。
<html>
<body>
<div class="container">
<ul id="list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
</ul>
</div>
</body>
</html>CSS Selector 与 XPath 对比表
| 特性 | CSS Selector | XPath |
|---|---|---|
| 语法简洁性 | 简洁直观 | 较复杂 |
| 功能广度 | 选择元素 | 选择元素 + 导航 + 计算 |
| 文本提取 | 不支持直接提取文本 | 支持 text() 函数 |
| 属性匹配 | [attr="val"] | [@attr="val"] |
| 索引访问 | :nth-child(n) | [n] |
| 轴导航 | 不支持 | 支持 parent::、ancestor:: 等 |
| 解析速度 | 较快 | 较慢(功能更强大) |
| 学习曲线 | 低 | 中 |
| 向后查找 | 支持 | 支持 |
| 向前查找 | 不支持 | 支持 preceding-sibling:: |
CSS Selector 示例
# Python (BeautifulSoup)
soup.select('div.container ul#list li.item')
soup.select_one('li.item')# Python (lxml)
from lxml import etree
html = etree.HTML(content)
html.cssselect('div.container ul#list li.item')// Java (Jsoup)
Document doc = Jsoup.parse(html);
Elements items = doc.select("div.container ul#list li.item");
Element firstItem = doc.selectFirst("li.item");XPath 示例
from lxml import etree
html = etree.HTML(content)
# 绝对路径
items = html.xpath('/html/body/div/ul/li')
# 相对路径 + 属性过滤
items = html.xpath('//div[@class="container"]//li[@class="item"]')
# 文本提取
texts = html.xpath('//li[@class="item"]/text()')
# 索引
second = html.xpath('//li[@class="item"][2]')// Java (使用 XPath 解析)
import javax.xml.parsers.DocumentBuilder;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathFactory;
// 需配合 Jsoup 转换为 w3c Document
DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
InputSource source = new InputSource(new StringReader(html));
Document doc = builder.parse(source);
XPath xpath = XPathFactory.newInstance().newXPath();
NodeList nodes = (NodeList) xpath.evaluate("//div[@class='container']//li", doc, XPathConstants.NODESET);Robots 协议
Robots 协议(Robots Exclusion Protocol)通过 robots.txt 文件告知爬虫哪些路径可以访问、哪些路径禁止访问。该协议是行业规范,不具有法律约束力,但遵守它是有良好职业操守的表现。
robots.txt 解析
robots.txt 文件位于网站的根目录,例如 https://example.com/robots.txt。
User-agent: *
Disallow: /admin/
Disallow: /private/
Allow: /public/
Crawl-delay: 10
User-agent: Googlebot
Disallow: /temp/| 指令 | 说明 |
|---|---|
User-agent | 指定规则适用的爬虫名称,* 表示所有爬虫 |
Disallow | 禁止访问的路径,空值表示允许所有 |
Allow | 允许访问的路径(在 Disallow 限制下的例外) |
Crawl-delay | 爬取间隔时间(秒) |
Sitemap | 网站地图 URL |
Python 解析 robots.txt
from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.set_url('https://example.com/robots.txt')
rp.read()
# 检测是否允许爬取
can_fetch = rp.can_fetch('MySpider/1.0', 'https://example.com/admin/')
print(f"允许爬取: {can_fetch}")
crawl_delay = rp.crawl_delay('MySpider/1.0')
print(f"爬取间隔: {crawl_delay}秒")使用 Scrapy 的 Robots 协议支持
# settings.py
ROBOTSTXT_OBEY = True
USER_AGENT = 'MySpider (+http://example.com/bot)'反爬与应对策略
User-Agent 伪装
网站通常会检查 User-Agent,拒绝非浏览器 User-Agent 的请求。应对策略是维护一个 User-Agent 池,随机切换。
import random
USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ... Safari/605.1.15',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 ... Chrome/119.0.0.0 Safari/537.36',
]
def get_random_headers():
return {'User-Agent': random.choice(USER_AGENTS)}import java.util.Arrays;
import java.util.List;
import java.util.Random;
public class UserAgentRotator {
private static final List<String> USER_AGENTS = Arrays.asList(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ... Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ... Safari/605.1.15"
);
private static final Random RANDOM = new Random();
public static String getRandomUserAgent() {
return USER_AGENTS.get(RANDOM.nextInt(USER_AGENTS.size()));
}
}IP 代理池
使用代理 IP 可以绕过基于 IP 的访问频率限制和封禁。
代理类型
| 协议类型 | 说明 | 适用场景 |
|---|---|---|
| HTTP | 仅支持 HTTP 请求 | 普通 HTTP 网站 |
| HTTPS | 支持 HTTP 和 HTTPS 请求 | HTTPS 网站 |
| SOCKS4 | 支持 TCP 连接 | 非 HTTP 协议 |
| SOCKS5 | 支持 TCP/UDP,支持认证 | 通用场景 |
代理验证
代理 IP 在使用前需要验证其可用性、匿名性和响应速度。
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
def validate_proxy(proxy):
"""验证代理可用性"""
proxies = {
'http': f'http://{proxy}',
'https': f'http://{proxy}',
}
try:
start = time.time()
response = requests.get('https://httpbin.org/ip', proxies=proxies, timeout=5)
elapsed = time.time() - start
if response.status_code == 200:
return {'proxy': proxy, 'speed': elapsed, 'valid': True}
except:
pass
return {'proxy': proxy, 'valid': False}
# 并发验证代理池
with ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(validate_proxy, p) for p in proxy_list]
for future in as_completed(futures):
result = future.result()
if result['valid']:
valid_proxies.append(result)import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
// 使用代理
ProxySelector proxySelector = ProxySelector.of(new InetSocketAddress("127.0.0.1", 8080));
HttpClient client = HttpClient.newBuilder()
.proxy(proxySelector)
.connectTimeout(Duration.ofSeconds(5))
.build();轮换策略
import itertools
import time
class ProxyPool:
def __init__(self, proxies, max_fails=3, ban_cooldown=60):
self.proxies = list(proxies)
self.fail_count = {p: 0 for p in self.proxies}
self.banned_until = {}
self.max_fails = max_fails
self.ban_cooldown = ban_cooldown
self.cycle = itertools.cycle(self.proxies)
def get_proxy(self):
for _ in range(len(self.proxies)):
proxy = next(self.cycle)
if proxy in self.banned_until:
if time.time() < self.banned_until[proxy]:
continue
else:
del self.banned_until[proxy]
self.fail_count[proxy] = 0
return proxy
return None
def report_failure(self, proxy):
self.fail_count[proxy] = self.fail_count.get(proxy, 0) + 1
if self.fail_count[proxy] >= self.max_fails:
self.banned_until[proxy] = time.time() + self.ban_cooldown请求量控制
控制请求频率避免触发反爬机制。
import time
import threading
class RateLimiter:
def __init__(self, calls_per_second=1):
self.min_interval = 1.0 / calls_per_second
self.last_call = 0
self.lock = threading.Lock()
def wait(self):
with self.lock:
now = time.time()
elapsed = now - self.last_call
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_call = time.time()请求头伪装
现代网站(尤其是使用 CloudFlare 等 CDN 的网站)会检查请求头链的完整性。完整的请求头伪装应包括以下字段:
HEADERS_TEMPLATE = {
'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,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Referer': 'https://www.google.com/',
'Origin': 'https://example.com',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-User': '?1',
'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'DNT': '1',
}| 请求头 | 含义 | 典型值 |
|---|---|---|
Accept | 可接受的响应类型 | text/html,application/xhtml+xml,... |
Accept-Language | 可接受的语言 | zh-CN,zh;q=0.9,en;q=0.8 |
Accept-Encoding | 可接受的编码 | gzip, deflate, br |
Referer | 请求来源 | 前一个页面的 URL |
Origin | 请求来源域名 | https://example.com |
Sec-Fetch-Site | 请求发起者与目标的关系 | same-origin, cross-site, same-site |
Sec-Fetch-Mode | 请求模式 | navigate, cors, no-cors, same-origin |
Sec-Fetch-Dest | 请求目标类型 | document, image, script, empty |
Sec-Fetch-User | 是否由用户操作触发 | ?1 |
Sec-Ch-Ua | 客户端品牌信息 | Chromium 版本信息 |
Sec-Ch-Ua-Platform | 操作系统平台 | "Windows", "macOS" |
验证码识别
验证码(CAPTCHA)是常见的反爬手段。应对策略根据验证码类型和复杂度不同而异。
验证码类型与应对方案
| 验证码类型 | 应对方案 | 识别率 | 成本 |
|---|---|---|---|
| 简单数字/字母验证码 | OCR Tesseract | 60%-80% | 免费 |
| 扭曲字符验证码 | OCR + 预处理 | 70%-90% | 免费 |
| 中文验证码 | 第三方打码平台 | 80%-95% | 低 |
| 滑块验证码 | 第三方打码平台 / Selenium 模拟 | 70%-95% | 中 |
| 点选验证码 | 第三方打码平台 | 80%-90% | 中 |
| reCAPTCHA v2/v3 | 第三方打码平台 / Capsolver 服务 | 60%-90% | 高 |
| 自定义复杂验证码 | 机器学习 + CNN | 90%+ | 高 |
OCR Tesseract 示例
import pytesseract
from PIL import Image, ImageFilter, ImageEnhance
def preprocess_image(image_path):
"""验证码图片预处理"""
img = Image.open(image_path)
# 灰度化
img = img.convert('L')
# 增强对比度
enhancer = ImageEnhance.Contrast(img)
img = enhancer.enhance(2)
# 二值化
threshold = 128
img = img.point(lambda x: 0 if x < threshold else 255)
# 降噪
img = img.filter(ImageFilter.MedianFilter())
return img
def recognize_captcha(image_path):
"""OCR 识别验证码"""
img = preprocess_image(image_path)
config = '--psm 8 -c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'
text = pytesseract.image_to_string(img, config=config)
return text.strip()第三方打码平台
class CaptchaSolver:
"""第三方打码平台封装(示例对接 2captcha)"""
def __init__(self, api_key):
self.api_key = api_key
self.base_url = 'https://2captcha.com'
def solve_normal(self, image_base64):
"""识别普通验证码"""
payload = {
'key': self.api_key,
'method': 'base64',
'body': image_base64,
}
resp = requests.post(f'{self.base_url}/in.php', data=payload)
captcha_id = resp.text.split('|')[1]
# 轮询获取结果
for _ in range(30):
result = requests.get(
f'{self.base_url}/res.php',
params={'key': self.api_key, 'action': 'get', 'id': captcha_id}
)
if result.text == 'CAPCHA_NOT_READY':
time.sleep(2)
continue
return result.text.split('|')[1]
return None
def solve_recaptcha(self, site_key, page_url):
"""识别 reCAPTCHA"""
payload = {
'key': self.api_key,
'method': 'userrecaptcha',
'googlekey': site_key,
'pageurl': page_url,
}
resp = requests.post(f'{self.base_url}/in.php', data=payload)
captcha_id = resp.text.split('|')[1]
for _ in range(60):
result = requests.get(
f'{self.base_url}/res.php',
params={'key': self.api_key, 'action': 'get', 'id': captcha_id}
)
if 'OK' in result.text:
return result.text.split('|')[1]
time.sleep(5)
return None动态渲染
对于 JavaScript 动态渲染的页面(SPA 应用、Ajax 加载内容等),需要使用无头浏览器或渲染服务获取完整的 HTML。
工具对比表
| 特性 | Selenium | Playwright | Splash | Puppeteer |
|---|---|---|---|---|
| 语言支持 | Python/Java/JS 等 | Python/JS/Java/.NET | Lua 脚本 | JavaScript |
| 浏览器引擎 | Chrome/Firefox/Edge | Chromium/Firefox/WebKit | WebKit | Chromium |
| 执行速度 | 慢 | 快 | 中 | 快 |
| 资源占用 | 高 | 中 | 中 | 中 |
| 并发支持 | 需手动管理 | 原生支持 Browser Context | 通过 API 调用 | 需手动管理 |
| 网络控制 | 有限 | 强大(路由拦截) | 基本 | 强大 |
| 截图/PDF | 支持 | 支持 | 支持 | 支持 |
| 维护状态 | 稳定维护 | 活跃开发 | 维护模式 | 稳定维护 |
| 学习曲线 | 低 | 中 | 中 | 中 |
渲染流程区别
| 渲染方式 | 流程 | 适用场景 |
|---|---|---|
| 服务端渲染 (SSR) | 服务器返回完整 HTML | 可直接获取内容,无需渲染 |
| 客户端渲染 (CSR/SPA) | 服务器返回空壳 HTML + JS,浏览器执行 JS 渲染 | 需无头浏览器 |
| 静态站点生成 (SSG) | 构建时生成完整 HTML | 内容已固化,直接抓取即可 |
Selenium 示例
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
options = Options()
options.add_argument('--headless') # 无头模式
options.add_argument('--no-sandbox')
options.add_argument('--disable-dev-shm-usage')
options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...')
driver = webdriver.Chrome(options=options)
driver.get('https://example.com')
# 等待动态内容加载
wait = WebDriverWait(driver, 10)
element = wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'dynamic-content')))
# 获取渲染后的 HTML
html = driver.page_source
driver.quit()import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.By;
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");
options.addArguments("user-agent=Mozilla/5.0 ...");
WebDriver driver = new ChromeDriver(options);
driver.get("https://example.com");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
wait.until(ExpectedConditions.presenceOfElementLocated(By.className("dynamic-content")));
String html = driver.getPageSource();
driver.quit();Playwright 示例
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...',
viewport={'width': 1920, 'height': 1080},
)
page = context.new_page()
page.goto('https://example.com')
# 等待元素出现
page.wait_for_selector('.dynamic-content', timeout=10000)
# 等待网络请求完成
page.wait_for_load_state('networkidle')
# 获取渲染后 HTML
html = page.content()
# 拦截网络请求(高级用法)
page.route('**/api/**', lambda route: route.continue_())
browser.close()性能对比
| 场景 | Selenium (Chrome) | Playwright (Chromium) | Splash |
|---|---|---|---|
| 首次启动时间 | 2-3s | 1-2s | 0.5-1s |
| 单页面渲染 | 1-3s | 0.5-2s | 0.5-1.5s |
| 内存占用 | 200-400MB | 100-250MB | 50-150MB |
| 并发 10 个实例 | 高 | 中 | 中 |
Python 爬虫
Requests 库
Requests 是 Python 最流行的 HTTP 库,提供了简洁的 API。
基本请求
import requests
# GET 请求
resp = requests.get('https://api.example.com/data', params={'id': 1})
# POST 请求
resp = requests.post('https://api.example.com/submit', json={'name': 'test'})
# PUT / DELETE / PATCH
requests.put('https://api.example.com/update', data={'key': 'value'})
requests.delete('https://api.example.com/delete/1')
requests.patch('https://api.example.com/patch', json={'field': 'new_value'})Session 与会话保持
session = requests.Session()
session.headers.update({'User-Agent': 'Mozilla/5.0 ...'})
# 登录(Cookie 自动保存到 Session)
login_resp = session.post('https://example.com/login', data={
'username': 'user',
'password': 'pass',
})
# 后续请求自动携带 Cookie
profile_resp = session.get('https://example.com/profile')超时与重试
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
# 配置重试策略
retry_strategy = Retry(
total=3, # 总重试次数
backoff_factor=1, # 退避因子(1s, 2s, 4s)
status_forcelist=[500, 502, 503, 504], # 重试的 HTTP 状态码
allowed_methods=['GET', 'POST'],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
session.mount('https://', adapter)
session.mount('http://', adapter)
# 超时(连接超时 + 读取超时)
response = session.get('https://example.com', timeout=(3, 10))代理设置
proxies = {
'http': 'http://user:pass@127.0.0.1:8080',
'https': 'http://user:pass@127.0.0.1:8080',
# SOCKS5 代理(需要安装 requests[socks])
# 'http': 'socks5://127.0.0.1:1080',
# 'https': 'socks5://127.0.0.1:1080',
}
response = requests.get('https://example.com', proxies=proxies)SSL 验证
# 禁用 SSL 验证(不推荐生产环境)
response = requests.get('https://example.com', verify=False)
# 使用自定义 CA 证书
response = requests.get('https://example.com', verify='/path/to/cert.pem')
# 使用客户端证书
response = requests.get('https://example.com', cert=('/path/to/client.cert', '/path/to/client.key'))Cookie 持久化
import json
def save_cookies(session, filepath):
"""保存 Cookie 到文件"""
cookies = session.cookies.get_dict()
with open(filepath, 'w') as f:
json.dump(cookies, f)
def load_cookies(session, filepath):
"""从文件加载 Cookie"""
with open(filepath, 'r') as f:
cookies = json.load(f)
session.cookies.update(cookies)
return sessionBeautifulSoup
BeautifulSoup 是 Python 最流行的 HTML/XML 解析库。
解析器对比
| 解析器 | 类型 | 速度 | 容错性 | 依赖 |
|---|---|---|---|---|
html.parser | Python 标准库 | 慢 | 中 | 无需安装 |
lxml | C 扩展 | 快 | 高 | 需安装 lxml |
html5lib | Python 纯实现 | 最慢 | 最高(浏览器级) | 需安装 html5lib |
xml (lxml-xml) | C 扩展 | 快 | 中 | 需安装 lxml |
Parser 差异示例
from bs4 import BeautifulSoup
html = '<html><br/><p>text</p></html>'
# html.parser - 标准库解析器
soup1 = BeautifulSoup(html, 'html.parser')
# 可能不会自动闭合某些标签
# lxml - 快速且容错
soup2 = BeautifulSoup(html, 'lxml')
# 自动补充缺失的 html/body 标签
# html5lib - 浏览器级容错
soup3 = BeautifulSoup(html, 'html5lib')
# 最大程度模拟浏览器行为,结果最规范find / find_all
soup = BeautifulSoup(html, 'lxml')
# find_all - 查找所有匹配元素
all_divs = soup.find_all('div')
all_items = soup.find_all(class_='item') # 按 class
all_active = soup.find_all('li', class_='active') # 标签 + class
all_by_attrs = soup.find_all(attrs={'data-id': '123'})
# find - 查找第一个匹配元素
first_div = soup.find('div')
first_link = soup.find('a', href=True) # 存在 href 属性的 a 标签
# 使用正则表达式
import re
emails = soup.find_all(text=re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'))
# 限制返回数量
first_three = soup.find_all('div', limit=3)
# 递归控制
direct_children = soup.find_all('div', recursive=False)CSS Selector
# select - 返回列表
items = soup.select('div.container > ul#list > li.item')
first = soup.select_one('div.container > ul#list > li.item')
# 属性选择器
soup.select('a[href^="https"]') # href 以 https 开头
soup.select('a[href$=".pdf"]') # href 以 .pdf 结尾
soup.select('a[href*="download"]') # href 包含 download
soup.select('[data-type="article"]') # 自定义属性
# 伪类选择器
soup.select('li:nth-child(2)') # 第二个 li
soup.select('li:first-child') # 第一个 li
soup.select('li:last-child') # 最后一个 li
soup.select('p:empty') # 空 p 标签
soup.select(':not(.hidden)') # 不包含 hidden 类的元素Tag 属性与 Navigation
# tag 属性
tag = soup.find('a')
print(tag.name) # 标签名: 'a'
print(tag.attrs) # 所有属性: {'href': '...', 'class': ['link']}
print(tag['href']) # 获取属性值
print(tag.get('href')) # 安全获取属性值
print(tag.get('target', '_self')) # 带默认值
# tag 内容
print(tag.string) # 标签内文本(不含子标签)
print(tag.text) # 所有文本(含子标签)
print(tag.get_text(separator=' ', strip=True))
# Navigation 遍历
tag.parent # 父节点
tag.parents # 所有祖先节点(生成器)
tag.children # 直接子节点(生成器)
tag.descendants # 所有子孙节点(生成器)
tag.next_sibling # 下一个兄弟节点
tag.previous_sibling # 上一个兄弟节点
tag.next_siblings # 之后所有兄弟节点(生成器)
tag.previous_siblings # 之前所有兄弟节点(生成器)
# 查找兄弟节点
tag.find_next_sibling('div')
tag.find_previous_sibling('div')
tag.find_all_next('p')
tag.find_all_previous('p')Scrapy 框架
Scrapy 是 Python 最强大的爬虫框架,提供了完整的爬取、处理、存储流水线。
项目结构
myproject/
├── scrapy.cfg # 项目配置
├── myproject/
│ ├── __init__.py
│ ├── items.py # Item 定义(数据模型)
│ ├── middlewares.py # 中间件
│ ├── pipelines.py # Pipeline(数据处理与存储)
│ ├── settings.py # 全局设置
│ ├── spiders/ # Spider 目录
│ │ ├── __init__.py
│ │ └── example_spider.py
│ └── itemloaders.py # Item Loader(可选)Spider 示例
import scrapy
from myproject.items import ProductItem
class ProductSpider(scrapy.Spider):
name = 'product_spider'
allowed_domains = ['example.com']
start_urls = ['https://example.com/products']
def parse(self, response):
"""解析列表页"""
for product in response.css('div.product-item'):
item = ProductItem()
item['name'] = product.css('h2::text').get()
item['price'] = product.css('span.price::text').get()
item['url'] = product.css('a::attr(href)').get()
# 跟进详情页
yield response.follow(item['url'], self.parse_detail, meta={'item': item})
# 分页
next_page = response.css('a.next::attr(href)').get()
if next_page:
yield response.follow(next_page, self.parse)
def parse_detail(self, response):
"""解析详情页"""
item = response.meta['item']
item['description'] = response.css('div.description::text').get()
item['specs'] = response.css('table.specs td::text').getall()
yield itemItem 定义
# items.py
import scrapy
class ProductItem(scrapy.Item):
name = scrapy.Field()
price = scrapy.Field()
url = scrapy.Field()
description = scrapy.Field()
specs = scrapy.Field()
crawl_time = scrapy.Field()Pipeline 数据处理
# pipelines.py
import json
import pymongo
from itemadapter import ItemAdapter
class JsonPipeline:
"""JSON 文件存储"""
def open_spider(self, spider):
self.file = open('products.json', 'w', encoding='utf-8')
self.file.write('[')
def close_spider(self, spider):
self.file.write(']')
self.file.close()
def process_item(self, item, spider):
line = json.dumps(ItemAdapter(item).asdict(), ensure_ascii=False) + ',\n'
self.file.write(line)
return item
class MongoPipeline:
"""MongoDB 存储"""
def __init__(self, mongo_uri, mongo_db):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db
@classmethod
def from_crawler(cls, crawler):
return cls(
mongo_uri=crawler.settings.get('MONGO_URI'),
mongo_db=crawler.settings.get('MONGO_DATABASE'),
)
def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]
def close_spider(self, spider):
self.client.close()
def process_item(self, item, spider):
self.db['products'].insert_one(ItemAdapter(item).asdict())
return item
class DuplicatesPipeline:
"""去重 Pipeline"""
def __init__(self):
self.urls_seen = set()
def process_item(self, item, spider):
adapter = ItemAdapter(item)
if adapter.get('url') in self.urls_seen:
raise DropItem(f"重复项: {adapter['url']}")
self.urls_seen.add(adapter['url'])
return itemMiddleware 中间件
# middlewares.py
import random
from scrapy import signals
from scrapy.downloadermiddlewares.useragent import UserAgentMiddleware
class RandomUserAgentMiddleware(UserAgentMiddleware):
"""随机 User-Agent"""
def __init__(self, user_agents):
self.user_agents = user_agents
@classmethod
def from_crawler(cls, crawler):
return cls(
user_agents=crawler.settings.get('USER_AGENT_LIST', [])
)
def process_request(self, request, spider):
request.headers['User-Agent'] = random.choice(self.user_agents)
class ProxyMiddleware:
"""代理中间件"""
def process_request(self, request, spider):
request.meta['proxy'] = 'http://127.0.0.1:8080'
class RetryMiddleware:
"""自定义重试中间件"""
def process_response(self, request, response, spider):
if response.status in [403, 429]:
# 被封 IP 时的处理
spider.logger.warning(f"请求被限制: {request.url}")
# 切换代理重试
new_request = request.copy()
new_request.meta['proxy'] = get_new_proxy()
return new_request
return responseSettings 配置
# settings.py
BOT_NAME = 'myproject'
SPIDER_MODULES = ['myproject.spiders']
NEWSPIDER_MODULE = 'myproject.spiders'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...'
ROBOTSTXT_OBEY = True
# 下载延迟
DOWNLOAD_DELAY = 1.0 # 基础延迟(秒)
RANDOMIZE_DOWNLOAD_DELAY = True # 随机化延迟(0.5x ~ 1.5x)
# 并发控制
CONCURRENT_REQUESTS = 16 # 全局并发请求数
CONCURRENT_REQUESTS_PER_DOMAIN = 8 # 单域名并发
CONCURRENT_REQUESTS_PER_IP = 0 # 单 IP 并发(0 表示不限制)
REACTOR_THREADPOOL_MAXSIZE = 20 # 线程池大小
# 重试设置
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]
# 超时设置
DOWNLOAD_TIMEOUT = 30
CONNECT_TIMEOUT = 10
# Pipeline 配置(数值越小优先级越高)
ITEM_PIPELINES = {
'myproject.pipelines.DuplicatesPipeline': 100,
'myproject.pipelines.JsonPipeline': 200,
'myproject.pipelines.MongoPipeline': 300,
}
# Middleware 配置
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.RandomUserAgentMiddleware': 400,
'myproject.middlewares.ProxyMiddleware': 500,
'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
}
# 自定义设置
MONGO_URI = 'mongodb://localhost:27017'
MONGO_DATABASE = 'scrapy_data'
USER_AGENT_LIST = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...',
'Mozilla/5.0 (X11; Linux x86_64) ...',
]Scrapy 进阶
CrawlSpider
CrawlSpider 通过 Rule 规则自动跟进链接,适合爬取整站内容。
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor
class BlogCrawlSpider(CrawlSpider):
name = 'blog_crawler'
allowed_domains = ['blog.example.com']
start_urls = ['https://blog.example.com/']
rules = (
# 提取分类页链接
Rule(LinkExtractor(allow=r'/category/\w+/$'), follow=True),
# 提取分页链接
Rule(LinkExtractor(allow=r'/page/\d+/$'), follow=True),
# 提取文章详情链接(最终爬取目标)
Rule(LinkExtractor(allow=r'/article/\d+/$'), callback='parse_article'),
)
def parse_article(self, response):
yield {
'title': response.css('h1.title::text').get(),
'content': response.css('div.content').get(),
'url': response.url,
}Item Loader
Item Loader 提供了一种声明式的数据清洗和处理方式。
from scrapy.loader import ItemLoader
from scrapy.loader.processors import TakeFirst, MapCompose, Join
from itemloaders.processors import Identity
import re
def strip_whitespace(value):
return value.strip() if value else value
def parse_price(value):
"""解析价格(去除货币符号)"""
if value:
return float(re.sub(r'[^\d.]+', '', value))
return 0.0
def parse_stock(value):
return value.strip() == '有货'
class ProductLoader(ItemLoader):
default_output_processor = TakeFirst()
name_in = MapCompose(str.strip)
price_in = MapCompose(parse_price)
description_in = MapCompose(strip_whitespace)
in_stock_in = MapCompose(parse_stock)
images_out = Identity() # 保持为列表
tags_out = Identity()
# 使用 Item Loader
loader = ProductLoader(item=ProductItem(), response=response)
loader.add_css('name', 'h1.product-title::text')
loader.add_css('price', 'span.price::text')
loader.add_css('description', 'div.description::text')
loader.add_css('in_stock', 'span.stock-status::text')
loader.add_css('images', 'img.product-image::attr(src)')
loader.add_value('url', response.url)
yield loader.load_item()ImagesPipeline / FilesPipeline
Scrapy 内置了媒体文件下载 Pipeline。
# settings.py
ITEM_PIPELINES = {
'scrapy.pipelines.images.ImagesPipeline': 1,
'scrapy.pipelines.files.FilesPipeline': 2,
}
IMAGES_STORE = '/data/images' # 图片存储目录
FILES_STORE = '/data/files' # 文件存储目录
IMAGES_THUMBS = { # 自动生成缩略图
'small': (50, 50),
'medium': (200, 200),
}
IMAGES_MIN_HEIGHT = 100 # 最小高度过滤
IMAGES_MIN_WIDTH = 100 # 最小宽度过滤
IMAGES_EXPIRES = 90 # 缓存过期天数
FILES_EXPIRES = 90# items.py - 图片和文件 Item
class ProductWithImagesItem(scrapy.Item):
name = scrapy.Field()
image_urls = scrapy.Field() # 必须命名为 image_urls(ImagesPipeline)
images = scrapy.Field() # 下载结果自动写入
file_urls = scrapy.Field() # 必须命名为 file_urls(FilesPipeline)
files = scrapy.Field() # 下载结果自动写入
# 自定义 ImagesPipeline
from scrapy.pipelines.images import ImagesPipeline
from scrapy.http import Request
class CustomImagesPipeline(ImagesPipeline):
def file_path(self, request, response=None, info=None, *, item=None):
"""自定义文件存储路径"""
image_name = request.url.split('/')[-1]
return f'full/{item["name"]}/{image_name}'
def item_completed(self, results, item, info):
if results:
item['images'] = [result['path'] for ok, result in results if ok]
return item分布式爬虫 Scrapy-Redis
Scrapy-Redis 将 Scrapy 的调度器和去重集合迁移到 Redis,实现多台机器协同爬取。
# settings.py
# 启用 Scrapy-Redis 调度器
SCHEDULER = 'scrapy_redis.scheduler.Scheduler'
SCHEDULER_PERSIST = True # 爬取暂停后不丢失队列
# 使用 Redis 去重
DUPEFILTER_CLASS = 'scrapy_redis.dupefilter.RFPDupeFilter'
# Redis 连接配置
REDIS_URL = 'redis://localhost:6379'
# 可选的:从 Redis 队列读取 start_urls
REDIS_START_URLS_KEY = '%(name)s:start_urls'# spider.py - 基于 Scrapy-Redis 的 Spider
from scrapy_redis.spiders import RedisSpider
class RedisProductSpider(RedisSpider):
"""从 Redis 队列读取起始 URL"""
name = 'redis_product_spider'
allowed_domains = ['example.com']
# 从 Redis key 'redis_product_spider:start_urls' 读取起始 URL
redis_key = '%(name)s:start_urls'
def parse(self, response):
# 同普通 Spider 的 parse 方法
pass
# 推送起始 URL 到 Redis:
# redis-cli lpush redis_product_spider:start_urls https://example.com/products
# 启动多个爬虫实例(多台机器):
# scrapy crawl redis_product_spiderJava 爬虫
Jsoup
Jsoup 是 Java 平台最流行的 HTML 解析库,提供了 jQuery 风格的 API。
parse 解析
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.io.File;
import java.io.IOException;
// 解析 HTML 字符串
String html = "<html><body><div class='content'>Hello</div></body></html>";
Document doc = Jsoup.parse(html);
// 解析 URL(直接获取并解析)
Document docFromUrl = Jsoup.connect("https://example.com")
.userAgent("Mozilla/5.0 ...")
.timeout(10000)
.get();
// 解析文件
Document docFromFile = Jsoup.parse(new File("index.html"), "UTF-8");
// 解析片段
Document fragment = Jsoup.parseBodyFragment("<div><p>片段</p></div>");Connection 设置
// 完整 Connection 配置
Document doc = Jsoup.connect("https://example.com/login")
.url("https://example.com/login")
.userAgent("Mozilla/5.0 ...")
.timeout(10000)
.cookie("session_id", "abc123")
.referrer("https://google.com")
.header("Accept-Language", "zh-CN,zh;q=0.9")
.method(Connection.Method.POST)
.data("username", "admin")
.data("password", "123456")
.followRedirects(true)
.ignoreHttpErrors(false)
.maxBodySize(0) // 无限制
.execute()
.parse();Selector 选择器
Document doc = Jsoup.connect("https://example.com").get();
// CSS Selector
Elements links = doc.select("a[href]"); // 所有带 href 的 a 标签
Elements images = doc.select("img[src$=.jpg]"); // 所有 .jpg 图片
Elements items = doc.select("div.item, li.product"); // 多选择器
Element first = doc.selectFirst("h1.title"); // 第一个匹配
// 属性选择
Elements externalLinks = doc.select("a[href^=https]"); // href 以 https 开头
Elements pdfLinks = doc.select("a[href$=.pdf]"); // href 以 .pdf 结尾
Elements containsLinks = doc.select("a[href*=download]"); // href 包含 download
// 伪类选择器
Elements oddRows = doc.select("tr:odd"); // 奇数行
Elements evenRows = doc.select("tr:even"); // 偶数行
Element lastItem = doc.select("li:last-child").first(); // 最后一个 li
// 父子/兄弟选择器
Elements directChildren = doc.select("ul > li"); // 直接子元素
Elements descendants = doc.select("ul li"); // 所有后代
Elements siblings = doc.select("li + li"); // 相邻兄弟Elements / Element 操作
// Elements 集合操作
Elements items = doc.select("li.product");
System.out.println(items.size()); // 数量
items.first(); // 第一个
items.last(); // 最后一个
items.get(2); // 索引(从 0 开始)
items.subList(0, 5); // 子列表
// 遍历
for (Element item : items) {
// 处理每个元素
}
// Element 操作
Element div = doc.selectFirst("div.content");
div.tagName(); // 标签名
div.id(); // id 属性
div.className(); // class 属性
div.classNames(); // 所有 class(Set<String>)
div.hasClass("active"); // 是否包含 class
div.attributes(); // 所有属性
div.dataset(); // data-* 属性(Map<String, String>)
// DOM 遍历
div.parent(); // 父节点
div.parents(); // 所有祖先
div.children(); // 直接子元素
div.childElements(); // 直接子元素(Element 版本)
div.firstElementChild(); // 第一个子元素
div.lastElementChild(); // 最后一个子元素
div.siblingElements(); // 兄弟元素
div.nextElementSibling(); // 下一个兄弟元素
div.previousElementSibling(); // 上一个兄弟元素attributes / text / html
// 属性操作
Element link = doc.selectFirst("a");
link.attr("href"); // 获取 href 属性值
link.attr("target", "_blank"); // 设置属性
link.removeAttr("rel"); // 移除属性
link.hasAttr("download"); // 是否包含属性
link.absUrl("href"); // 解析为绝对 URL
// 文本操作
Element p = doc.selectFirst("p.content");
p.text(); // 纯文本(不含标签)
p.wholeText(); // 全部文本(含空白)
p.textNodes(); // 所有 TextNode
p.getOwnText(); // 仅自身的直接文本
// HTML 操作
div.html(); // 内部 HTML
div.outerHtml(); // 外部 HTML(含自身标签)
div.data(); // 数据内容(script/style)
// 数据提取综合示例
Elements products = doc.select("div.product");
for (Element product : products) {
String name = product.selectFirst("h2.name").text();
double price = Double.parseDouble(
product.selectFirst("span.price").text().replace("$", "")
);
String url = product.selectFirst("a").absUrl("href");
String image = product.selectFirst("img").attr("src");
System.out.printf("%s: $%.2f [%s]%n", name, price, url);
}