数据采集作业三
数据采集第三次作业
码云仓库地址:https://gitee.com/你的用户名/数据采集作业
作业① 中国气象网图片爬取
1.1 代码与运行结果
爬什么。 爬中国气象网 http://www.weather.com.cn/,在站内广度优先翻页,把页面上的图片下载到 images/ 子文件夹,单线程和多线程各跑一遍,比一下速度。
代码分三个文件。 公共部分抽到 common.py,单线程 1.py、多线程 1.1.py 都 import 它,两个版本的差别只在「任务怎么调度」,这样耗时对比才有意义。
限流设置。 学号 102402145,尾数 2 位当页数上限,后 3 位当图片数上限:
STUDENT_ID = "102402145"
MAX_PAGES = int(STUDENT_ID[-2:]) # 45
MAX_IMAGES = int(STUDENT_ID[-3:]) # 145
几个实现上的点。
-
翻页循环的条件里只放页数:
while queue and pages < MAX_PAGES。图片到 145 张以后只翻页、不再下载,不然会出现「图够了、页数只翻了三四页」的情况。 -
多线程分两个阶段:先并发抓页面、只收集图片地址,再并发下载。
WORKERS = 8,普通网页站点再多容易被限流。下载没用Session,多线程共用一个Session会串。
完整代码 作业1/common.py:
# -*- coding: utf-8 -*-
"""作业1 公共模块:爬取上限、页面抓取、链接与图片地址提取、图片落盘。
单线程版 1.py 和多线程版 1.1.py 都从这里 import,
两个文件的差别只在「任务怎么调度」,这样耗时对比才有意义。
"""
import hashlib
import os
import time
import urllib.parse
import requests
from bs4 import BeautifulSoup
# ------------------------------------------------------------------ 爬取上限
# 作业要求:总页数取学号后 2 位,图片总数取学号后 3 位
STUDENT_ID = "102402145"
MAX_PAGES = int(STUDENT_ID[-2:]) # 45 页
MAX_IMAGES = int(STUDENT_ID[-3:]) # 145 张
START_URL = "http://www.weather.com.cn/"
# 首页图片密度不高,只靠首页翻到 45 页也凑不够 145 张,
# 所以额外把图片频道和资讯频道作为种子入口,这两个频道一页的图最多
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.0.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9",
}
TIMEOUT = 15
IMAGE_DIR = "images" # 作业要求的 images 子文件夹
IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp")
# ------------------------------------------------------------------ 网络请求
def fetch_page(url):
"""抓一个页面,返回 (html 文本, 最终地址);失败返回 (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_image(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() != "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):
"""取页面里所有本站链接,补成绝对地址并去重(保持原顺序)。"""
soup = BeautifulSoup(html, "html.parser")
links, seen = [], set()
for a_tag in soup.find_all("a", href=True):
href = a_tag["href"].strip()
if not href or href.startswith(("javascript:", "mailto:")):
continue
absolute = urllib.parse.urljoin(base_url, href).split("#")[0]
if not is_page_link(absolute) or absolute in seen:
continue
seen.add(absolute)
links.append(absolute)
return links
def extract_images(html, base_url):
"""取页面里所有图片地址,返回绝对地址列表(已去重)。"""
soup = BeautifulSoup(html, "html.parser")
images, seen = [], set()
for img in soup.find_all("img"):
# 懒加载的图 src 是空的,真实地址在 data-src / data-original 上
raw = (img.get("src") or img.get("data-src")
or img.get("data-original") or "").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 in seen:
continue
seen.add(absolute)
images.append(absolute)
return images
# ------------------------------------------------------------------ 落盘与输出
def image_filename(url):
"""文件名 = URL 的 MD5 前 12 位 + 原扩展名。
直接用 URL 最后一段会重名(很多图都叫 01.jpg),后下的把先下的覆盖掉,
所以用哈希做名字,既不重名也不会出现中文乱码。
"""
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 report(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)
if __name__ == "__main__":
# 直接运行本文件时,先探一下三个入口各有多少图,确认配额够用
for url in SEED_URLS:
html, final_url = fetch_page(url)
if html is None:
continue
imgs = extract_images(html, final_url)
print("%-40s 图片 %3d 张,站内链接 %3d 个"
% (url, len(imgs), len(extract_links(html, final_url))))
time.sleep(0.3)
完整代码 作业1/1.py:
"""作业1 · 单线程版:广度优先翻页,抓到图片就立刻下载。
学号 102402145 → 最多翻 45 个页面,最多下载 145 张图片。
"""
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 # 下载失败的次数
# 循环条件里只放页数:图片到 145 张以后只翻页、不再下载,
# 这样两条上限都守得住,不会出现「图够了但只翻了三页」的情况
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_page(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_image(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))
time.sleep(0.3) # 翻页间隔,别请求太密
c.report("单线程", pages, downloaded, time.time() - started, failed)
if __name__ == "__main__":
main()
完整代码 作业1/1.1.py:
"""作业1 · 多线程版:用线程池并发抓页面、并发下载图片。
思路和单线程版一样(广度优先、同样的两条上限),区别是把「抓」和「存」
拆成两个阶段并发跑:
阶段一:并发抓页面,只收集图片地址,攒够 45 页就停;
阶段二:并发下载图片,取前 145 张。
"""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import common as c
WORKERS = 8 # 线程数:普通网页 8 个足够,再多容易被对方限流
def grab_page(url):
"""抓一个页面,返回 (地址, 最终地址, html)。失败时 html 为 None。"""
html, final_url = c.fetch_page(url)
return url, final_url, html
def download(image_url):
"""下载一张图,返回 (图片地址, 字节流)。"""
return image_url, c.fetch_image(image_url)
def main():
c.banner("多线程 / %d 线程" % WORKERS)
started = time.time()
pool = 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 = [pool.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 = [pool.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))
pool.shutdown()
c.report("多线程 / %d 线程" % WORKERS, pages, downloaded,
time.time() - started, failed)
if __name__ == "__main__":
main()
**运行结果:




1.2 实验心得
一是先探路,再定入口。 一开始我以为首页的图够用,跑完只有 46 张。写了个小程序把首页、/photo/、/news/ 三个页面的图片数都数了一遍,分别是 46、71、28,才决定三个入口一起用。
二是文件名不能直接取 URL 最后一段。 气象网的图片很多都叫 01.jpg、02.jpg,只是放在不同目录下。用 URL 末段当文件名,后下的会把先下的覆盖掉,计数和实际文件数就对不上了。改成 URL 的 MD5 前 12 位,既不重名也不会出现中文乱码。
三是页数上限和图片上限要分开控。 如果写成「图片够了就 break」,跳出的是整个 while,页数就停在三四页。所以图片到量只跳过下载、继续翻页,两条上限才都满足。
四是多线程比单线程快 8 倍多。 43.01 秒压到 4.99 秒。这里时间基本都花在等网络,线程一多就摊开了。不过 8 线程加速 8.6 倍已经接近线性,说明这一批任务确实全是 IO 等待,几乎没占 CPU。
五是编码问题比想象中麻烦。 resp.text 出来的中文有时是乱码,气象网有的页面是 utf-8、有的还是 gbk,只能按响应头先试,不行再挨个编码解一遍。
作业②:东方财富沪深 A 股行情
2.1 接口分析与完整代码
页面定位。 从东方财富首页点进「行情」,列表页在 quote.eastmoney.com/center/gridlist.html。
结构分析。 打开 F12,切到 Network,勾上 Fetch/XHR 再刷新,数据是下面这个接口拉回来的:
GET https://push2.eastmoney.com/api/qt/clist/get
?pn=1&pz=100&po=1&np=1&fltt=2&invt=2&fid=f3
&fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048
&fields=f2,f3,f4,f5,f6,f7,f12,f14,f15,f16,f17,f18
参数含义:
| 参数 | 含义 |
|---|---|
pn / pz |
页码 / 每页条数,返回里的 data.total 是总条数,据此算总页数 |
po / fid |
排序方向 / 排序字段,fid=f3 表示按涨跌幅排 |
fs |
市场范围:深主板 m:0+t:6、创业板 m:0+t:80、沪主板 m:1+t:2、科创板 m:1+t:23、北交所 m:0+t:81+s:2048 |
fields |
需要的字段编号,返回的 data.diff 是数组,一条一个股票 |
返回结构是 {"data": {"total": 5927, "diff": [{...}, {...}]}},字段是编号不是名字,所以要建一张映射表。停牌或数据缺失时字段值是字符串 "-",统一转 None、入库为 NULL。
| 接口字段 | Item 字段 | 中文含义 |
|---|---|---|
| f12 | bStockNo |
股票代码 |
| f14 | bStockName |
股票名称 |
| f2 | bLatestPrice |
最新报价 |
| f3 | bChangePercent |
涨跌幅(%) |
| f4 | bChangeAmount |
涨跌额 |
| f5 | bVolume |
成交量(手) |
| f6 | bTurnover |
成交额(元) |
| f7 | bAmplitude |
振幅(%) |
| f15 / f16 | bHigh / bLow |
最高 / 最低 |
| f17 / f18 | bOpen / bPrevClose |
今开 / 昨收 |
完整代码 作业2/stock_scrapy/items.py:
# -*- coding: utf-8 -*-
"""作业②的 Item 定义:东方财富网 · 沪深京 A 股实时行情。
字段命名规则:b + 英文含义(b = basic),与 SQLite 表 eastmoney_stock 的列名一一对应,
便于 Pipeline 直接把 Item 写入数据库。
"""
import scrapy
class StockItem(scrapy.Item):
"""一条股票行情记录 = 表格中的一行。"""
bStockNo = scrapy.Field() # 股票代码,如 600000
bStockName = scrapy.Field() # 股票名称,如 浦发银行
bLatestPrice = scrapy.Field() # 最新报价(元)
bChangePercent = scrapy.Field() # 涨跌幅(%)
bChangeAmount = scrapy.Field() # 涨跌额(元)
bVolume = scrapy.Field() # 成交量(手)
bTurnover = scrapy.Field() # 成交额(元)
bAmplitude = scrapy.Field() # 振幅(%)
bHigh = scrapy.Field() # 最高价
bLow = scrapy.Field() # 最低价
bOpen = scrapy.Field() # 今开价
bPrevClose = scrapy.Field() # 昨收价
bQuoteTime = scrapy.Field() # 本条数据的抓取时间
完整代码 作业2/stock_scrapy/pipelines.py:
# -*- coding: utf-8 -*-
"""作业②的 Pipeline:把 Item 分别做「JSON 序列化输出」和「数据库存储」。
两个 Pipeline 串联执行(数值越小越先执行):
StockJsonPipeline (300) —— 把 Item 序列化成 JSON 写进 output/stock.json
StockSqlitePipeline (400) —— 把 Item 批量写入 SQLite 表 eastmoney_stock
数据库用 SQLite:Python 标准库自带 sqlite3,不用装 MySQL、不用配账号密码,
整个库就是磁盘上的一个 .db 文件,拷走就能用,交作业和答辩演示都方便。
序列化的核心就是 ItemAdapter:它统一了 dict / scrapy.Item / dataclass 三种 Item 的
取值方式,所以 json.dumps(dict(adapter)) 就能拿到干净的字典直接落盘。
"""
import json
import logging
import os
import sqlite3
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
logger = logging.getLogger(__name__) # 版本无关的日志对象
# 建表语句:表头英文命名,与 items.py 中 StockItem 的字段一一对应。
# SQLite 没有 DECIMAL / DATETIME 这些类型,价格统一用 REAL、时间用 TEXT 存字符串。
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS eastmoney_stock (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 序号
bStockNo TEXT NOT NULL, -- 股票代码
bStockName TEXT NOT NULL, -- 股票名称
bLatestPrice REAL, -- 最新报价
bChangePercent REAL, -- 涨跌幅(%)
bChangeAmount REAL, -- 涨跌额
bVolume INTEGER, -- 成交量(手)
bTurnover REAL, -- 成交额(元)
bAmplitude REAL, -- 振幅(%)
bHigh REAL, -- 最高价
bLow REAL, -- 最低价
bOpen REAL, -- 今开价
bPrevClose REAL, -- 昨收价
bQuoteTime TEXT, -- 抓取时间 YYYY-MM-DD HH:MM:SS
UNIQUE (bStockNo, bQuoteTime)
);
"""
# 入库字段顺序(不含自增主键 id,序号由数据库自动生成)
COLUMNS = (
"bStockNo", "bStockName", "bLatestPrice", "bChangePercent", "bChangeAmount",
"bVolume", "bTurnover", "bAmplitude", "bHigh", "bLow", "bOpen", "bPrevClose",
"bQuoteTime",
)
# SQLite 用 ? 作占位符;同一只票同一时刻重复抓到时只更新最新价(ON CONFLICT 即 UPSERT)
INSERT_SQL = (
"INSERT INTO eastmoney_stock (%s) VALUES (%s) "
"ON CONFLICT(bStockNo, bQuoteTime) DO UPDATE SET bLatestPrice=excluded.bLatestPrice"
% (", ".join(COLUMNS), ", ".join(["?"] * len(COLUMNS)))
)
class StockJsonPipeline:
"""把 Item 序列化成 JSON Lines(每行一个对象)保存到 output/stock.json。"""
def open_spider(self, spider=None):
os.makedirs("output", exist_ok=True)
self.file = open(os.path.join("output", "stock.json"), "w", encoding="utf-8")
self.count = 0
def process_item(self, item, spider=None):
# ItemAdapter 把 Item 转成普通字典,再用 json.dumps 序列化
record = dict(ItemAdapter(item).asdict())
self.file.write(json.dumps(record, ensure_ascii=False) + "\n")
self.count += 1
return item
def close_spider(self, spider=None):
self.file.close()
logger.info("JSON 序列化完成:output/stock.json,共 %d 条", self.count)
class StockSqlitePipeline:
"""把 Item 批量写入 SQLite:累积到 batch_size 条后一次性 executemany。"""
batch_size = 200
def __init__(self, settings):
self.settings = settings
self.buffer = []
self.conn = None
@classmethod
def from_crawler(cls, crawler):
"""由 Scrapy 调用,把 settings 里的数据库配置传进来。"""
return cls(crawler.settings)
def open_spider(self, spider=None):
"""开爬前建库建表,并清空上一轮的行情快照(实时行情只保留最新一次)。"""
cfg = self.settings.getdict("SQLITE_CONFIG")
db_path = cfg["database"]
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
# connect 时文件不存在就会自动创建,相当于「建库」
self.conn = sqlite3.connect(db_path)
self.conn.execute(CREATE_TABLE_SQL)
self.conn.execute("DELETE FROM eastmoney_stock") # 避免重复运行堆叠旧数据
self.conn.commit()
logger.info("数据库 %s 的 eastmoney_stock 表准备就绪", db_path)
def process_item(self, item, spider=None):
"""校验必填字段后放入缓冲区,攒够一批再写库。"""
adapter = ItemAdapter(item)
if not adapter.get("bStockNo") or not adapter.get("bStockName"):
raise DropItem("缺少股票代码/名称:%s" % dict(adapter))
self.buffer.append(tuple(adapter.get(col) for col in COLUMNS))
if len(self.buffer) >= self.batch_size:
self.flush_buffer()
return item
def flush_buffer(self):
"""把缓冲区一次性写入数据库。"""
if not self.buffer:
return
self.conn.executemany(INSERT_SQL, self.buffer)
self.conn.commit()
self.buffer.clear()
def close_spider(self, spider=None):
"""收尾:写完剩余数据并关闭连接。"""
self.flush_buffer()
if self.conn:
self.conn.close()
完整代码 作业2/stock_scrapy/spiders/stock.py:
Scrapy 2.13 之后入口从 start_requests() 变成异步生成器 start(),两个都写上,新旧版本都能跑。
# -*- coding: utf-8 -*-
"""作业②的爬虫:东方财富网 沪深京 A 股实时行情。
数据来源
--------
东方财富的行情列表页(quote.eastmoney.com/center/gridlist.html)是 JavaScript
动态渲染的,直接请求 HTML 用 XPath 取不到表格数据,因此这里改用页面背后真正
取数的 JSON 接口 push2.eastmoney.com/api/qt/clist/get:
- fs 参数指定市场范围:m:0 t:6 深市主板 / m:0 t:80 创业板 / m:1 t:2 沪市主板
/ m:1 t:23 科创板 / m:0 t:81 s:2048 北交所
- pn / pz 为页码与每页条数,按 total 计算总页数后翻页
- fields 指定需要的字段,避免返回一堆用不上的数据
字段对应关系
------------
f2 最新价 / f3 涨跌幅 / f4 涨跌额 / f5 成交量(手) / f6 成交额(元) / f7 振幅
f12 代码 / f14 名称 / f15 最高 / f16 最低 / f17 今开 / f18 昨收
停牌或数据缺失时接口返回字符串 "-",这里统一转成 None,方便入库为 NULL。
"""
import math
from datetime import datetime
from urllib.parse import urlencode
import scrapy
from stock_scrapy.items import StockItem
class StockSpider(scrapy.Spider):
"""翻页抓取全部 A 股行情,逐条 yield StockItem 交给 Pipeline 处理。"""
name = "stock"
allowed_domains = ["eastmoney.com"]
api_url = "https://push2.eastmoney.com/api/qt/clist/get"
page_size = 100 # 每页条数,接口允许 100
market_filter = ( # 沪深京 A 股:深主板 + 创业板 + 沪主板 + 科创板 + 北交所
"m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048"
)
fields = "f2,f3,f4,f5,f6,f7,f12,f14,f15,f16,f17,f18"
custom_settings = {
"DEFAULT_REQUEST_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"
),
"Referer": "https://quote.eastmoney.com/",
}
}
def __init__(self, max_pages=None, *args, **kwargs):
"""max_pages 可通过 `scrapy crawl stock -a max_pages=5` 临时演示小批量抓取。"""
super().__init__(*args, **kwargs)
self.max_pages = int(max_pages) if max_pages else None
def build_url(self, page_no: int) -> str:
"""拼装第 page_no 页的接口地址。"""
params = {
"pn": page_no, # 页码,从 1 开始
"pz": self.page_size, # 每页条数
"po": 1, # 排序方式:1 为降序
"np": 1,
"fltt": 2, # 价格保留两位小数
"invt": 2,
"fid": "f3", # 按涨跌幅排序
"fs": self.market_filter,
"fields": self.fields,
}
return "%s?%s" % (self.api_url, urlencode(params, safe="+,:"))
def start_requests(self):
"""从第 1 页开始请求(旧版 Scrapy 的入口)。"""
return [scrapy.Request(self.build_url(1), callback=self.parse, meta={"page_no": 1})]
async def start(self):
"""Scrapy 2.13+ 的入口:异步生成器,逐个产出起始请求。"""
for request in self.start_requests():
yield request
def parse(self, response):
"""解析 JSON:逐条产出 Item,并按 total 计算出下一页。"""
payload = response.json()
data = payload.get("data") or {}
rows = data.get("diff") or []
total = data.get("total") or 0
page_no = response.meta["page_no"]
self.logger.info("第 %d 页:返回 %d 条,接口 total=%d", page_no, len(rows), total)
quote_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for row in rows:
yield StockItem(
bStockNo=row.get("f12"),
bStockName=row.get("f14"),
bLatestPrice=self.to_number(row.get("f2")),
bChangePercent=self.to_number(row.get("f3")),
bChangeAmount=self.to_number(row.get("f4")),
bVolume=self.to_number(row.get("f5")),
bTurnover=self.to_number(row.get("f6")),
bAmplitude=self.to_number(row.get("f7")),
bHigh=self.to_number(row.get("f15")),
bLow=self.to_number(row.get("f16")),
bOpen=self.to_number(row.get("f17")),
bPrevClose=self.to_number(row.get("f18")),
bQuoteTime=quote_time,
)
# 翻页:总页数 = ceil(total / 每页条数)
total_pages = math.ceil(total / self.page_size)
if self.max_pages:
total_pages = min(total_pages, self.max_pages)
if page_no < total_pages:
next_page = page_no + 1
yield scrapy.Request(
self.build_url(next_page),
callback=self.parse,
meta={"page_no": next_page},
dont_filter=True,
)
@staticmethod
def to_number(value):
"""把接口里的 "-"、"" 等占位值转成 None,其余转成 float。"""
if value in (None, "", "-"):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
完整代码 作业2/stock_scrapy/settings.py:
# -*- coding: utf-8 -*-
"""作业②的 Scrapy 配置。
关键配置有三块:
1. 爬虫身份与礼貌抓取:UA、关闭 robots(本次为教学采集,接口数据量小)、下载延时;
2. 数据管道:先序列化 JSON,再写 SQLite,顺序由权重数字决定(小的先执行);
3. SQLite 库文件路径:Pipeline 通过 settings.getdict("SQLITE_CONFIG") 读取。
"""
BOT_NAME = "stock_scrapy"
SPIDER_MODULES = ["stock_scrapy.spiders"]
NEWSPIDER_MODULE = "stock_scrapy.spiders"
# ---------------------------------------------------------------------------
# 抓取行为
# ---------------------------------------------------------------------------
ROBOTSTXT_OBEY = False # 抓的是公开行情接口,教学用途
CONCURRENT_REQUESTS = 4 # 同时最多 4 个请求
DOWNLOAD_DELAY = 0.2 # 每个请求间隔 0.2 秒,避免给接口造成压力
COOKIES_ENABLED = False
RETRY_ENABLED = True
RETRY_TIMES = 2
DOWNLOAD_TIMEOUT = 20
AJAXCRAWL_ENABLED = False
DEFAULT_REQUEST_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": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9",
}
# 注册下载中间件:随机 User-Agent(数字是执行顺序,值小的先执行)
DOWNLOADER_MIDDLEWARES = {
"stock_scrapy.middlewares.RandomUserAgentMiddleware": 400,
}
# ---------------------------------------------------------------------------
# Item Pipeline:300 先做 JSON 序列化,400 再写数据库
# ---------------------------------------------------------------------------
ITEM_PIPELINES = {
"stock_scrapy.pipelines.StockJsonPipeline": 300,
"stock_scrapy.pipelines.StockSqlitePipeline": 400,
}
# ---------------------------------------------------------------------------
# SQLite 数据库配置
# 路径相对「作业2 目录」,文件不存在时 sqlite3.connect 会自动创建。
# 想换位置只改这一行即可,不需要任何账号密码或服务端进程。
# ---------------------------------------------------------------------------
SQLITE_CONFIG = {
"database": "db/spider_homework.db",
}
# ---------------------------------------------------------------------------
# 日志与输出
# ---------------------------------------------------------------------------
LOG_LEVEL = "INFO"
FEED_EXPORT_ENCODING = "utf-8" # 导出文件用 UTF-8,避免中文乱码
DOWNLOADER_MIDDLEWARE_RESPONSE_EXCEPTIONS = True
完整代码 作业2/2.py(运行入口):
除了 scrapy crawl stock,我另外写了个 2.py 当入口,把「切到工程目录、读 settings、启动爬虫、跑完查一次库」串成一条命令,直接 python 2.py 就行。这里面有个坑:get_project_settings() 是靠当前目录下的 scrapy.cfg 找 settings 的,所以脚本一开头必须先 os.chdir() 到自己所在目录,否则换个目录运行就读不到配置。
# -*- coding: utf-8 -*-
"""作业②运行入口:在 Scrapy 工程里启动 stock 爬虫,跑完顺便核对一下入库结果。
用法:
python 2.py # 抓全部 60 页(约 5900 条)
python 2.py --pages 3 # 只抓前 3 页,用来快速试跑
python 2.py --quiet # 只看结果,不打印逐条日志
"""
import argparse
import os
import sqlite3
import sys
# 必须在导入爬虫之前把工作目录切到工程根目录(scrapy.cfg 所在处),
# 否则 get_project_settings() 找不到 settings 模块,SQLite 的相对路径也会算错
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(BASE_DIR)
sys.path.insert(0, BASE_DIR)
from scrapy.crawler import CrawlerProcess # noqa: E402
from scrapy.utils.project import get_project_settings # noqa: E402
from stock_scrapy.spiders.stock import StockSpider # noqa: E402
def parse_args():
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description="作业② 东方财富行情爬虫")
parser.add_argument("--pages", type=int, default=None,
help="只抓前 N 页(每页 100 条),默认抓全部")
parser.add_argument("--quiet", action="store_true",
help="降低日志级别,只保留警告和结果")
return parser.parse_args()
def banner(args):
"""打印本次运行的基本信息。"""
print("=" * 68)
print("作业②|东方财富 沪深京 A 股实时行情(Scrapy + XPath + SQLite)")
print("爬虫名称:stock 目标表:eastmoney_stock(库文件 db/spider_homework.db)")
print("抓取范围:%s" % ("前 %d 页" % args.pages if args.pages else "全部页面"))
print("=" * 68)
def check_db(settings):
"""跑完之后查一次数据库,确认数据真的进去了(失败不影响本次抓取)。"""
cfg = settings.getdict("SQLITE_CONFIG")
table = "eastmoney_stock"
try:
conn = sqlite3.connect(cfg["database"])
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM %s" % table)
total = cur.fetchone()[0]
print("-" * 68)
print("入库自检:%s 共 %d 条" % (cfg["database"], total))
cur.execute(
"SELECT bStockNo, bStockName, bLatestPrice, bChangePercent, bVolume "
"FROM %s ORDER BY bChangePercent DESC LIMIT 5" % table
)
rows = cur.fetchall()
if rows:
print("%-10s %-12s %10s %10s %14s"
% ("股票代码", "股票名称", "最新价", "涨跌幅%", "成交量"))
for row in rows:
print("%-10s %-12s %10s %10s %14s"
% tuple("-" if v is None else v for v in row))
print("-" * 68)
conn.close()
except Exception as exc: # 库文件被占用也不该让脚本报错退出
print("(跳过入库自检:%s: %s)" % (type(exc).__name__, exc))
def main():
args = parse_args()
banner(args)
settings = get_project_settings()
if args.quiet:
settings.set("LOG_LEVEL", "WARNING")
process = CrawlerProcess(settings)
process.crawl(StockSpider, max_pages=args.pages)
process.start() # 阻塞,直到爬虫结束
check_db(settings)
print("JSON 序列化结果:%s" % os.path.join(BASE_DIR, "output", "stock.json"))
if __name__ == "__main__":
main()
**运行结果:


2.2 实验心得
一是页面渲染方式决定了用 XPath 还是解析 JSON。 我一开始老老实实按题目写 XPath,结果 //table//tr 一条都取不到,F12 看 Network 才明白数据是 push2.eastmoney.com 异步拉回来的。这说明 XPath 是解析静态文档结构的工具,碰上 JS 渲染的页面就得换路子,硬套 XPath 只是在自欺欺人。所以 XPath 我只用在了作业③——中行牌价是服务端直出的表格,//table[@id="priceTable"]//tr[td] 一行就定位到位。两个题正好形成对比。
二是字段编号要对着网页一条条核。 接口返回的是 f2、f3、f15 这种编号,我在页面上把鼠标悬停在表头上、对照 Network 里的返回值逐个确认。f3 是涨跌幅、f4 是涨跌额,两者容易混;f5 是成交量(手)、f6 是成交额(元),单位差三个数量级。映射错一个,入库的数据看着正常、其实全错,这种事后极难发现。
三是停牌和数据缺失要单独处理。 停牌股票的接口字段直接给字符串 "-"。第一版我写了 float(item["f2"]),跑到停牌股就抛 ValueError,整个爬虫直接中断。后来统一走一个 to_number(),非数值一律返回 None,入库时存 NULL,既不报错也不伪造数据。
四是翻页的终止条件不能只看「有没有数据」。 一开始写的是 if not diff: break,但最后一页往往还会返回几条凑不满的数据,很容易多请求一次。改成用 data.total 算出总页数 ceil(total / pz),再和当前页比对,翻到就停——既不会漏页,也不会白跑。
作业③:中国银行外汇牌价
3.1 XPath 分析与完整代码
页面定位。 中国银行外汇牌价页 https://www.boc.cn/sourcedb/whpj/ 是服务端直出的静态表格,一次返回全部 45 种外币,不需要翻页。
结构分析。 F12 看 Elements,牌价全部放在 id="priceTable" 的 <table> 里,每个数据行 8 个 <td>:
<tr>
<td>阿联酋迪拉姆</td>
<td>198.58</td> <!-- 现汇买入价 -->
<td>192.31</td> <!-- 现钞买入价 -->
<td>199.98</td> <!-- 现汇卖出价 -->
<td>206.59</td> <!-- 现钞卖出价 -->
<td>202.29</td> <!-- 中行折算价 -->
<td>2026/11/08</td>
<td>11:27:14</td>
</tr>
XPath 三层就够:
rows = response.xpath('//table[@id="priceTable"]//tr[td]') # 含 td 的行,自动跳过表头
cells = row.xpath("./td") # 该行单元格
text = cell.xpath("string(.)").get(default="").strip() # 单元格纯文本
//tr[td] 比 //tr[position()>1] 稳:表头行用的是 th 不是 td,用「有 td 才算数据行」过滤,页面结构小改也不会错行。取文本用 string(.) 而不是 text(),是因为有的单元格内部还嵌了 span、a,text() 只拿得到第一个文本节点。
字段缩写按题目的表头来定:
| Item 字段 | 含义 | 对应表格列 |
|---|---|---|
bCurrency |
Currency 货币名称 | 货币名称 |
bTBP |
TBP 现汇买入价 | 现汇买入价 |
bCBP |
CBP 现钞买入价 | 现钞买入价 |
bTSP |
TSP 现汇卖出价 | 现汇卖出价 |
bCSP |
CSP 现钞卖出价 | 现钞卖出价 |
bMiddleRate |
中行折算价(附加字段) | 中行折算价 |
bPubDate / bTime |
Time 发布时间 | 发布日期 / 发布时间 |
完整代码 作业3/forex_scrapy/items.py:
# -*- coding: utf-8 -*-
"""作业③的 Item 定义:中国银行外汇牌价。
字段缩写含义(作业要求的表头):
TBP = 现汇买入价(Telegraphic Buying Price)
CBP = 现钞买入价(Cash Buying Price)
TSP = 现汇卖出价(Telegraphic Selling Price)
CSP = 现钞卖出价(Cash Selling Price)
"""
import scrapy
class ForexItem(scrapy.Item):
"""一条外汇牌价记录 = 表格中的一行。"""
bCurrency = scrapy.Field() # 货币名称,如 阿联酋迪拉姆
bTBP = scrapy.Field() # 现汇买入价
bCBP = scrapy.Field() # 现钞买入价
bTSP = scrapy.Field() # 现汇卖出价
bCSP = scrapy.Field() # 现钞卖出价
bMiddleRate = scrapy.Field() # 中行折算价
bPubDate = scrapy.Field() # 发布日期,如 2026-11-08
bTime = scrapy.Field() # 发布时间,如 10:30:00
完整代码 作业3/forex_scrapy/spiders/forex.py:
# -*- coding: utf-8 -*-
"""作业③的爬虫:中国银行外汇牌价( https://www.boc.cn/sourcedb/whpj/ )。
页面结构
--------
牌价数据全部放在 id="priceTable" 的表格里,每行 8 个单元格:
货币名称 | 现汇买入价 | 现钞买入价 | 现汇卖出价 | 现钞卖出价 | 中行折算价 | 发布日期 | 发布时间
用 XPath 定位:
//table[@id="priceTable"]//tr[td] —— 取所有「含 td 的行」,自动跳过表头行
./td —— 该行的单元格
string(.) —— 取单元格的全部文本(去掉内部标签干扰)
说明:该页面一次返回全部 45 种外币,翻页只在按日期查询时才有意义,
所以这里只抓当前牌价页;重复运行按「币种 + 日期 + 时间」去重,不会产生重复数据。
"""
from datetime import datetime
import scrapy
from forex_scrapy.items import ForexItem
class ForexSpider(scrapy.Spider):
"""抓取中国银行外汇牌价表,逐行产出 ForexItem。"""
name = "forex"
allowed_domains = ["boc.cn"]
start_url = "https://www.boc.cn/sourcedb/whpj/"
custom_settings = {
"DEFAULT_REQUEST_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"
),
"Referer": "https://www.boc.cn/",
}
}
def start_requests(self):
"""起始请求(旧版 Scrapy 的入口)。"""
return [scrapy.Request(self.start_url, callback=self.parse)]
async def start(self):
"""Scrapy 2.13+ 的入口:异步生成器。"""
for request in self.start_requests():
yield request
def parse(self, response):
"""用 XPath 逐行解析牌价表。"""
rows = response.xpath('//table[@id="priceTable"]//tr[td]')
self.logger.info("共定位到 %d 行牌价数据", len(rows))
for row in rows:
cells = row.xpath("./td")
if len(cells) < 8: # 结构异常的行直接跳过
continue
# 每个单元格用 string(.) 取文本并去掉首尾空白
values = [cell.xpath("string(.)").get(default="").strip() for cell in cells[:8]]
currency, tbp, cbp, tsp, csp, middle, pub_datetime, pub_time = values
if not currency or currency == "货币名称": # 双保险:跳过表头/空行
continue
# 第 7 列是「2026/11/08 10:30:00」,第 8 列是单独的时分秒,两个都要兼容
pub_date, time_from_datetime = self.split_datetime(pub_datetime)
pub_time = pub_time or time_from_datetime
yield ForexItem(
bCurrency=currency,
bTBP=self.to_number(tbp),
bCBP=self.to_number(cbp),
bTSP=self.to_number(tsp),
bCSP=self.to_number(csp),
bMiddleRate=self.to_number(middle),
bPubDate=pub_date,
bTime=pub_time,
)
@staticmethod
def to_number(text):
"""空字符串(部分币种没有现汇价)转 None,其余转 float。"""
if not text or text in ("-", "—"):
return None
try:
return float(text.replace(",", ""))
except ValueError:
return None
@staticmethod
def split_datetime(text):
"""把 "2026/11/08 10:30:00" 拆成 ("2026-11-08", "10:30:00");只给日期时时间为空。"""
parts = (text or "").split()
date_text = parts[0] if parts else ""
time_text = parts[1] if len(parts) > 1 else ""
try:
date_text = datetime.strptime(date_text, "%Y/%m/%d").strftime("%Y-%m-%d")
except ValueError:
date_text = None
return date_text, time_text
完整代码 作业3/forex_scrapy/pipelines.py:
和作业②是同一套骨架:一样用 SQLite(库文件 作业3/db/spider_homework.db),只改了表名、字段和唯一键。这里有个我一开始想错的地方:最早想着「牌价是带时间戳的,留着当历史数据」,于是只写 ON CONFLICT ... DO UPDATE 不清表。结果牌价一小时更新一次,重跑一次表里就变成 90 条,每种货币两条,COUNT(*) 和「45 种外币」直接对不上。最后还是改成开放时 DELETE FROM boc_forex,只保留本次抓到的最新一版——作业要的是「当前牌价快照」,不是历史序列,这两件事得先想清楚再建表。
# -*- coding: utf-8 -*-
"""作业③的 Pipeline:Item 序列化为 JSON + 写入 SQLite 表 boc_forex。
数据库用 SQLite(Python 标准库自带 sqlite3),整库就是 作业3/db/spider_homework.db
这一个文件,不用装服务端、不用配账号密码,交作业时连库带代码一起拷走就行。
"""
import json
import logging
import os
import sqlite3
from itemadapter import ItemAdapter
from scrapy.exceptions import DropItem
logger = logging.getLogger(__name__)
# 建表语句:币种 + 日期 + 时间 作为唯一键,配合开放时清表,重跑不会留下重复数据。
# SQLite 没有 DECIMAL / DATE / TIME 类型,价格用 REAL,日期时间用 TEXT 存字符串。
CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS boc_forex (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 序号
bCurrency TEXT NOT NULL, -- 货币名称
bTBP REAL, -- 现汇买入价 TBP
bCBP REAL, -- 现钞买入价 CBP
bTSP REAL, -- 现汇卖出价 TSP
bCSP REAL, -- 现钞卖出价 CSP
bMiddleRate REAL, -- 中行折算价
bPubDate TEXT, -- 发布日期 YYYY-MM-DD
bTime TEXT, -- 发布时间 HH:MM:SS
UNIQUE (bCurrency, bPubDate, bTime)
);
"""
COLUMNS = (
"bCurrency", "bTBP", "bCBP", "bTSP", "bCSP", "bMiddleRate", "bPubDate", "bTime",
)
# SQLite 用 ? 作占位符;同币种同时刻重复抓到时覆盖四个价格与折算价
INSERT_SQL = (
"INSERT INTO boc_forex (%s) VALUES (%s) "
"ON CONFLICT(bCurrency, bPubDate, bTime) DO UPDATE SET "
"bTBP=excluded.bTBP, bCBP=excluded.bCBP, bTSP=excluded.bTSP, "
"bCSP=excluded.bCSP, bMiddleRate=excluded.bMiddleRate"
% (", ".join(COLUMNS), ", ".join(["?"] * len(COLUMNS)))
)
class ForexJsonPipeline:
"""把 Item 序列化成 JSON Lines 保存到 output/forex.json。"""
def open_spider(self, spider=None):
os.makedirs("output", exist_ok=True)
self.file = open(os.path.join("output", "forex.json"), "w", encoding="utf-8")
self.count = 0
def process_item(self, item, spider=None):
record = dict(ItemAdapter(item).asdict())
self.file.write(json.dumps(record, ensure_ascii=False) + "\n")
self.count += 1
return item
def close_spider(self, spider=None):
self.file.close()
logger.info("JSON 序列化完成:output/forex.json,共 %d 条", self.count)
class ForexSqlitePipeline:
"""把 Item 批量写入 SQLite 的 boc_forex 表。"""
batch_size = 50
def __init__(self, settings):
self.settings = settings
self.buffer = []
self.conn = None
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings)
def open_spider(self, spider=None):
cfg = self.settings.getdict("SQLITE_CONFIG")
db_path = cfg["database"]
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
# 文件不存在时 connect 会自动创建,相当于「建库」
self.conn = sqlite3.connect(db_path)
self.conn.execute(CREATE_TABLE_SQL)
# 牌价是整点快照,重跑一次就会多出一整批同一时刻的数据,
# 所以先清表,只保留本次抓到的最新一版
self.conn.execute("DELETE FROM boc_forex")
self.conn.commit()
logger.info("数据库 %s 的 boc_forex 表准备就绪", db_path)
def process_item(self, item, spider=None):
adapter = ItemAdapter(item)
if not adapter.get("bCurrency"):
raise DropItem("缺少货币名称:%s" % dict(adapter))
self.buffer.append(tuple(adapter.get(col) for col in COLUMNS))
if len(self.buffer) >= self.batch_size:
self.flush_buffer()
return item
def flush_buffer(self):
if not self.buffer:
return
self.conn.executemany(INSERT_SQL, self.buffer)
self.conn.commit()
self.buffer.clear()
def close_spider(self, spider=None):
self.flush_buffer()
if self.conn:
self.conn.close()
完整代码 作业3/3.py(运行入口):
和作业②的入口一个写法,只是换成 forex 爬虫、少一个翻页参数。
# -*- coding: utf-8 -*-
"""作业③运行入口:在 Scrapy 工程里启动 forex 爬虫,跑完顺便核对一下入库结果。
等价于在 作业3 目录下执行:
scrapy crawl forex
用法:
python 3.py # 抓中国银行外汇牌价全部 45 种外币
python 3.py --quiet # 只看结果,不打印逐条日志
"""
import argparse
import os
import sqlite3
import sys
# 先切到工程根目录(scrapy.cfg 所在处)再导入爬虫,否则读不到 settings,
# SQLite 的相对路径也会算错
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(BASE_DIR)
sys.path.insert(0, BASE_DIR)
from scrapy.crawler import CrawlerProcess # noqa: E402
from scrapy.utils.project import get_project_settings # noqa: E402
from forex_scrapy.spiders.forex import ForexSpider # noqa: E402
def parse_args():
"""解析命令行参数。"""
parser = argparse.ArgumentParser(description="作业③ 中国银行外汇牌价爬虫")
parser.add_argument("--quiet", action="store_true",
help="降低日志级别,只保留警告和结果")
return parser.parse_args()
def banner():
"""打印本次运行的基本信息。"""
print("=" * 68)
print("作业③|中国银行外汇牌价(Scrapy + XPath + SQLite)")
print("爬虫名称:forex 目标表:boc_forex(库文件 db/spider_homework.db)")
print("数据页面:https://www.boc.cn/sourcedb/whpj/")
print("=" * 68)
def check_db(settings):
"""跑完之后查一次数据库,确认牌价真的进去了(失败不影响本次抓取)。"""
cfg = settings.getdict("SQLITE_CONFIG")
table = "boc_forex"
try:
conn = sqlite3.connect(cfg["database"])
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM %s" % table)
total = cur.fetchone()[0]
print("-" * 68)
print("入库自检:%s 共 %d 条" % (cfg["database"], total))
cur.execute(
"SELECT bCurrency, bTBP, bCBP, bTSP, bCSP, bTime "
"FROM %s ORDER BY id LIMIT 5" % table
)
rows = cur.fetchall()
if rows:
print("%-14s %10s %10s %10s %10s %10s"
% ("货币名称", "现汇买入", "现钞买入", "现汇卖出", "现钞卖出", "时间"))
for row in rows:
print("%-14s %10s %10s %10s %10s %10s"
% tuple("-" if v is None else v for v in row))
print("-" * 68)
conn.close()
except Exception as exc: # 库文件被占用也不该让脚本报错退出
print("(跳过入库自检:%s: %s)" % (type(exc).__name__, exc))
def main():
args = parse_args()
banner()
settings = get_project_settings()
if args.quiet:
settings.set("LOG_LEVEL", "WARNING")
process = CrawlerProcess(settings)
process.crawl(ForexSpider)
process.start() # 阻塞,直到爬虫结束
check_db(settings)
print("JSON 序列化结果:%s" % os.path.join(BASE_DIR, "output", "forex.json"))
if __name__ == "__main__":
main()
**运行结果:


3.2 实验心得
一是表格里藏着一列「假日期」。 我原以为第 7 列是发布日期、第 8 列是时间,结果打印出来第 7 列是 2026/11/08,第 8 列是 11:27:14,有的币种第 7 列还会带上时间。处理方式是按空格拆开,日期交给 strptime 校验后统一成 YYYY-MM-DD、时间统一成 HH:MM:SS,都以文本存——SQLite 本来就没有日期类型,规整成定长文本反而更好比较和排序;解析失败就存空。宁可空着,也不要存一个看起来正常的错值。
二是表头行的过滤方式会影响稳定性。 最早写 rows[1:] 切片跳过表头,后来发现页面上方还有一个隐藏的空行,切完还是错位。换成 //tr[td](有单元格才算数据行)就彻底与行数无关了——判断依据尽量落在结构特征上,而不是位置序号上。
三是题目只要 6 项,表格却给了 8 列。 题目要求的表头是 Currency、TBP、CBP、TSP、CSP、Time 六项,对应现汇买入、现钞买入、现汇卖出、现钞卖出、发布时间。中行折算价 题目没要求,但它对算套算汇率有用,我就一起存了,库里多一列不影响评分,少一列以后要重跑。

浙公网安备 33010602011771号