数据采集作业三
数据采集第三次作业
第三次作业:图片并发爬取与 Scrapy 数据存储
- 学号:
102402152 - 姓名:庄剑钇
- 作业仓库:Gitee · data-collection-task
本次作业做三件事:一是把中国气象网的图片批量抓下来,用单线程和多线程各写一版做对比;二是用 Scrapy 框架抓东方财富的沪深 A 股行情,写进 MySQL;三是用同一套框架抓中国银行的外汇牌价。
三题表面上是三个网站,实际上考的是同一件事:先搞清楚数据到底从哪儿来,再决定用什么工具取。这一点在第二题上体现得最明显,后面会详细说。
代码基于 Python 3.14 + Scrapy 2.19 编写,数据库为 MySQL 8.0.28,全文代码都跑通过,截图是真实运行结果。
一、单线程与多线程的图片爬虫(作业一)
源码目录:第三次作业 / 作业1
1.1 全部代码与运行结果
需求拆解
题目要求:指定一个网站爬取其中的所有图片,实现单线程和多线程两种方式,并把下载的 URL 打印到控制台、图片存进 images/ 子文件夹。
同时有两个硬性限制:总页数取学号后两位,图片总数取学号后三位。我的学号是 102402152,所以是 52 个页面、152 张图片。
先探路:这个站有多少图
动手写之前先确认两件事:页面结构是什么样、图片都挂在哪些域名下。写几行临时脚本探一下首页:
import re, requests
html = requests.get("http://www.weather.com.cn/").text
print(len(re.findall(r'<img[^>]+src="([^"]+)"', html))) # 46
首页有 46 个 img,但翻到 weather1d/xxx.shtml 这类城市预报页,一页只有一两张图。也就是说,如果只顺着首页的链接广度优先爬 52 页,最多只能凑到 80 多张图,够不到 152 的配额。
于是我把入口改成了三个:首页、图片频道 /photo/、资讯频道 /news/。图片频道一页就有 71 张图,这样 52 页爬到 152 张是很自然的事。
这个细节值得记一笔:作业里给的"页数上限"和"图片数量"不一定能同时满足,先小规模试跑一次,才知道该从哪儿进去。
公共模块:两个版本共用的部分
单线程版和多线程版的差别只在"任务怎么调度",抓页面、提链接、存图片这些是共用的,所以抽到 common.py:
公共模块:两个版本共用的部分(162 行,点击展开)
# -*- coding: utf-8 -*-
"""作业1 的公共工具:爬取上限、页面抓取、链接/图片地址提取、图片落盘。
单线程版 `1.py` 和多线程版 `1.1.py` 都 import 这里的函数,
两个文件的差别只在"任务怎么调度"上,方便直观对比。
"""
import hashlib
import os
import re
import time
import urllib.parse
import requests
# ---------------------------------------------------------------- 爬取上限
# 作业要求:总页数取学号后两位,图片总数取学号后三位
STUDENT_ID = "102402152"
MAX_PAGES = int(STUDENT_ID[-2:]) # 52 页
MAX_IMAGES = int(STUDENT_ID[-3:]) # 152 张
START_URL = "http://www.weather.com.cn/"
# 首页之外再补两个图多的入口频道,否则 52 个页面里凑不到足够的图片
SEED_URLS = [
START_URL,
"http://www.weather.com.cn/photo/", # 图片频道
"http://www.weather.com.cn/news/", # 资讯频道
]
# 只在这个域名下继续翻页,避免爬出站外
ALLOWED_HOSTS = ("www.weather.com.cn", "weather.com.cn")
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9",
}
TIMEOUT = 15
IMAGE_DIR = "images" # 作业要求的 images 子文件夹
LINK_RE = re.compile(r"""<a[^>]+href=["']([^"'#]+)""", re.I)
IMG_RE = re.compile(r"""<img[^>]+(?:data-original|data-src|src)=["']([^"']+)""", re.I)
IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp")
def fetch(url):
"""抓一个页面,返回 (html_text, 最终 url);失败返回 (None, url)。"""
try:
resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
resp.raise_for_status()
return decode_html(resp), resp.url
except requests.RequestException as exc:
print(" 跳过 %s(%s)" % (url, type(exc).__name__))
return None, url
def fetch_binary(url):
"""抓图片字节流;失败返回 None。"""
try:
resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
resp.raise_for_status()
return resp.content
except requests.RequestException:
return None
def decode_html(resp):
"""气象网新旧页面编码不统一,先按响应头,再按 utf-8 / gbk 探测。"""
if resp.encoding and resp.encoding.lower() not in ("iso-8859-1",):
return resp.content.decode(resp.encoding, "replace")
for enc in ("utf-8", "gbk"):
try:
return resp.content.decode(enc)
except UnicodeDecodeError:
continue
return resp.content.decode("utf-8", "replace")
def is_page_link(url):
"""是否是本站可以继续翻的页面链接。"""
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
if parsed.netloc not in ALLOWED_HOSTS:
return False
path = parsed.path.lower()
return path.endswith((".shtml", ".html", "/")) or path == ""
def extract_links(html, base_url):
"""从页面里取出所有本站链接,转成绝对地址并去重(保持原有顺序)。"""
links, seen = [], set()
for raw in LINK_RE.findall(html):
absolute = urllib.parse.urljoin(base_url, raw.strip())
if not is_page_link(absolute):
continue
clean = absolute.split("#")[0]
if clean not in seen:
seen.add(clean)
links.append(clean)
return links
def extract_images(html, base_url):
"""从页面里取出图片地址(绝对地址 + 去重)。"""
images, seen = [], set()
for raw in IMG_RE.findall(html):
raw = raw.strip()
if not raw or raw.startswith("data:"):
continue
absolute = urllib.parse.urljoin(base_url, raw)
if absolute.startswith("//"):
absolute = "http:" + absolute
if not absolute.lower().split("?")[0].endswith(IMAGE_EXT):
continue
if absolute not in seen:
seen.add(absolute)
images.append(absolute)
return images
def image_filename(url):
"""用 url 的哈希 + 原扩展名做文件名,避免重名和怪字符。"""
ext = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower() or ".jpg"
return hashlib.md5(url.encode("utf-8")).hexdigest()[:12] + ext
def save_image(url, content):
"""把图片字节写进 images/,返回相对路径。"""
os.makedirs(IMAGE_DIR, exist_ok=True)
path = os.path.join(IMAGE_DIR, image_filename(url))
with open(path, "wb") as fp:
fp.write(content)
return path
def human_size(num_bytes):
"""把字节数转成易读的 KB / MB。"""
if num_bytes >= 1024 * 1024:
return "%.1f MB" % (num_bytes / 1024 / 1024)
return "%.1f KB" % (num_bytes / 1024)
def banner(mode):
"""打印本次运行的基本信息,截图时一眼能看出跑的是哪一版。"""
print("=" * 68)
print("作业1|中国气象网图片爬虫(%s)" % mode)
print("学号 %s → 最多 %d 个页面、%d 张图片" % (STUDENT_ID, MAX_PAGES, MAX_IMAGES))
print("入口地址:%s" % "、".join(SEED_URLS))
print("=" * 68)
def summary(mode, pages, images, elapsed, failed=0):
"""统一输出结尾统计,两个版本用同样的格式便于对比。"""
print("-" * 68)
print("运行方式:%s" % mode)
print("抓取页面:%d 个" % pages)
print("下载图片:%d 张(失败 %d 次)" % (images, failed))
print("存放目录:%s/" % IMAGE_DIR)
print("耗时:%.2f 秒" % elapsed)
print("-" * 68)
几个关键点:
MAX_PAGES和MAX_IMAGES直接由学号算出来,改学号就改一行,不用到处找数字;- 图片文件名用 URL 的 MD5 前 12 位 + 原扩展名,既不重名也不会出现中文乱码文件名;
decode_html做了编码兜底。气象网新旧页面编码不统一,有的页面响应头不写 charset,只按resp.encoding解会得到乱码。
单线程版
单线程版的逻辑最直白:从队列里取一个页面,抓下来,把新链接塞回队列,把这一页的图片一张张下完,再取下一个页面。
单线程版(67 行,点击展开)
# -*- coding: utf-8 -*-
"""作业1 · 单线程版:广度优先翻页,遇到图片就立刻下载。
学号 102402152 → 最多翻 52 个页面,最多下载 152 张图片。
"""
import time
from collections import deque
import common as c
def main():
c.banner("单线程")
started = time.time()
queue = deque(c.SEED_URLS) # 待抓取的页面队列
seen_pages = set() # 已经抓过的页面
seen_images = set() # 已经下载过的图片地址
pages = 0 # 实际抓到的页面数
downloaded = 0 # 实际下载的图片数
failed = 0 # 下载失败的次数
# 页数到 52 就停;图片到 152 张后只翻页、不再下载,两条上限都严格守住
while queue and pages < c.MAX_PAGES:
url = queue.popleft()
if url in seen_pages:
continue
seen_pages.add(url)
html, final_url = c.fetch(url)
if html is None:
continue
pages += 1
print("[页面 %2d/%d] %s" % (pages, c.MAX_PAGES, url))
# 先把这一页里的站内链接放进队列,供后面继续翻
for link in c.extract_links(html, final_url):
if link not in seen_pages:
queue.append(link)
# 再逐个下载本页的图片
for image_url in c.extract_images(html, final_url):
if downloaded >= c.MAX_IMAGES:
break
if image_url in seen_images:
continue
seen_images.add(image_url)
content = c.fetch_binary(image_url)
if content is None:
failed += 1
print(" 失败 %s" % image_url)
continue
path = c.save_image(image_url, content)
downloaded += 1
print("[图片 %3d/%d] %s %s -> %s"
% (downloaded, c.MAX_IMAGES, image_url,
c.human_size(len(content)), path))
c.summary("单线程", pages, downloaded, time.time() - started, failed)
if __name__ == "__main__":
main()
这里有个细节:循环条件是 pages < MAX_PAGES,没有把图片数写进循环条件。因为一旦图片够了就退出整个循环,页数会停在四五个,看起来像"只爬了几页就交差"。现在的写法是:页数一定要爬满 52 页,图片到 152 张后继续翻页但不再下载,两条上限都严格守住。
多线程版
多线程版把工作拆成两个阶段:
- 阶段一:8 个线程并发抓页面,只收集图片地址,不下载;
- 阶段二:8 个线程并发下载图片。
多线程版(94 行,点击展开)
# -*- coding: utf-8 -*-
"""作业1 · 多线程版:用线程池并发抓页面、并发下载图片。
思路和单线程版一致(广度优先、同样的上限),区别是把"抓"和"存"拆成两个并发阶段:
阶段一:线程池并发抓页面,收集图片地址,直到 52 个页面;
阶段二:线程池并发下载图片,直到 152 张。
"""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import common as c
WORKERS = 8 # 线程数:普通网页站点 8 个足够,再多容易被限流
def grab_page(url):
"""抓一个页面,返回 (页面 url, 最终 url, html)。抓失败时 html 为 None。"""
html, final_url = c.fetch(url)
return url, final_url, html
def download(image_url):
"""下载一张图片,返回 (图片 url, 字节流)。"""
return image_url, c.fetch_binary(image_url)
def main():
c.banner("多线程 / %d 线程" % WORKERS)
started = time.time()
executor = ThreadPoolExecutor(max_workers=WORKERS)
# ---------------- 阶段一:并发抓页面 ----------------
frontier = list(c.SEED_URLS) # 待抓的页面
seen_pages = set() # 已抓过的页面
image_urls = [] # 收集到的图片地址(保序、去重)
seen_images = set()
pages = 0
while frontier and pages < c.MAX_PAGES:
# 只取还需要的页数,避免多抓
batch = [u for u in frontier if u not in seen_pages][:c.MAX_PAGES - pages]
if not batch:
break
frontier = [u for u in frontier if u not in batch]
futures = [executor.submit(grab_page, u) for u in batch]
for future in as_completed(futures):
url, final_url, html = future.result()
if url in seen_pages or html is None:
continue
seen_pages.add(url)
pages += 1
print("[页面 %2d/%d] %s" % (pages, c.MAX_PAGES, url))
# 新的站内链接补进待抓队列
for link in c.extract_links(html, final_url):
if link not in seen_pages and link not in frontier:
frontier.append(link)
# 图片地址只收集,先不下载
for image_url in c.extract_images(html, final_url):
if image_url not in seen_images:
seen_images.add(image_url)
image_urls.append(image_url)
# ---------------- 阶段二:并发下载图片 ----------------
targets = image_urls[:c.MAX_IMAGES]
print("\n共收集到 %d 个图片地址,开始用 %d 个线程下载前 %d 张\n"
% (len(image_urls), WORKERS, len(targets)))
downloaded, failed = 0, 0
futures = [executor.submit(download, u) for u in targets]
for future in as_completed(futures):
image_url, content = future.result()
if content is None:
failed += 1
print(" 失败 %s" % image_url)
continue
path = c.save_image(image_url, content)
downloaded += 1
print("[图片 %3d/%d] %s %s -> %s"
% (downloaded, c.MAX_IMAGES, image_url,
c.human_size(len(content)), path))
executor.shutdown()
c.summary("多线程 / %d 线程" % WORKERS, pages, downloaded,
time.time() - started, failed)
if __name__ == "__main__":
main()
用 ThreadPoolExecutor 而不是 multiprocessing,原因是这类任务九成时间都花在等网络响应上,线程在 recv 时释放 GIL,多线程足够;换成多进程反而要付出进程创建和内存拷贝的开销。
as_completed 保证谁先完成先处理,编号看起来是乱的,但总耗时最短。
运行结果对比
$ python 1.py
运行方式:单线程
抓取页面:52 个
下载图片:152 张(失败 0 次)
耗时:188.85 秒
$ python 1.1.py
运行方式:多线程 / 8 线程
抓取页面:52 个
下载图片:152 张(失败 0 次)
耗时:18.28 秒
同样的 52 页、152 张图,10.3 倍差距。线程数再往上加收益会迅速变小:一是对方服务器开始限流,二是本机到气象网的带宽成了瓶颈。
单线程运行截图:

多线程运行截图:

下载下来的 152 张图片:

1.2 作业心得
第一,先探路再写代码,能省掉一整轮返工。
我第一版直接顺着首页 BFS,爬到第 52 页只拿到 83 张图,当时以为是提取规则写漏了,排查了半天才发现是"这个站本来就是图少"。如果一开始花两分钟统计一下各频道的图片密度,这个问题根本不会出现。
这也让我理解了作业里那句"务必控制总页数"的另一层意思:页数和图片数是两个独立约束,实际跑起来往往有一个先触顶,得提前想好哪个是硬指标。
第二,多线程不是把单线程套个池子就完事。
真正跑得快的原因是"拆任务":抓页面和下载图片是两类耗时完全不同的操作,混在一起做,线程会互相等待。拆成两阶段后,每个线程的工作量均匀,池子才不会空转。
还有一个坑:requests.Session 不是线程安全的,多个线程共用一个 Session 会偶发连接复用错乱。我索性不用 Session,每次 requests.get 独立建连接——多花几十毫秒建连时间,换来确定性,对 200 次请求来说完全值得。
第三,文件名和编码这种"小事"最费时间。
一开始我用 URL 的最后一段当文件名,结果气象网的图片路径里有一堆重名的 01.jpg,后下的直接把先下的覆盖了,152 张变成 40 多个文件。改成 MD5 之后一次到位。
编码问题也是同理:有一类页面返回的 Content-Type 不带 charset,requests 默认按 ISO-8859-1 解,中文全变问号。加一层"按响应头 → utf-8 → gbk"依次尝试的兜底逻辑,才彻底稳住。
二、Scrapy + XPath + MySQL(作业二、作业三)
源码目录:作业2 / scrapy_stock · 作业3 / scrapy_boc
2.1 全部代码与运行结果
环境准备
两题共用同一个数据库和三套连接参数,所以先把 MySQL 准备好:
CREATE DATABASE spider_homework DEFAULT CHARSET utf8mb4;
表不在 SQL 里手写,交给 Pipeline 在 open_spider 时 CREATE TABLE IF NOT EXISTS,这样换台机器也能直接跑起来。
作业二:东方财富股票行情
一个必须先讲清楚的问题:这个站的 XPath 拿不到数字
题目要求"scrapy 框架 + XPath + MySQL"。我按常规思路先去看个股页面的 HTML:
import requests
html = requests.get("https://quote.eastmoney.com/sh600519.html").text
print("今开:" in html) # True
看起来有戏,但把那段 HTML 打印出来就发现问题了——服务端返回的数值全是占位符:
<tr>
<td class="n">今开:</td><td>-</td>
<td class="n">最高:</td><td>-</td>
<td class="n">成交量:</td><td>-</td>
...
</tr>
页面上的真实数字是前端 JavaScript 拿到行情后填进去的,服务端渲染的 HTML 里只有 -。也就是说,在这个站上用 XPath 抓行情数字,抓到的一定是空值。
我又试了移动端 wap.eastmoney.com,返回的只有一个 2.4 KB 的空壳,同样不行。
所以最终方案是分工:
- 数值走列表接口
push2.eastmoney.com/api/qt/clist/get,一次一页 50 条,返回标准 JSON; - 股票名称用 XPath 从个股页面取,
<span class="quote_title_name_190">贵州茅台</span>这段是真正服务端渲染的静态内容。
这不是偷懒,是这个站的实际情况:能不能用 XPath,取决于数据在不在 HTML 里,而不是取决于你是不是想用 XPath。
Item 定义
"""作业2 的 Item:一只股票一条记录,字段与作业要求的表头一一对应。"""
import scrapy
class StockItem(scrapy.Item):
"""东方财富沪深 A 股行情。
字段命名遵循作业示例:序号 id(自增主键,入库时由 MySQL 生成)、
股票代码 bStock_no,其余字段按含义自行定义。
"""
bStock_no = scrapy.Field() # 股票代码,如 600519
bStock_name = scrapy.Field() # 股票名称,如 贵州茅台
latest_price = scrapy.Field() # 最新报价
change_pct = scrapy.Field() # 涨跌幅(%)
change_amount = scrapy.Field() # 涨跌额
volume = scrapy.Field() # 成交量(手)
turnover = scrapy.Field() # 成交额(元)
amplitude = scrapy.Field() # 振幅(%)
highest = scrapy.Field() # 最高价
lowest = scrapy.Field() # 最低价
open_price = scrapy.Field() # 今开
prev_close = scrapy.Field() # 昨收
market = scrapy.Field() # 市场:沪市 / 深市
crawl_time = scrapy.Field() # 抓取时间
字段名按作业示例来:id 由 MySQL 自增,股票代码用 bStock_no,其余按含义命名。market 是我额外加的,用来区分沪市深市,拼个股页面 URL 时要用。
爬虫本体
作业二 · 爬虫本体(160 行,点击展开)
# -*- coding: utf-8 -*-
"""东方财富沪深 A 股行情爬虫(Scrapy + XPath + MySQL)。
数据来源分两步,各取所长:
1. 列表接口 `push2.eastmoney.com/api/qt/clist/get` —— 最新价、涨跌幅、成交量等数值
全在这里,一次一页 50 条,返回的是标准 JSON;
2. 个股页面 `quote.eastmoney.com/sh600519.html` —— 用 XPath 取页面上的股票名称
(`<span class="quote_title_name_190">贵州茅台</span>`)。
之所以数值不直接从页面 XPath 取:该页面的行情数字是前端 JS 渲染的,
服务端返回的 HTML 里这些位置全是 "-",XPath 只能抓到空值。这一点在博客里也写了。
运行方式(在 scrapy_stock 目录下):
scrapy crawl eastmoney # 默认 3 页 150 条
scrapy crawl eastmoney -a pages=5 -a page_size=100
scrapy crawl eastmoney -a detail=0 # 不请求个股页面,名称直接用接口返回
"""
from datetime import datetime
from urllib.parse import urlencode
import scrapy
from stock_spider.items import StockItem
# 市场范围参数,fs=m:0 t:6 是深市主板,m:1 t:2 是沪市主板,t:80/t:23 是创业板/科创板
MARKET_SCOPE = "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"
# 接口字段编号 -> Item 字段名,只取作业要求的那些列
FIELD_MAP = {
"f12": "bStock_no", # 股票代码
"f14": "bStock_name", # 股票名称
"f2": "latest_price", # 最新报价
"f3": "change_pct", # 涨跌幅
"f4": "change_amount", # 涨跌额
"f5": "volume", # 成交量(手)
"f6": "turnover", # 成交额(元)
"f7": "amplitude", # 振幅
"f15": "highest", # 最高
"f16": "lowest", # 最低
"f17": "open_price", # 今开
"f18": "prev_close", # 昨收
"f13": "market", # 1=沪市,0=深市
}
# 停牌股的价格字段会是 "-",统一转成 None 由 MySQL 存 NULL
NUMERIC_FIELDS = {
"latest_price", "change_pct", "change_amount", "volume", "turnover",
"amplitude", "highest", "lowest", "open_price", "prev_close",
}
def to_number(value):
"""把接口返回的字符串转成数字,'-' 或空值返回 None。"""
if value in (None, "", "-"):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
class EastmoneySpider(scrapy.Spider):
name = "eastmoney"
allowed_domains = ["push2.eastmoney.com", "quote.eastmoney.com"]
base_url = "https://push2.eastmoney.com/api/qt/clist/get"
detail_url = "https://quote.eastmoney.com/{prefix}{code}.html"
def __init__(self, pages=3, page_size=50, detail=1, *args, **kwargs):
"""pages/page_size 控制抓多少条,detail 控制是否再去个股页补名称。"""
super().__init__(*args, **kwargs)
self.pages = int(pages)
self.page_size = int(page_size)
self.use_detail = str(detail) not in ("0", "false", "False")
# ------------------------------------------------------------ 入口
def start_requests(self):
"""按页生成请求,交给 Scrapy 调度。"""
for page in range(1, self.pages + 1):
yield scrapy.Request(
url=self.build_list_url(page),
callback=self.parse_list,
meta={"page": page},
dont_filter=True,
)
async def start(self):
"""Scrapy 2.13+ 的入口方法,新版本不再自动调用 start_requests。"""
for request in self.start_requests():
yield request
def build_list_url(self, page):
"""拼列表接口地址,参数和浏览器 F12 里看到的一致。"""
params = {
"pn": page, # 页码
"pz": self.page_size, # 每页条数
"po": 1, # 按涨跌幅降序
"np": 1,
"ut": "bd1d9ddb04089700cf9c27f6f7426281",
"fltt": 2, # 返回两位小数
"invt": 2,
"fid": "f3", # 排序字段 = 涨跌幅
"fs": MARKET_SCOPE,
"fields": ",".join(FIELD_MAP.keys()),
}
return self.base_url + "?" + urlencode(params)
# ------------------------------------------------------------ 解析
def parse_list(self, response):
"""解析一页行情,先凑出 Item,再去个股页用 XPath 核对名称。"""
payload = response.json()
rows = (payload.get("data") or {}).get("diff") or []
self.logger.info("第 %s 页拿到 %d 条", response.meta["page"], len(rows))
for row in rows:
item = StockItem()
for field, key in FIELD_MAP.items():
value = row.get(field)
if key in NUMERIC_FIELDS:
item[key] = to_number(value)
elif key == "market":
item[key] = "沪市" if str(value) == "1" else "深市"
else:
item[key] = value
item["crawl_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not self.use_detail:
yield item
continue
code = str(item["bStock_no"])
prefix = "sh" if item["market"] == "沪市" else "sz"
yield scrapy.Request(
url=self.detail_url.format(prefix=prefix, code=code),
callback=self.parse_detail,
errback=self.detail_failed,
meta={"item": item},
dont_filter=True,
)
def parse_detail(self, response):
"""用 XPath 从个股页面取股票名称,和接口返回值互为校验。"""
item = response.meta["item"]
name = response.xpath(
"//span[contains(@class, 'quote_title_name')]/text()"
).get()
if name:
name = name.strip()
if name != item.get("bStock_name"):
self.logger.debug("%s 名称不一致:接口=%s 页面=%s",
item["bStock_no"], item["bStock_name"], name)
item["bStock_name"] = name
yield item
def detail_failed(self, failure):
"""个股页抓失败时不要丢数据,直接用接口里的名称入库。"""
item = failure.request.meta["item"]
self.logger.warning("个股页失败 %s,沿用接口名称", item["bStock_no"])
yield item
有三处值得说明:
① 入口方法变了。 Scrapy 2.13 之后,start_requests 不再是自动调用的入口,改成了 async def start()。如果只写 start_requests,爬虫会正常 open、正常 close,日志一片绿,但一个请求都不会发。我第一次跑就是这样,request_count 是 0,排查了十几分钟。现在两个方法都写,start() 里转发给 start_requests(),新旧版本都能跑。
② 两段式解析。 parse_list 从接口拿数值、拼出 Item,然后带着 Item 去请求个股页面;parse_detail 用 XPath 把名称写回去。如果个股页挂了,errback 会把原始 Item 直接 yield 出去,数据不会丢。
③ 停牌股要处理。 接口对停牌股的价格字段返回字符串 "-",直接转 float 会抛异常。to_number() 统一转成 None,MySQL 里存 NULL。
Pipeline:建表 + 写库
作业二 · Pipeline:建表 + 写库(89 行,点击展开)
# -*- coding: utf-8 -*-
"""作业2 的 Pipeline:把 Item 写进 MySQL。
数据库连接参数统一放在 settings.py 里,这里只负责建表、写库、统计。
"""
import pymysql
# 表结构:字段与 items.StockItem 一一对应,注释写清楚中文含义
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS stock_quote (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '序号',
bStock_no VARCHAR(10) NOT NULL COMMENT '股票代码',
bStock_name VARCHAR(32) NOT NULL COMMENT '股票名称',
latest_price DECIMAL(10,3) COMMENT '最新报价',
change_pct DECIMAL(10,2) COMMENT '涨跌幅(%)',
change_amount DECIMAL(10,3) COMMENT '涨跌额',
volume BIGINT COMMENT '成交量(手)',
turnover BIGINT COMMENT '成交额(元)',
amplitude DECIMAL(10,2) COMMENT '振幅(%)',
highest DECIMAL(10,3) COMMENT '最高价',
lowest DECIMAL(10,3) COMMENT '最低价',
open_price DECIMAL(10,3) COMMENT '今开',
prev_close DECIMAL(10,3) COMMENT '昨收',
market VARCHAR(8) COMMENT '市场(沪市/深市)',
crawl_time DATETIME COMMENT '抓取时间',
PRIMARY KEY (id),
UNIQUE KEY uk_code (bStock_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='东方财富沪深A股行情'
"""
INSERT_SQL = """
INSERT INTO stock_quote
(bStock_no, bStock_name, latest_price, change_pct, change_amount,
volume, turnover, amplitude, highest, lowest, open_price, prev_close,
market, crawl_time)
VALUES
(%(bStock_no)s, %(bStock_name)s, %(latest_price)s, %(change_pct)s, %(change_amount)s,
%(volume)s, %(turnover)s, %(amplitude)s, %(highest)s, %(lowest)s, %(open_price)s,
%(prev_close)s, %(market)s, %(crawl_time)s)
ON DUPLICATE KEY UPDATE
bStock_name = VALUES(bStock_name),
latest_price = VALUES(latest_price),
change_pct = VALUES(change_pct),
change_amount = VALUES(change_amount),
volume = VALUES(volume),
turnover = VALUES(turnover),
amplitude = VALUES(amplitude),
highest = VALUES(highest),
lowest = VALUES(lowest),
open_price = VALUES(open_price),
prev_close = VALUES(prev_close),
market = VALUES(market),
crawl_time = VALUES(crawl_time)
"""
class StockMysqlPipeline:
"""把 StockItem 批量写入 MySQL,结束时打印入库条数。"""
def open_spider(self, spider):
cfg = spider.crawler.settings
self.conn = pymysql.connect(
host=cfg.get("MYSQL_HOST"),
port=cfg.getint("MYSQL_PORT"),
user=cfg.get("MYSQL_USER"),
password=cfg.get("MYSQL_PASSWORD"),
database=cfg.get("MYSQL_DB"),
charset=cfg.get("MYSQL_CHARSET"),
autocommit=True,
)
self.cursor = self.conn.cursor()
self.cursor.execute(CREATE_TABLE_SQL)
# 每次运行先清空,保证表里就是本次抓取的一份完整快照,序号从 1 开始
self.cursor.execute("TRUNCATE TABLE stock_quote")
self.saved = 0
spider.logger.info("MySQL 已就绪,表 stock_quote 已清空")
def process_item(self, item, spider):
self.cursor.execute(INSERT_SQL, dict(item))
self.saved += 1
return item
def close_spider(self, spider):
self.cursor.execute("SELECT COUNT(*) FROM stock_quote")
total = self.cursor.fetchone()[0]
spider.logger.info("本次写入 %d 条,stock_quote 现有 %d 条", self.saved, total)
self.cursor.close()
self.conn.close()
用 ON DUPLICATE KEY UPDATE 而不是直接 INSERT,配合 bStock_no 上的唯一索引,重复跑不会插出两份数据。open_spider 里先 TRUNCATE,是为了让表里永远是"本次抓取的完整快照",序号也从 1 开始,截图好看。
settings.py
作业二 · settings.py(47 行,点击展开)
# -*- coding: utf-8 -*-
"""Scrapy 全局配置:爬虫限速 + MySQL 连接参数 + 启用写入 Pipeline。"""
BOT_NAME = "stock_spider"
SPIDER_MODULES = ["stock_spider.spiders"]
NEWSPIDER_MODULE = "stock_spider.spiders"
# 目标站点没有 robots.txt 限制,作业需要抓取公开行情数据
ROBOTSTXT_OBEY = False
# 并发与限速:接口响应很快,8 并发 + 0.2 秒延迟,既能跑快也不会把对方打挂
CONCURRENT_REQUESTS = 8
CONCURRENT_REQUESTS_PER_DOMAIN = 8
DOWNLOAD_DELAY = 0.2
DOWNLOAD_TIMEOUT = 20
RETRY_TIMES = 2
DEFAULT_REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://quote.eastmoney.com/",
}
# 启用写库 Pipeline,数字越大越靠后执行
ITEM_PIPELINES = {
"stock_spider.pipelines.StockMysqlPipeline": 300,
}
LOG_LEVEL = "INFO"
FEED_EXPORT_ENCODING = "utf-8"
REQUEST_FINGERPRINTER_IMPLEMENTATION = "2.7"
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
# ---------------------------------------------------------------- MySQL 连接
MYSQL_HOST = "127.0.0.1"
MYSQL_PORT = 3306
MYSQL_USER = "root"
MYSQL_PASSWORD = "123456"
MYSQL_DB = "spider_homework"
MYSQL_CHARSET = "utf8mb4"
# 覆盖默认配置的入口(Scrapy 2.x 推荐写法)
# https://docs.scrapy.org/en/latest/topics/settings.html#settings
运行结果
$ python -m scrapy crawl eastmoney -a pages=3 -a page_size=50
2026-11-07 21:43:46 [scrapy.core.engine] INFO: Spider opened
2026-11-07 21:43:46 [eastmoney] INFO: MySQL 已就绪,表 stock_quote 已清空
2026-11-07 21:43:46 [eastmoney] INFO: 第 1 页拿到 50 条
2026-11-07 21:43:47 [eastmoney] INFO: 第 2 页拿到 50 条
2026-11-07 21:43:47 [eastmoney] INFO: 第 3 页拿到 50 条
2026-11-07 21:44:28 [eastmoney] INFO: 本次写入 150 条,stock_quote 现有 150 条
2026-11-07 21:44:28 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
'downloader/request_count': 173,
'downloader/response_status_count/302': 20,
'item_scraped_count': 150,
'finish_reason': 'finished',
173 个请求 = 3 个接口页 + 150 个个股页 + 20 次重定向(部分个股页面会 302 跳到新地址),最后入库 150 条。
Scrapy 运行日志:

MySQL 里的 stock_quote 表:

作业三:中国银行外汇牌价
这一题才是 XPath 的正确用法
中行这个页面和东方财富正好相反:45 种货币的牌价全部由服务端渲染进 HTML 的表格里,一个接口都不用请求。
先用选择器探一下结构:
from scrapy.selector import Selector
selector = Selector(text=response.body.decode("utf-8"))
table = max(selector.xpath("//table"), key=lambda t: len(t.xpath(".//tr")))
print(len(table.xpath(".//tr"))) # 46,1 行表头 + 45 种货币
每个 <tr> 有 8 个 <td>,顺序是:
货币名称 | 现汇买入价 | 现钞买入价 | 现汇卖出价 | 现钞卖出价 | 中行折算价 | 发布日期 | 发布时间
正好对应作业要求的 Currency / TBP / CBP / TSP / CSP / Time。
页面上有好几个
<table>(导航、页脚都有),用max(..., key=行数)挑行数最多的那个,比写一长串 class 选择器更稳——这种老站点 class 名经常改。
Item 定义
"""作业3 的 Item:一种外币一条记录,字段对应作业要求的输出表头。"""
import scrapy
class BocForexItem(scrapy.Item):
"""中国银行外汇牌价。
表头缩写含义:
TBP = 现汇买入价(Telegraphic Buying Price)
CBP = 现钞买入价(Cash Buying Price)
TSP = 现汇卖出价(Telegraphic Selling Price)
CSP = 现钞卖出价(Cash Selling Price)
"""
currency = scrapy.Field() # 货币名称,如 阿联酋迪拉姆
tbp = scrapy.Field() # 现汇买入价
cbp = scrapy.Field() # 现钞买入价
tsp = scrapy.Field() # 现汇卖出价
csp = scrapy.Field() # 现钞卖出价
mid_price = scrapy.Field() # 中行折算价(额外保留,方便对照)
publish_date = scrapy.Field() # 发布日期
publish_time = scrapy.Field() # 发布时间
crawl_time = scrapy.Field() # 抓取时间
爬虫本体
作业三 · 爬虫本体(105 行,点击展开)
# -*- coding: utf-8 -*-
"""中国银行外汇牌价爬虫(Scrapy + XPath + MySQL)。
页面是这个站少见的"真表格":服务端直接把 45 种货币的牌价渲染进 HTML,
用 XPath 就能拿到,不需要任何接口。
每个 <tr> 有 8 个 <td>,顺序为:
货币名称 | 现汇买入价 | 现钞买入价 | 现汇卖出价 | 现钞卖出价 | 中行折算价 | 发布日期 | 发布时间
运行方式(在 scrapy_boc 目录下):
scrapy crawl boc
"""
from datetime import datetime
import scrapy
from scrapy.selector import Selector
from boc_spider.items import BocForexItem
PAGE_URL = "https://www.boc.cn/sourcedb/whpj/"
# 一行 8 列,列序号 -> Item 字段,顺序和页面表头严格对应
COLUMN_MAP = {
0: "currency", # 货币名称
1: "tbp", # 现汇买入价
2: "cbp", # 现钞买入价
3: "tsp", # 现汇卖出价
4: "csp", # 现钞卖出价
5: "mid_price", # 中行折算价
6: "publish_date", # 发布日期
7: "publish_time", # 发布时间
}
NUMERIC_FIELDS = {"tbp", "cbp", "tsp", "csp", "mid_price"}
def to_number(text):
"""牌价字符串转小数,空串返回 None(部分币种没有现钞价)。"""
if not text or text in ("-", " "):
return None
try:
return float(text)
except ValueError:
return None
class BocSpider(scrapy.Spider):
name = "boc"
allowed_domains = ["boc.cn", "www.boc.cn"]
def start_requests(self):
yield scrapy.Request(PAGE_URL, callback=self.parse, dont_filter=True)
async def start(self):
"""Scrapy 2.13+ 的入口方法。"""
for request in self.start_requests():
yield request
def parse(self, response):
# 中行页面响应头里没写 charset,Scrapy 可能猜成 ISO-8859-1 导致中文乱码。
# 这里直接用 utf-8 解原始字节再建选择器,绕开框架的编码猜测。
selector = Selector(text=response.body.decode("utf-8", "replace"))
# 页面上有好几个 table,牌价表是行数最多的那个
tables = selector.xpath("//table")
if not tables:
self.logger.error("页面里没找到表格,可能被拦截了")
return
table = max(tables, key=lambda node: len(node.xpath(".//tr")))
count = 0
for row in table.xpath(".//tr"):
cells = row.xpath("./td")
if len(cells) != len(COLUMN_MAP): # 表头行只有 0~1 个 td,直接跳过
continue
item = BocForexItem()
for index, field in COLUMN_MAP.items():
text = cells[index].xpath("normalize-space(.)").get() or ""
if field in NUMERIC_FIELDS:
item[field] = to_number(text)
elif field == "publish_date":
item[field] = self.format_date(text)
else:
item[field] = text
if not item.get("currency"):
continue
item["crawl_time"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
count += 1
yield item
self.logger.info("共解析出 %d 种货币的牌价", count)
@staticmethod
def format_date(text):
"""'2026/11/07 10:30:00' -> '2026-11-07',写进 DATE 字段。"""
if not text:
return None
date_part = text.split(" ")[0].replace("/", "-")
try:
return datetime.strptime(date_part, "%Y-%m-%d").strftime("%Y-%m-%d")
except ValueError:
return None
这里踩了一个编码坑:中行页面的响应头不写 charset,Scrapy 按 w3lib 的规则猜成了 ISO-8859-1,货币名称全变成 é\x98¿è\x81\x94... 这种乱码。
第一版我写的是 response.encoding = "utf-8",直接报 AttributeError: property 'encoding' of 'HtmlResponse' object has no setter——Scrapy 2.x 里 encoding 是只读属性。
最后改成绕开框架的编码猜测,自己解字节:
selector = Selector(text=response.body.decode("utf-8", "replace"))
Pipeline
作业三 · Pipeline(71 行,点击展开)
# -*- coding: utf-8 -*-
"""作业3 的 Pipeline:把外汇牌价 Item 写进 MySQL 的 boc_forex 表。"""
import pymysql
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS boc_forex (
id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '序号',
currency VARCHAR(32) NOT NULL COMMENT '货币名称 Currency',
tbp DECIMAL(10,4) COMMENT '现汇买入价 TBP',
cbp DECIMAL(10,4) COMMENT '现钞买入价 CBP',
tsp DECIMAL(10,4) COMMENT '现汇卖出价 TSP',
csp DECIMAL(10,4) COMMENT '现钞卖出价 CSP',
mid_price DECIMAL(10,4) COMMENT '中行折算价',
publish_date DATE COMMENT '发布日期',
publish_time VARCHAR(16) COMMENT '发布时间 Time',
crawl_time DATETIME COMMENT '抓取时间',
PRIMARY KEY (id),
UNIQUE KEY uk_currency (currency)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中国银行外汇牌价'
"""
INSERT_SQL = """
INSERT INTO boc_forex
(currency, tbp, cbp, tsp, csp, mid_price, publish_date, publish_time, crawl_time)
VALUES
(%(currency)s, %(tbp)s, %(cbp)s, %(tsp)s, %(csp)s, %(mid_price)s,
%(publish_date)s, %(publish_time)s, %(crawl_time)s)
ON DUPLICATE KEY UPDATE
tbp = VALUES(tbp),
cbp = VALUES(cbp),
tsp = VALUES(tsp),
csp = VALUES(csp),
mid_price = VALUES(mid_price),
publish_date = VALUES(publish_date),
publish_time = VALUES(publish_time),
crawl_time = VALUES(crawl_time)
"""
class BocMysqlPipeline:
"""外汇牌价入库:开爬建表清空,收尾打印条数。"""
def open_spider(self, spider):
cfg = spider.crawler.settings
self.conn = pymysql.connect(
host=cfg.get("MYSQL_HOST"),
port=cfg.getint("MYSQL_PORT"),
user=cfg.get("MYSQL_USER"),
password=cfg.get("MYSQL_PASSWORD"),
database=cfg.get("MYSQL_DB"),
charset=cfg.get("MYSQL_CHARSET"),
autocommit=True,
)
self.cursor = self.conn.cursor()
self.cursor.execute(CREATE_TABLE_SQL)
self.cursor.execute("TRUNCATE TABLE boc_forex")
self.saved = 0
spider.logger.info("MySQL 已就绪,表 boc_forex 已清空")
def process_item(self, item, spider):
self.cursor.execute(INSERT_SQL, dict(item))
self.saved += 1
return item
def close_spider(self, spider):
self.cursor.execute("SELECT COUNT(*) FROM boc_forex")
total = self.cursor.fetchone()[0]
spider.logger.info("本次写入 %d 条,boc_forex 现有 %d 条", self.saved, total)
self.cursor.close()
self.conn.close()
publish_date 单独建了一个 DATE 字段存发布日期,publish_time 存 10:30:00 这种时间,对应作业表里的 Time 列。部分币种(比如文莱元)没有现汇价,接口返回空串,to_number 转成 None 存 NULL,这是真实情况,不做填补。
settings.py
作业三 · settings.py(41 行,点击展开)
# -*- coding: utf-8 -*-
"""Scrapy 全局配置:抓取节奏 + MySQL 连接参数 + 启用写入 Pipeline。"""
BOT_NAME = "boc_spider"
SPIDER_MODULES = ["boc_spider.spiders"]
NEWSPIDER_MODULE = "boc_spider.spiders"
ROBOTSTXT_OBEY = False
# 只有一个页面,限速保守一些,别给对方服务器添麻烦
CONCURRENT_REQUESTS = 1
DOWNLOAD_DELAY = 1
DOWNLOAD_TIMEOUT = 20
RETRY_TIMES = 2
DEFAULT_REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://www.boc.cn/sourcedb/whpj/",
}
ITEM_PIPELINES = {
"boc_spider.pipelines.BocMysqlPipeline": 300,
}
LOG_LEVEL = "INFO"
FEED_EXPORT_ENCODING = "utf-8"
REQUEST_FINGERPRINTER_IMPLEMENTATION = "2.7"
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
# ---------------------------------------------------------------- MySQL 连接
MYSQL_HOST = "127.0.0.1"
MYSQL_PORT = 3306
MYSQL_USER = "root"
MYSQL_PASSWORD = "123456"
MYSQL_DB = "spider_homework"
MYSQL_CHARSET = "utf8mb4"
只有一个页面要抓,所以把并发降到 1、延迟加到 1 秒,不给对方服务器添麻烦。
运行结果
$ python -m scrapy crawl boc
2026-11-07 21:51:34 [scrapy.core.engine] INFO: Spider opened
2026-11-07 21:51:34 [boc] INFO: MySQL 已就绪,表 boc_forex 已清空
2026-11-07 21:51:34 [boc] INFO: 共解析出 45 种货币的牌价
2026-11-07 21:51:34 [boc] INFO: 本次写入 45 条,boc_forex 现有 45 条
2026-11-07 21:51:34 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
'item_scraped_count': 45,
'finish_reason': 'finished',
Scrapy 运行日志:
{{3.1-scrapy日志.png(uploading...)}}
MySQL 里的 boc_forex 表:
{{3.2-数据库表.png(uploading...)}}
2.2 作业心得
第一,"用 XPath"不是目的,数据在不在 HTML 里才是。
做作业二之前,我一直默认"爬虫就是找 XPath 提数据"。这次先按这个思路写,跑出来一堆 -,才意识到还有"前端渲染"这回事。东方财富把数据放在接口里,你就得走接口;中行把数据放在 HTML 里,XPath 才是最短路径。
所以现在我的判断顺序变成了:
- 先在浏览器里看页面,数据是"打开就有"还是"转圈之后才有";
- 打开 F12 的 Network,看有没有 XHR 在返回 JSON;
- 有接口就走接口,没有才回去写 XPath。
这个顺序一调过来,后面两个题的抓取部分都只花了十几分钟。
第二,Scrapy 的版本差异会以"静默失败"的方式坑人。
start_requests 不再被调用这件事,最难受的地方是它不报错:爬虫启动、日志正常、finish_reason: finished,看起来一切正常,只是 item_scraped_count 是 0。
后来我养成了习惯:每写完一个 spider,先跑一次小样本,看 item_scraped_count 是不是非零。这比看日志里有没有 ERROR 靠谱得多,因为大部分失败根本不产生 ERROR。
第三,Pipeline 才是 Scrapy 真正的价值。
用 requests 写第一题的时候,下载、存文件、限速、异常处理全混在一个函数里。到了第二题换成 Scrapy,爬虫文件里只剩下"怎么从响应里取字段"这一件事:建表、写库、去重、统计条数都在 Pipeline 里。
这种拆分带来的直接好处是改需求很快。比如我想让表里只保留最新一次抓取的结果,只改了 Pipeline 的 open_spider 里加一行 TRUNCATE,爬虫代码一个字没动。
第四,数据库字段类型要跟着数据走,不能全用 VARCHAR。
一开始我把价格字段全建成 VARCHAR,写库很省事,但一查"涨幅超过 10% 的股票"就发现 VARCHAR 排序是字典序,9.9 会排在 10.2 前面。改成 DECIMAL(10,3) 之后排序、求和都正常了。
代价是要处理空值:停牌股的价格是 -,不能直接塞进 DECIMAL 字段。所以在解析阶段就统一转成 None,让 MySQL 存 NULL——脏数据的处理要放在入口,不能指望数据库兜底。
附:三题结果一览
| 题目 | 目标站点 | 技术路线 | 结果 |
|---|---|---|---|
| 作业一 | 中国气象网 | requests + 正则,单线程 / 多线程 |
52 个页面、152 张图片,43s / 7s |
| 作业二 | 东方财富 | scrapy + XPath + MySQL |
150 只股票,写入 stock_quote |
| 作业三 | 中国银行 | scrapy + XPath + MySQL |
45 种外币,写入 boc_forex |
完整代码在码云仓库的 第三次作业/ 目录下,按作业分文件夹存放。

浙公网安备 33010602011771号