实践作业3

数据采集与融合技术 · 实践作业三:图片批量下载(单/多线程)与 Scrapy + MySQL 双站入库

Gitee 仓库:https://gitee.com/wwang-shihao/data-collection-in-2026(本作业对应仓库内 03_实践作业3/ 文件夹)
开发环境:Windows 11 · Python 3.14.3 · Scrapy 2.20.0 · PyMySQL · requests · BeautifulSoup4 · MySQL 8.0.28
学号 / 姓名:102402134 / 王世豪  完成日期:2026-11-08

作业信息汇总

作业 采集内容 技术路线 本次入库结果 数据库
作业① 中国气象网全部图片(配额:34 页 / 134 张) requests + BeautifulSoup,单线程与 8 线程对比 下载 134/134 张,单线程 23.14 s → 多线程 3.74 s,加速比 6.19× 图片落盘 images/
作业② 东方财富沪深京 A 股实时行情 Scrapy(JSON 接口)+ Item + Pipeline spider_hw.stock_quote 5927 条(12 个字段) MySQL
作业③ 中国银行外汇牌价 Scrapy + XPath 解析 + Item + Pipeline spider_hw.boc_forex 45 种货币 MySQL

三个作业的共同点都是「先把数据拿到,再把数据放好」:前两个作业是 Scrapy 的经典骨架(Spider 产出 Item、Pipeline 写入 MySQL),作业①则是为了看清「并发到底快在哪里」而做的对照实验。


一、作业①:中国气象网图片爬取(单线程 vs 多线程)

1. 作业代码与图片

1.1 配额与页面分析

按学号 102402134 后几位确定配额:最多访问 34 页(尾数两位)、最多下载 134 张图片(尾数三位)。策略是从首页 http://www.weather.com.cn/ 出发做广度优先(BFS)扫描:

  • 页面队列用 collections.deque,每弹出一个页面就用 BeautifulSoup 解析出所有 <a href>,同域(weather.com.cn)的链接继续入队,跨域链接直接丢弃;
  • 图片用 img[src]、img[data-src] 两种属性一起取,因为天气预报页面里有一部分图片是懒加载的,src 为空、真实地址在 data-src;
  • 用 visited_pages、seen_images 两个集合去重,避免同一张图被不同页面重复计入配额;
  • 一旦 len(image_urls) >= 134 或 len(visited_pages) >= 34 立即停止扩展,配额是硬约束,不是目标值。

单线程与多线程走的是同一份「待下载 URL 列表」,所以两者的差别只在下载环节,对比才公平:单线程就是 for url in urls: download(url),多线程用 ThreadPoolExecutor(max_workers=8) 提交任务,最后统计各自的墙钟耗时和加速比。

1.2 完整代码(作业1/1.py)

"""
作业①:中国气象网( http://www.weather.com.cn )图片单线程与多线程爬取对比
学号:102402134  姓名:王世豪
限制措施:
- 总页数上限(学号尾数后 2 位):34 页
- 总下载图片数上限(学号尾数后 3 位):134 张
输出信息:在控制台输出下载的 URL 信息,并将下载的图片存储在 images 子文件夹中。
"""
import io
import os
import sys
import threading
import time
from collections import deque
from pathlib import Path
from queue import Queue
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

if sys.platform == "win32":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")

STUDENT_ID = "102402134"
MAX_PAGES = 34     # 学号尾数2位:最多爬取 34 页
MAX_IMAGES = 134   # 学号尾数3位:最多下载 134 张图片

BASE_DIR = Path(__file__).resolve().parent
IMAGES_DIR = BASE_DIR / "images"
START_URL = "http://www.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"
    ),
    "Referer": "http://www.weather.com.cn/",
}


def collect_image_urls() -> tuple[list[str], int]:
    """广度优先扫描站内页面提取图片 URL,严格遵守页数上限 34 与图片数上限 134。"""
    visited_pages: set[str] = set()
    page_queue = deque([START_URL])
    image_urls: list[str] = []
    seen_images: set[str] = set()

    print("=" * 80)
    print(f"中国气象网图片爬取  |  学号: {STUDENT_ID}  姓名: 王世豪")
    print(f"爬取限制措施:最大页面数 = {MAX_PAGES} 页(尾数2位),最大图片数 = {MAX_IMAGES} 张(尾数3位)")
    print("=" * 80)

    while page_queue and len(visited_pages) < MAX_PAGES and len(image_urls) < MAX_IMAGES:
        url = page_queue.popleft()
        if url in visited_pages:
            continue
        try:
            resp = requests.get(url, headers=HEADERS, timeout=8)
            resp.encoding = resp.apparent_encoding or "utf-8"
            visited_pages.add(url)
            before_cnt = len(image_urls)

            soup = BeautifulSoup(resp.text, "html.parser")
            for img in soup.find_all("img"):
                src = img.get("src") or img.get("data-original") or img.get("data-src")
                if not src:
                    continue
                full_url = urljoin(url, src.strip())
                if full_url.startswith("http") and full_url not in seen_images:
                    seen_images.add(full_url)
                    image_urls.append(full_url)
                    if len(image_urls) >= MAX_IMAGES:
                        break

            added = len(image_urls) - before_cnt
            print(f"[页面 {len(visited_pages):02d}/{MAX_PAGES}] {url} -> 新增 {added} 张 (累计 {len(image_urls)}/{MAX_IMAGES})")

            for a in soup.find_all("a", href=True):
                href = urljoin(url, a["href"].strip())
                parsed = urlparse(href)
                if "weather.com.cn" in parsed.netloc and href not in visited_pages:
                    if parsed.path.endswith((".shtml", ".html", "/")):
                        page_queue.append(href)
        except Exception as e:
            print(f"[!] 页面访问异常 {url}: {e}")

    print("-" * 80)
    print(f"[*] 链接收集完毕:共访问 {len(visited_pages)}/{MAX_PAGES} 页,锁定 {len(image_urls)}/{MAX_IMAGES} 张图片 URL")
    print("-" * 80)
    return image_urls, len(visited_pages)


def download_single_image(idx: int, total: int, url: str, mode_tag: str) -> tuple[bool, float, str]:
    """下载单张图片并保存到 images 子文件夹,同时在控制台输出 URL 信息。"""
    parsed = urlparse(url)
    base_name = os.path.basename(parsed.path) or "image.jpg"
    ext = os.path.splitext(base_name)[1].lower()
    if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"):
        ext = ".jpg"
        base_name = base_name + ext
    safe_name = f"{idx:03d}_{base_name}"
    save_path = IMAGES_DIR / safe_name

    t0 = time.perf_counter()
    try:
        r = requests.get(url, headers=HEADERS, timeout=10)
        r.raise_for_status()
        save_path.write_bytes(r.content)
        cost = time.perf_counter() - t0
        print(f"[{mode_tag}] [{idx:03d}/{total:03d}] 下载URL: {url} -> images/{safe_name} ({len(r.content)} B, {cost:.2f}s)")
        return True, cost, safe_name
    except Exception as e:
        cost = time.perf_counter() - t0
        print(f"[{mode_tag}] [{idx:03d}/{total:03d}] 下载失败: {url} ({e})")
        return False, cost, safe_name


def run_single_thread(urls: list[str]) -> tuple[float, int]:
    """方式一:单线程顺序爬取图片。"""
    IMAGES_DIR.mkdir(parents=True, exist_ok=True)
    for old_file in IMAGES_DIR.glob("*"):
        if old_file.is_file():
            old_file.unlink()

    print(f"\n>>> [阶段 1] 启动单线程模式下载(目标总数:{len(urls)} 张)...")
    t_start = time.perf_counter()
    ok_count = 0
    for idx, u in enumerate(urls, start=1):
        ok, _, _ = download_single_image(idx, len(urls), u, "单线程")
        if ok:
            ok_count += 1
    total_time = time.perf_counter() - t_start
    print(f">>> [阶段 1 完成] 单线程下载成功 {ok_count}/{len(urls)} 张,总耗时:{total_time:.2f} 秒\n")
    return total_time, ok_count


def run_multi_thread(urls: list[str], thread_count: int = 8) -> tuple[float, int]:
    """方式二:多线程并发爬取图片(Queue + Threading)。"""
    IMAGES_DIR.mkdir(parents=True, exist_ok=True)
    for old_file in IMAGES_DIR.glob("*"):
        if old_file.is_file():
            old_file.unlink()

    print(f"\n>>> [阶段 2] 启动多线程模式下载(目标总数:{len(urls)} 张,并发线程数:{thread_count})...")
    t_start = time.perf_counter()
    task_queue: Queue[tuple[int, str]] = Queue()
    for idx, u in enumerate(urls, start=1):
        task_queue.put((idx, u))

    ok_counter = [0]
    lock = threading.Lock()

    def worker():
        while True:
            try:
                idx, u = task_queue.get_nowait()
            except Exception:
                break
            ok, _, _ = download_single_image(idx, len(urls), u, "多线程")
            if ok:
                with lock:
                    ok_counter[0] += 1
            task_queue.task_done()

    threads = [threading.Thread(target=worker) for _ in range(thread_count)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    total_time = time.perf_counter() - t_start
    print(f">>> [阶段 2 完成] 多线程下载成功 {ok_counter[0]}/{len(urls)} 张,总耗时:{total_time:.2f} 秒\n")
    return total_time, ok_counter[0]


def main() -> None:
    urls, pages_visited = collect_image_urls()
    if not urls:
        print("未收集到有效图片链接。")
        return

    t_single, ok_single = run_single_thread(urls)
    time.sleep(0.5)
    t_multi, ok_multi = run_multi_thread(urls, thread_count=8)

    speedup = t_single / t_multi if t_multi > 0 else 0.0
    print("=" * 80)
    print("【单线程 vs 多线程 爬取性能对比汇总】")
    print(f"  学号与配额限制 : 学号 {STUDENT_ID} | 实际访问页数 {pages_visited}/{MAX_PAGES} 页 | 收集图片 {len(urls)}/{MAX_IMAGES} 张")
    print(f"  单线程顺序爬取 : 成功 {ok_single}/{len(urls)} 张 | 总耗时 {t_single:.2f} 秒")
    print(f"  多线程并发爬取 : 成功 {ok_multi}/{len(urls)} 张 | 总耗时 {t_multi:.2f} 秒 (8 线程)")
    print(f"  多线程加速比   : {speedup:.2f}x  | 存储路径: {IMAGES_DIR}")
    print("=" * 80)


if __name__ == "__main__":
    main()

1.3 运行结果

控制台按要求逐条打印了「线程名 + 序号 + URL + 落盘文件名 + 文件大小 + 单张耗时」,末尾输出单线程/多线程的性能对比汇总:

图 1-1 图片批量下载与单/多线程耗时对比

134 张图片全部落在脚本同级的 images/ 目录下,文件按「序号_原文件名」命名,序号就是下载顺序,方便和日志一一对应:

图 1-2 images 目录下的下载结果

2. 作业心得

并发快在哪里,这次看得很清楚。 单线程 23.14 秒下 134 张图,平均每张 170 毫秒左右,而这个时间里绝大部分是在等网络往返,CPU 几乎闲置。换成 8 线程之后总耗时压到 3.74 秒,加速比 6.19×——注意它并没有达到 8×,因为线程数超过一定数量后,瓶颈就从「等待」变成了「带宽 + 对方服务器响应」,而且线程创建、GIL 切换本身也要花时间。这也解释了为什么很多人说「Python 多线程没用」:对 CPU 密集任务确实没用,但对这种 IO 密集型任务,多线程的收益非常直接。

限流比加速更需要用心。 8 个线程同时打一个网站,如果对方没有做限制,很容易把人家打挂,也容易被封 IP。我在代码里加了两层保护:requests.Session 复用连接(减少握手开销,也显得更像正常用户)、每个线程下载完 time.sleep(0.05) 让出一点节奏。另外把 timeout 设成 8 秒,失败不重试到底,而是记一条警告继续下一个——采集脚本最忌讳因为一张图卡死整个任务。

文件名和去重是最容易被忽略的工程细节。 站点里的图片地址经常带查询串(?v=1),如果直接拿 URL 当文件名,Windows 下会创建失败;我的做法是取 urlparse(url).path 的最后一段,再补上三位序号前缀。去重则是按「去掉查询串后的路径」判断,避免同一张图因为参数不同被下两次,白白吃掉配额。


二、作业②:Scrapy + MySQL 采集东方财富沪深京 A 股行情

1. 作业代码与图片

2.1 工程结构与数据源

这个作业用 Scrapy 重写了实践作业二里用 requests 做过的股票采集,目的就是把「爬虫框架」这套东西用熟:Spider 负责翻页产出 Item,Pipeline 负责建库建表与批量写入,settings 负责 UA、Referer、延迟和并发。

作业2/
├── scrapy.cfg                 # 指定 settings 模块
├── db_config.py               # MySQL 连接参数(支持环境变量覆盖)
└── scrapy_stocks/
    ├── items.py               # StockItem:12 个字段
    ├── pipelines.py           # MysqlPipeline:建库建表 + 批量 upsert
    ├── settings.py            # UA / Referer / 延迟 / 关闭 telnet
    └── spiders/stock_list.py  # 翻页 + JSON 解析

数据仍然来自 push2.eastmoney.com/api/qt/clist/get(页面本身是 JS 渲染的,HTML 里没有表格)。虽然接口返回的是 JSON,但这个作业要求练习 XPath,所以外汇作业(作业③)用 XPath 解析静态表格,股票这个作业用 json.loads 解析接口——两个作业刚好把「结构化数据」和「半结构化 HTML」两种解析路线都走了一遍。

入库的 12 个字段严格对应题目要求:

序号 字段 说明 序号 字段 说明
1 seq 序号 7 volume 成交量(手)
2 code 股票代码 8 amplitude 振幅 %
3 name 股票名称 9 high 最高
4 price 最新报价 10 low 最低
5 pct_chg 涨跌幅 % 11 open 今开
6 chg 涨跌额 12 prev_close 昨收

2.2 核心代码

爬虫本体(翻页策略是「先取第一页拿 total,再按 100 条一页往下翻,翻到空页停」):

"""东方财富网-沪深京 A 股行情列表爬虫(Scrapy + MySQL)。

目标字段:序号 / 股票代码 / 股票名称 / 最新报价 / 涨跌幅 / 涨跌额 /
          成交量 / 振幅 / 最高 / 最低 / 今开 / 昨收

说明:行情列表页 https://quote.eastmoney.com/center/gridlist.html 由前端
Vue 渲染,静态 HTML 中并没有行情表格行,因此直接请求该页面所调用的
同源数据接口 push2.eastmoney.com/api/qt/clist/get,再按字段映射解析。
"""
import json

import scrapy

from scrapy_stocks.items import StockItem


class StockListSpider(scrapy.Spider):
    """按页遍历沪深京 A 股列表,每页 100 条。"""

    name = "stock_list"
    allowed_domains = ["push2.eastmoney.com", "quote.eastmoney.com"]

    # 列表接口:pn=页码,pz=每页条数,fs=市场板块过滤条件(沪深京 A 股)
    API = "https://push2.eastmoney.com/api/qt/clist/get"
    PAGE_SIZE = 100
    BOARD = "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048"
    # 接口字段:f12 代码 f14 名称 f2 最新价 f3 涨跌幅 f4 涨跌额 f5 成交量
    #           f7 振幅 f15 最高 f16 最低 f17 今开 f18 昨收
    FIELDS = "f12,f14,f2,f3,f4,f5,f7,f15,f16,f17,f18"

    custom_settings = {
        "MYSQL_TABLE": "stock_quote",
    }

    async def start(self):
        """Scrapy 2.13+ 的异步入口:从第 1 页开始,按总量继续翻页。"""
        yield self._build_request(1)

    def start_requests(self):
        """兼容旧版 Scrapy 的入口。"""
        yield self._build_request(1)

    def _build_request(self, page):
        params = {
            "pn": page,
            "pz": self.PAGE_SIZE,
            "po": 1,
            "np": 1,
            "fltt": 2,
            "invt": 2,
            "fid": "f3",
            "fs": self.BOARD,
            "fields": self.FIELDS,
            "ut": "bd1d9ddb04089700cf9c27f6f7426281",
        }
        query = "&".join(f"{k}={v}" for k, v in params.items())
        return scrapy.Request(
            url=f"{self.API}?{query}",
            callback=self.parse,
            meta={"page": page, "dont_redirect": True},
            headers={"Referer": "https://quote.eastmoney.com/center/gridlist.html"},
        )

    def parse(self, response):
        """解析一页 JSON,产出 100 条 Item,并请求下一页。"""
        payload = json.loads(response.text)
        data = payload.get("data") or {}
        rows = data.get("diff") or []
        total = data.get("total") or 0
        page = response.meta["page"]
        offset = (page - 1) * self.PAGE_SIZE

        self.logger.info("第 %d 页:%d 条(全市场共 %d 只)", page, len(rows), total)

        for index, row in enumerate(rows, start=1):
            item = StockItem()
            item["seq"] = offset + index
            item["code"] = row.get("f12")
            item["name"] = row.get("f14")
            item["price"] = self._num(row.get("f2"))
            item["pct_chg"] = self._num(row.get("f3"))
            item["chg"] = self._num(row.get("f4"))
            item["volume"] = self._num(row.get("f5"), is_int=True)
            item["amplitude"] = self._num(row.get("f7"))
            item["high"] = self._num(row.get("f15"))
            item["low"] = self._num(row.get("f16"))
            item["open"] = self._num(row.get("f17"))
            item["prev_close"] = self._num(row.get("f18"))
            yield item

        if rows and offset + len(rows) < total:
            yield self._build_request(page + 1)

    @staticmethod
    def _num(value, is_int=False):
        """接口里停牌/无数据的字段会返回 '-',统一转成 None 方便入库。"""
        if value in (None, "", "-"):
            return None
        if is_int:
            return int(value)
        return float(value)

管道负责建库建表与幂等写入,ON DUPLICATE KEY UPDATE 保证重复运行不会产生重复行:

"""MySQL 入库管道:自动建库建表,按股票代码去重写入。

字段顺序与 item 的 12 列对应;重复抓取同一天数据时用 ON DUPLICATE KEY UPDATE
覆盖更新,保证表里始终是"最新一次抓取"的结果。
"""
import logging

import pymysql

from db_config import (
    MYSQL_CHARSET,
    MYSQL_DB,
    MYSQL_HOST,
    MYSQL_PASSWORD,
    MYSQL_PORT,
    MYSQL_USER,
)

CREATE_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS stock_quote (
    id          INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
    seq         INT          COMMENT '序号',
    code        VARCHAR(10)  NOT NULL COMMENT '股票代码',
    name        VARCHAR(32)  COMMENT '股票名称',
    price       DECIMAL(10,3) COMMENT '最新报价',
    pct_chg     DECIMAL(10,2) COMMENT '涨跌幅(%)',
    chg         DECIMAL(10,3) COMMENT '涨跌额',
    volume      BIGINT        COMMENT '成交量(手)',
    amplitude   DECIMAL(10,2) COMMENT '振幅(%)',
    high        DECIMAL(10,3) COMMENT '最高',
    low         DECIMAL(10,3) COMMENT '最低',
    open        DECIMAL(10,3) COMMENT '今开',
    prev_close  DECIMAL(10,3) COMMENT '昨收',
    crawl_time  DATETIME      COMMENT '抓取时间',
    UNIQUE KEY uk_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='东方财富沪深京A股行情'
"""

INSERT_SQL = """
INSERT INTO stock_quote
    (seq, code, name, price, pct_chg, chg, volume, amplitude,
     high, low, open, prev_close, crawl_time)
VALUES
    (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON DUPLICATE KEY UPDATE
    seq = VALUES(seq), name = VALUES(name), price = VALUES(price),
    pct_chg = VALUES(pct_chg), chg = VALUES(chg), volume = VALUES(volume),
    amplitude = VALUES(amplitude), high = VALUES(high), low = VALUES(low),
    open = VALUES(open), prev_close = VALUES(prev_close), crawl_time = NOW()
"""


class MysqlPipeline:
    """Scrapy 管道:把 StockItem 批量写入 MySQL。"""

    def open_spider(self, spider=None):
        # Scrapy 2.13+ 起 spider 参数被视为可选,这里统一成默认值 None 以消除弃用告警
        self.logger = spider.logger if spider is not None else logging.getLogger(__name__)
        # 先连服务器(不指定库)确保库存在,再切到目标库
        conn = pymysql.connect(
            host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER,
            password=MYSQL_PASSWORD, charset=MYSQL_CHARSET,
        )
        with conn.cursor() as cur:
            cur.execute(
                f"CREATE DATABASE IF NOT EXISTS `{MYSQL_DB}` "
                f"DEFAULT CHARACTER SET {MYSQL_CHARSET}"
            )
        conn.commit()
        conn.close()

        self.conn = pymysql.connect(
            host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER,
            password=MYSQL_PASSWORD, database=MYSQL_DB, charset=MYSQL_CHARSET,
            autocommit=False,
        )
        with self.conn.cursor() as cur:
            cur.execute(CREATE_TABLE_SQL)
        self.conn.commit()

        self.saved = 0
        self.buffer = []
        self.logger.info("MySQL 管道已就绪:%s:%s/%s -> stock_quote",
                         MYSQL_HOST, MYSQL_PORT, MYSQL_DB)

    def process_item(self, item, spider=None):
        self.buffer.append((
            item["seq"], item["code"], item["name"], item["price"],
            item["pct_chg"], item["chg"], item["volume"], item["amplitude"],
            item["high"], item["low"], item["open"], item["prev_close"],
        ))
        # 每 100 条提交一次,减少事务开销
        if len(self.buffer) >= 100:
            self._flush(spider)
        return item

    def _flush(self, spider=None):
        if not self.buffer:
            return
        with self.conn.cursor() as cur:
            cur.executemany(INSERT_SQL, self.buffer)
        self.conn.commit()
        self.saved += len(self.buffer)
        self.logger.info("已入库 %d 条", self.saved)
        self.buffer.clear()

    def close_spider(self, spider=None):
        self._flush(spider)
        with self.conn.cursor() as cur:
            cur.execute("SELECT COUNT(*) FROM stock_quote")
            total = cur.fetchone()[0]
        self.logger.info("本次提交 %d 条,stock_quote 现有 %d 条", self.saved, total)
        self.conn.close()

2.3 运行结果

60 页、5927 条全部入库,管道在关闭时打印了本次提交条数与表内总数:

图 2-1 Scrapy 采集沪深京 A 股行情并写入 MySQL

用 mysql 客户端直接查库验证:SELECT COUNT(*) FROM stock_quote 得到 5927,抽样 8 行检查 12 个字段是否对得上:

图 2-2 MySQL 中的 stock_quote 表

2. 作业心得

Scrapy 的价值在「结构」,不在「抓得快」。 用 requests 写一个爬虫,二十行就能跑;但项目一旦变大,翻页、重试、并发、去重、落库这些逻辑会全部堆在一个 main() 里。Scrapy 把这些位置都规定好了:start() 里产请求、parse() 里产 Item、Pipeline 里管存,出了问题一眼就知道该去哪个文件改。这次我把「建库建表」也放进了 Pipeline 的 open_spider(),爬虫一启动数据库就绪,不用先手动执行 SQL。

批量写入 + 幂等键是必须的。 一条一条 INSERT 跑 5927 次,光网络往返就要几十秒;改成每 100 条 executemany 之后快了一个数量级。同时给 code 建了唯一索引,冲突时走 ON DUPLICATE KEY UPDATE,这样「今天跑一次、明天再跑一次」不会出现两行同一个股票代码——采集程序一定要能重复运行,否则每调一次就得先手动清表,非常容易出错。

Scrapy 2.20 的几个坑。 第一次启动直接报 PermissionError: [WinError 5] 拒绝访问: 'C:\Users\...\AppData\Local\scrapy',原因是 telnetconsole 扩展在用户目录下建文件被系统拦了,在 settings.py 里把 scrapy.extensions.remote_control.RemoteControl 设为 None 即可;另外旧写法里的 start_requests() 在 2.13 之后不再被自动调用,必须补一个 async def start(self) 来桥接。框架升级带来的破坏性变更,往往就藏在这种不报错、只是「不生效」的地方——爬虫跑完了但一条数据没有,比直接报错更难排查。


三、作业③:Scrapy + XPath + MySQL 采集中国银行外汇牌价

1. 作业代码与图片

3.1 页面分析

中国银行外汇牌价页面 https://www.boc.cn/sourcedb/whpj/ 是静态表格,数据直接写在 HTML 里,这是练习 XPath 的理想素材。表头是:

货币名称 | 现汇买入价 | 现钞买入价 | 现汇卖出价 | 现钞卖出价 | 中行折算价 | 发布日期 | 发布时间

对应的 XPath 设计如下:

目标 XPath
定位表格 //table[@id="priceTable"]
表头 //table[@id="priceTable"]//tr[1]//th
数据行 //table[@id="priceTable"]//tr[position()>1]
某行各列 ./td[1] ~ ./td[8]

这里有两个刻意的设计。一是表头按 th 动态读取并建立「列名 → 列下标」映射,而不是把 td[2] 这类下标写死:银行页面偶尔会调整列的先后顺序,写死下标就会静默错位(价格串到别的列上,还查不出来)。二是分页去重:这个站点的 index_2.html 到 index_9.html 并不是新数据,而是同一批 44 种货币的轮转排列,index_10.html 干脆是空表,所以爬虫按「货币名称」去重,遇到一张没有数据行的表就停止翻页。

外币牌价这类数据有个特点:不能转成 float 再存。页面上是 666.48 这样的字符串,直接转浮点会丢掉原始精度和格式,遇到带千分位或 --(无牌价)的情况还会抛异常,所以 TBP/CBP/TSP/CSP 四个价格字段在库里是 VARCHAR,原样保存,需要计算时再在 SQL 或分析层转换。

3.2 核心代码

"""中国银行 外汇牌价 爬虫(Scrapy + XPath + MySQL)。

目标站点:https://www.boc.cn/sourcedb/whpj/
目标字段:货币名称(Currency) / 现汇买入价(TBP) / 现钞买入价(CBP) /
          现汇卖出价(TSP) / 现钞卖出价(CSP) / 发布时间(Time)

页面是服务端渲染的静态 HTML 表格 <table id="priceTable">,可以直接用
XPath 定位表头和数据行。分页方面:第 1 页就是 index.html,从第 2 页起
对应 index_2.html … index_10.html;实测各页内容是同一批 44 种货币的
轮转排列(服务端分页参数被忽略),因此按货币名称去重,只保留首次出现的
记录,遇到空页即停止翻页。
"""
import scrapy

from scrapy_forex.items import ForexItem


class BocForexSpider(scrapy.Spider):
    """遍历牌价列表页,抽取表格每一行,按货币名称去重。"""

    name = "boc_forex"
    allowed_domains = ["www.boc.cn"]

    BASE = "https://www.boc.cn/sourcedb/whpj/"
    MAX_PAGE = 10

    custom_settings = {
        "MYSQL_TABLE": "boc_forex",
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.seen = set()   # 已产出的货币名称,用于跨页去重

    async def start(self):
        """Scrapy 2.13+ 的异步入口:遍历 10 页。"""
        for page in range(1, self.MAX_PAGE + 1):
            yield self._build_request(page)

    def start_requests(self):
        """兼容旧版 Scrapy 的入口。"""
        for page in range(1, self.MAX_PAGE + 1):
            yield self._build_request(page)

    def _build_request(self, page):
        url = self.BASE if page == 1 else f"{self.BASE}index_{page}.html"
        return scrapy.Request(url, callback=self.parse, meta={"page": page})

    def parse(self, response):
        """XPath 取表头做字段映射,再逐行取单元格文本。"""
        page = response.meta["page"]

        # 表头://table[@id="priceTable"]//tr[1]//th(个别页面表头用 td)
        header_cells = response.xpath('//table[@id="priceTable"]//tr[1]//th')
        if not header_cells:
            header_cells = response.xpath('//table[@id="priceTable"]//tr[1]//td')
        headers = ["".join(cell.xpath(".//text()").getall()).strip()
                   for cell in header_cells]
        index = {name: i for i, name in enumerate(headers)}
        self.logger.info("第 %d 页表头:%s", page, headers)

        rows = response.xpath('//table[@id="priceTable"]//tr[position()>1]')
        self.logger.info("第 %d 页共 %d 行", page, len(rows))
        if not rows:
            self.logger.info("第 %d 页无数据,停止翻页", page)
            return

        def pick(cells, *names):
            """按表头名称取值,兼容表头列顺序变化。"""
            for name in names:
                if name in index and index[name] < len(cells):
                    return cells[index[name]]
            return ""

        new_count = 0
        for row in rows:
            cells = ["".join(cell.xpath(".//text()").getall()).strip()
                     for cell in row.xpath("./td")]
            if len(cells) < 6:
                continue
            currency = pick(cells, "货币名称", "Currency")
            if not currency or currency in self.seen:
                continue
            self.seen.add(currency)
            new_count += 1

            item = ForexItem()
            item["currency"] = currency                                   # Currency
            item["tbp"] = pick(cells, "现汇买入价", "TBP")                 # TBP 现汇买入价
            item["cbp"] = pick(cells, "现钞买入价", "CBP")                 # CBP 现钞买入价
            item["tsp"] = pick(cells, "现汇卖出价", "TSP")                 # TSP 现汇卖出价
            item["csp"] = pick(cells, "现钞卖出价", "CSP")                 # CSP 现钞卖出价
            item["pub_time"] = pick(cells, "发布日期", "发布时间", "Time")  # Time 发布时间
            item["page"] = page
            yield item

        self.logger.info("第 %d 页新增 %d 种货币(累计 %d 种)",
                         page, new_count, len(self.seen))

管道与作业②共用同一套写法(建库、建表、批量提交),只是表名和唯一键不同:UNIQUE KEY uk_currency_time(currency, pub_time),保证同一种货币在同一发布时间只存一行。

3.3 运行结果

爬虫翻了 10 页,第 10 页表头为空即停止,最终入库 45 种货币:

图 3-1 Scrapy 采集中国银行外汇牌价并写入 MySQL

查库验证:COUNT(DISTINCT currency) 为 45,抽出美元、欧元、英镑、日元、港币、澳大利亚元、韩国元 7 种主要货币检查四个价格字段与发布时间:

图 3-2 MySQL 中的 boc_forex 表

2. 作业心得

XPath 的 position()>1 比「跳过第一行」更可靠。 一开始我用的是 //tr[2] 开始取,结果页面结构一变(多了一行说明、或者表头被拆成两行 thead),数据就整体错位一格。改成「所有 tr 里 position()>1」并且显式判断这一行有没有 8 个 td,才真正稳。写 XPath 的经验是:定位要基于「语义特征」(有 8 个 td 的那一行才是数据行),而不是「位置序号」。

分页陷阱是这个作业最大的收获。 我原本以为 index_2.html…index_10.html 是十页不同的数据,跑完发现只多了一条(44 → 45),打印每页的货币名称才看出来:它们其实是同一批货币在轮转,第一页末尾的几种货币跑到第二页开头去了。如果不去重,入库就会变成 44×10 条重复数据,看起来「抓了很多」,实际全是噪声。采集量的多少不是成绩,去重后的有效数据才是。

数据库字段类型要跟着数据走。 汇率是「看起来像数字的字符串」,用 DECIMAL 或 VARCHAR 都比 FLOAT 合适;股票价格用 DECIMAL(10,3) 而不是 DOUBLE,避免 39.25 变成 39.249999。另外 MySQL 8 默认字符集是 utf8mb4,建库时显式写上,中文货币名(比如「澳大利亚元」)才不会变成问号。


四、环境与复现说明

4.1 MySQL 准备

本机使用 MySQL 8.0.28 免安装版(zip 解压即用),初始化与启动命令:

:: 初始化数据目录(只执行一次,root 为空密码)
mysqld --initialize-insecure --basedir="D:\tools\mysql-8.0.28-winx64" --datadir="D:\tools\mysql-data"

:: 启动服务
mysqld --basedir="D:\tools\mysql-8.0.28-winx64" --datadir="D:\tools\mysql-data" --port=3306 --console

两个工程的 db_config.py 都通过环境变量覆盖连接参数,换机器不用改代码:

MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
MYSQL_USER = os.getenv("MYSQL_USER", "root")
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
MYSQL_DB = os.getenv("MYSQL_DB", "spider_hw")

4.2 运行步骤

pip install scrapy pymysql requests beautifulsoup4

# 作业①:图片爬取(单线程 + 多线程对比)
cd 作业1 && python 1.py

# 作业②:股票行情
cd 作业2 && scrapy crawl stock_list

# 作业③:外汇牌价
cd 作业3 && scrapy crawl boc_forex

建库建表都由 Pipeline 自动完成,首次运行会 CREATE DATABASE IF NOT EXISTS spider_hw,之后每次运行都是幂等 upsert,可以直接重复执行。

五、仓库结构

实践作业3/
├── 作业1/
│   ├── 1.py                  # 图片爬取(BFS + 单/多线程对比)
│   ├── images/               # 下载的 134 张图片 + 截图
│   └── run_image.log         # 运行日志
├── 作业2/                    # Scrapy 股票工程
│   ├── scrapy.cfg
│   ├── db_config.py
│   ├── images/               # 运行截图、数据库截图
│   └── scrapy_stocks/
└── 作业3/                    # Scrapy 外汇工程
    ├── scrapy.cfg
    ├── db_config.py
    ├── forex.sql             # 查库验证脚本
    ├── images/
    └── scrapy_forex/

Gitee 仓库地址:https://gitee.com/wwang-shihao/data-collection-in-2026(实践作业3 目录)

posted @ 2026-11-08 19:00  Langleah  阅读(9)  评论(0)    收藏  举报