学号:052404147  姓名:刘怀正

第三次作业的三道题分别对应三种典型场景:作业①是普通网页的图片批量下载(并对比单线程与多线程),作业②、作业③用 Scrapy 框架 + XPath + MySQL 完成结构化数据采集。运行环境为 Python 3.13、Scrapy 2.19、MariaDB 11.4(MySQL 协议兼容,端口 3307)、requests、lxml。

Gitee 文件夹(作业3):https://gitee.com/qgnb666/2026/tree/master/作业3

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

1.1 作业代码与运行结果

题目要求:指定一个网站,爬取该网站中的所有图片,分别用单线程和多线程下载;务必控制总页数(学号尾数 2 位)与图片数量(尾数后 3 位)。

限制条件:学号 052404147 → 总页数上限 47 页、图片数量上限 147 张。

实现思路

  1. 从 http://www.weather.com.cn/ 开始做广度优先遍历(BFS),只跟踪同域名下的 <a href>,最多抓取 47 个 HTML 页面;
  2. 每个页面用 lxml 解析 <img> 的 src 与 data-original(兼容懒加载),用 urljoin 补全相对地址并去重;
  3. 图片地址最多保留 147 个,先单线程逐张下载到 images_single/,再用 8 线程线程池下载到 images/,分别统计耗时;
  4. 文件名用“序号 + URL 的 MD5 前 10 位 + 原扩展名”,避免重名;控制台逐条输出图片 URL 与字节数。

完整代码(1/1.py)

# -*- coding: utf-8 -*-
"""
数据采集 第三次作业 —— 作业①
爬取指定网站(中国气象网  http://www.weather.com.cn )中的图片,分别用单线程和多线程下载。

限制条件(按学号 052404147):
    总页数     = 学号后 2 位 = 47 页
    图片数量   = 学号后 3 位 = 147 张

实现说明:
    1) 从首页开始做广度优先遍历(BFS),只跟踪同域名(weather.com.cn)的页面链接,
       最多抓取 47 个页面;
    2) 每个页面用 lxml 解析 <img> 的 src / data-original(兼容懒加载),
       用 urljoin 补全相对地址并去重,最多保留 147 个图片地址;
    3) 先单线程逐张下载到 images_single/,再用线程池(8 线程)下载到 images/,
       分别统计耗时并输出控制台。

学号:052404147   姓名:刘怀正
"""
import hashlib
import os
import sys
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urljoin, urlparse, urldefrag

import requests
from lxml import html as lxml_html

# ---------------- 学号限制 ----------------
STUDENT_ID = "052404147"
MAX_PAGES = int(STUDENT_ID[-2:])        # 47 页
MAX_IMAGES = int(STUDENT_ID[-3:])       # 147 张
WORKERS = 8                             # 多线程线程数

START_URL = "http://www.weather.com.cn/"
ALLOWED_DOMAIN = "weather.com.cn"

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/156.0 Safari/537.36"),
    "Referer": START_URL,
}

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PARENT_DIR = os.path.dirname(BASE_DIR)
SINGLE_DIR = os.path.join(BASE_DIR, "images_single")
MULTI_DIR = os.path.join(BASE_DIR, "images")
OUT_PATH = os.path.join(PARENT_DIR, "out_hw3_task1.txt")

OUT = open(OUT_PATH, "w", encoding="utf-8")


def log(*args):
    """同时输出到屏幕和 txt 文件,方便截图与写博客"""
    text = " ".join(str(a) for a in args)
    print(text, file=sys.stdout)
    print(text, file=OUT)


def is_allowed(url):
    """只保留中国气象网域名下的 http/https 链接"""
    host = urlparse(url).netloc.lower()
    return host.endswith(ALLOWED_DOMAIN)


def clean_url(url):
    """去掉 URL 的 #锚点 部分,避免同一页面重复入队"""
    return urldefrag(url)[0]


def crawl_pages():
    """
    广度优先遍历站点,最多抓取 MAX_PAGES 个 HTML 页面。
    返回:[(页面 URL, HTML 文本), ...]
    """
    queue = deque([START_URL])
    visited = set()
    pages = []

    while queue and len(pages) < MAX_PAGES:
        url = clean_url(queue.popleft())
        if url in visited or not is_allowed(url):
            continue
        visited.add(url)
        try:
            resp = requests.get(url, headers=HEADERS, timeout=15)
            resp.raise_for_status()
            if "text/html" not in resp.headers.get("content-type", ""):
                continue
            resp.encoding = resp.apparent_encoding
            pages.append((url, resp.text))
        except requests.RequestException:
            continue

        # 解析页面里的同站链接,加入待抓队列
        try:
            doc = lxml_html.fromstring(resp.text)
        except Exception:
            continue
        for href in doc.xpath("//a/@href"):
            full = clean_url(urljoin(url, href))
            if is_allowed(full) and full not in visited:
                queue.append(full)

    return pages


def extract_images(page_url, page_html):
    """从一个页面中提取图片地址(兼容懒加载的 data-original),返回去重后的列表"""
    try:
        doc = lxml_html.fromstring(page_html)
    except Exception:
        return []
    urls = []
    for img in doc.xpath("//img"):
        src = (img.get("data-original") or img.get("src") or "").strip()
        if not src or src.startswith("data:"):
            continue
        full = urljoin(page_url, src)
        if full.startswith("//"):
            full = "http:" + full
        if full not in urls:
            urls.append(full)
    return urls


def file_name(url, index):
    """按序号 + URL 的哈希值生成唯一文件名,保留原始扩展名"""
    ext = os.path.splitext(urlparse(url).path)[1].lower()
    if ext not in (".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"):
        ext = ".jpg"
    digest = hashlib.md5(url.encode("utf-8")).hexdigest()[:10]
    return "%03d_%s%s" % (index, digest, ext)


def download_one(index, url, save_dir, show=True):
    """下载一张图片,返回 (是否成功, 字节数)"""
    path = os.path.join(save_dir, file_name(url, index))
    try:
        resp = requests.get(url, headers=HEADERS, timeout=15)
        resp.raise_for_status()
        with open(path, "wb") as f:
            f.write(resp.content)
        if show:
            log(f"  [{index:03d}] {len(resp.content):>8} 字节  {url}")
        return True, len(resp.content)
    except requests.RequestException as err:
        if show:
            log(f"  [{index:03d}] 失败:{err}  {url}")
        return False, 0


def download_single(image_urls, save_dir):
    """单线程依次下载,返回 (成功数, 总字节数, 耗时)"""
    os.makedirs(save_dir, exist_ok=True)
    start = time.perf_counter()
    ok, total = 0, 0
    for i, url in enumerate(image_urls, 1):
        success, size = download_one(i, url, save_dir)
        ok += int(success)
        total += size
    return ok, total, time.perf_counter() - start


def download_multi(image_urls, save_dir, workers=WORKERS):
    """线程池并发下载,返回 (成功数, 总字节数, 耗时)"""
    os.makedirs(save_dir, exist_ok=True)
    start = time.perf_counter()
    results = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = [pool.submit(download_one, i, url, save_dir, False)
                   for i, url in enumerate(image_urls, 1)]
        for future in futures:
            results.append(future.result())
    ok = sum(1 for success, _ in results if success)
    total = sum(size for _, size in results)
    return ok, total, time.perf_counter() - start


if __name__ == "__main__":
    log("=" * 78)
    log("作业①:爬取中国气象网图片(单线程 / 多线程)")
    log(f"学号 {STUDENT_ID} -> 总页数上限 {MAX_PAGES} 页,图片数量上限 {MAX_IMAGES} 张,线程数 {WORKERS}")
    log("=" * 78)

    pages = crawl_pages()
    log(f"\n页面遍历完成:实际抓取 {len(pages)} 个页面")
    for i, (url, _) in enumerate(pages, 1):
        log(f"  页面 {i:02d}  {url}")
        if i >= 8:
            log(f"  ...(其余 {len(pages) - 8} 个页面略)")
            break

    image_urls = []
    for page_url, page_html in pages:
        for url in extract_images(page_url, page_html):
            if url not in image_urls:
                image_urls.append(url)
            if len(image_urls) >= MAX_IMAGES:
                break
        if len(image_urls) >= MAX_IMAGES:
            break

    log(f"\n共发现 {len(image_urls)} 个图片地址(上限 {MAX_IMAGES} 张),开始下载:\n")
    for i, url in enumerate(image_urls, 1):
        log(f"  [{i:03d}] {url}")

    log("\n" + "-" * 78)
    log("单线程下载开始")
    ok1, size1, t1 = download_single(image_urls, SINGLE_DIR)
    log(f"单线程完成:成功 {ok1}/{len(image_urls)} 张,{size1 / 1024:.1f} KB,耗时 {t1:.2f} 秒")

    log("\n" + "-" * 78)
    log("多线程下载开始(%d 线程)" % WORKERS)
    ok2, size2, t2 = download_multi(image_urls, MULTI_DIR)
    log(f"多线程完成:成功 {ok2}/{len(image_urls)} 张,{size2 / 1024:.1f} KB,耗时 {t2:.2f} 秒")

    log("\n" + "=" * 78)
    log(f"结果汇总:页面 {len(pages)} 个,图片 {len(image_urls)} 张")
    log(f"单线程 {t1:.2f} 秒  |  多线程 {t2:.2f} 秒  |  加速比 {t1 / t2:.2f}")
    log(f"单线程目录:{SINGLE_DIR}")
    log(f"多线程目录:{MULTI_DIR}")
    log("=" * 78)

    OUT.close()

运行结果(47 个页面、147 张图片,单线程 43.42 秒 vs 多线程 9.19 秒)

hw3_task1_result

下载到的部分图片(images/ 目录,147 张)

hw3_task1_images
完整的 147 条图片 URL 与控制台输出见仓库中的 作业3/out_hw3_task1.txt。

Gitee 文件夹链接:https://gitee.com/qgnb666/2026/tree/master/作业3

1.2 作业心得

这道题让我直观看到了多线程的价值:同样 147 张图片,单线程要 43.42 秒(大部分时间都花在等待网络响应上),换成 8 线程后只要 9.19 秒,加速比 4.72。原因是下载属于 I/O 密集型任务,ThreadPoolExecutor 在等待一个请求返回时可以去发起另一个请求,CPU 并不会被占满。

另一个收获是“爬站”的组织方式。如果只抓首页,图片数量远远不够,所以必须用 BFS 沿站内链接扩散;同时要限制页面数,否则会越爬越多。代码里用 visited 集合防止重复入队、用“同域名判断”防止爬到站外,再用图片列表去重和数量上限,三者配合才能做到“可控地爬完”。

二、作业②与作业③:Scrapy + XPath + MySQL

2.1 作业代码与运行结果

2.1.1 作业②:东方财富股票行情(Scrapy + XPath + MySQL)

题目要求:使用 Scrapy 框架 + XPath + MySQL 存储,爬取股票相关信息,字段包括序号、股票代码、名称、最新报价、涨跌幅、涨跌额、成交量、振幅、最高、最低、今开、昨收。

数据来源分析

东方财富的行情列表页 quote.eastmoney.com/center/gridlist.html 由 JavaScript 渲染,HTML 源码中没有表格数据;在 F12 的 Network 面板中可以看到页面调用的接口 push2.eastmoney.com/api/qt/clist/get,它返回 JSON。因此 Spider 请求该接口,把每条 JSON 记录转换成 XML 节点,再用 XPath 提取字段(f12=代码、f14=名称、f2=最新价、f3=涨跌幅、f4=涨跌额、f5=成交量、f7=振幅、f15=最高、f16=最低、f17=今开、f18=昨收)。

由于东方财富接口对 Scrapy/Twisted 的短连接偶尔返回 RST,代码里增加了一个下载中间件:下载异常时改用 requests 重新请求并返回 Response,保证每次都能稳定拿到 5 页共 500 条数据。

字段设计(MySQL 表 stock)

中文 英文字段 类型
序号 id INT 自增主键
股票代码 stock_no VARCHAR(10)
股票名称 stock_name VARCHAR(40)
最新报价 latest_price DECIMAL(10,2)
涨跌幅 change_pct DECIMAL(10,2)
涨跌额 change_amount DECIMAL(10,2)
成交量 volume BIGINT
振幅 amplitude DECIMAL(10,2)
最高 high_price DECIMAL(10,2)
最低 low_price DECIMAL(10,2)
今开 open_price DECIMAL(10,2)
昨收 prev_close DECIMAL(10,2)

Item 定义(2/stock_crawl/items.py)

# -*- coding: utf-8 -*-
"""作业②的 Item 定义:股票行情字段"""
import scrapy


class StockItem(scrapy.Item):
    """一只股票的一条行情记录"""
    stock_no = scrapy.Field()        # 股票代码
    stock_name = scrapy.Field()      # 股票名称
    latest_price = scrapy.Field()    # 最新报价
    change_pct = scrapy.Field()      # 涨跌幅(%)
    change_amount = scrapy.Field()   # 涨跌额
    volume = scrapy.Field()          # 成交量(手)
    amplitude = scrapy.Field()       # 振幅(%)
    high_price = scrapy.Field()      # 最高
    low_price = scrapy.Field()       # 最低
    open_price = scrapy.Field()      # 今开
    prev_close = scrapy.Field()      # 昨收
    crawl_time = scrapy.Field()      # 抓取时间

Spider(2/stock_crawl/spiders/stock.py)

# -*- coding: utf-8 -*-
"""
作业②的 Spider:爬取东方财富网沪深 A 股行情。

说明:
    东方财富的行情列表页(quote.eastmoney.com/center/gridlist.html)由 JavaScript
    渲染,HTML 源码中没有表格数据,因此直接抓页面用 XPath 取不到行情。
    在 F12 的 Network 面板中可以找到页面实际调用的数据接口 clist/get,
    它返回 JSON;本 Spider 请求该接口,把每条 JSON 记录转换成 XML 节点,
    再用 XPath 提取字段(f12=代码、f14=名称、f2=最新价、f3=涨跌幅……),
    以 Item 形式交给 Pipeline 写入 MySQL 并序列化为 JSON Lines。
"""
import json
import xml.etree.ElementTree as ET

import scrapy
from parsel import Selector

from stock_crawl.items import StockItem

# 东方财富沪深 A 股列表接口
API_URL = ("http://push2.eastmoney.com/api/qt/clist/get"
           "?pn={page}&pz=100&po=1&np=1&ut=bd1d9ddb04089700cf9c27f6f7426281"
           "&fltt=2&invt=2&fid=f3&fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"
           "&fields=f2,f3,f4,f5,f6,f7,f12,f14,f15,f16,f17,f18")

MAX_PAGES = 5          # 抓取前 5 页,共约 500 只股票


def to_num(value):
    """把接口里的 '-'、'' 等无效值转成 None,其余转成 float"""
    if value in (None, "-", ""):
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def records_to_xml(records):
    """把 JSON 记录列表转换成 <root><stock><f12>…</f12>…</stock></root> 的 XML 文本"""
    root = ET.Element("root")
    for record in records:
        node = ET.SubElement(root, "stock")
        for key, value in record.items():
            child = ET.SubElement(node, key)
            child.text = "" if value is None else str(value)
    return ET.tostring(root, encoding="unicode")


class StockSpider(scrapy.Spider):
    """沪深 A 股行情 Spider"""

    name = "stock"
    allowed_domains = ["eastmoney.com"]

    def start_requests(self):
        """按页生成接口请求(旧版 Scrapy 的入口)"""
        for page in range(1, MAX_PAGES + 1):
            url = API_URL.format(page=page)
            self.logger.info("请求第 %d 页:%s", page, url)
            yield scrapy.Request(url, callback=self.parse, meta={"page": page})

    async def start(self):
        """Scrapy 2.13+ 的异步入口,与 start_requests 等价"""
        for request in self.start_requests():
            yield request

    def parse(self, response):
        """解析接口 JSON -> XML -> XPath -> Item"""
        payload = json.loads(response.text)
        data = payload.get("data") or {}
        records = data.get("diff") or []
        if not records:
            self.logger.warning("第 %s 页没有返回数据", response.meta.get("page"))
            return

        self.logger.info("第 %s 页返回 %d 条,接口总数 %s",
                         response.meta.get("page"), len(records), data.get("total"))

        xml_text = records_to_xml(records)
        selector = Selector(text=xml_text, type="xml")

        for node in selector.xpath("//stock"):
            yield StockItem(
                stock_no=node.xpath("./f12/text()").get(),
                stock_name=node.xpath("./f14/text()").get(),
                latest_price=to_num(node.xpath("./f2/text()").get()),
                change_pct=to_num(node.xpath("./f3/text()").get()),
                change_amount=to_num(node.xpath("./f4/text()").get()),
                volume=to_num(node.xpath("./f5/text()").get()),
                amplitude=to_num(node.xpath("./f7/text()").get()),
                high_price=to_num(node.xpath("./f15/text()").get()),
                low_price=to_num(node.xpath("./f16/text()").get()),
                open_price=to_num(node.xpath("./f17/text()").get()),
                prev_close=to_num(node.xpath("./f18/text()").get()),
            )

Pipeline(2/stock_crawl/pipelines.py,JSON 序列化 + MySQL)

# -*- coding: utf-8 -*-
"""作业②的 Pipeline:JSON 序列化输出 + MySQL 存储"""
import json
import logging
import os
from datetime import datetime

import pymysql
from itemadapter import ItemAdapter

logger = logging.getLogger(__name__)

# MySQL 连接参数:默认本机 MariaDB(端口 3307),也可用环境变量覆盖
MYSQL_CONFIG = {
    "host": os.getenv("MYSQL_HOST", "127.0.0.1"),
    "port": int(os.getenv("MYSQL_PORT", "3307")),
    "user": os.getenv("MYSQL_USER", "root"),
    "password": os.getenv("MYSQL_PASSWORD", "root123"),
    "charset": "utf8mb4",
}
DATABASE = os.getenv("MYSQL_DATABASE", "hw3")
TABLE = "stock"

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(BASE_DIR, "output")


class JsonLinesPipeline:
    """把每个 Item 序列化成一行 JSON,写入 output/stock_items.jsonl"""

    def open_spider(self, spider=None):
        os.makedirs(OUT_DIR, exist_ok=True)
        path = os.path.join(OUT_DIR, "stock_items.jsonl")
        self.file = open(path, "w", encoding="utf-8")
        logger.info("JSON 序列化输出:%s", path)

    def process_item(self, item, spider=None):
        # ItemAdapter 把 Item 转成字典,ensure_ascii=False 保证中文可读
        line = json.dumps(dict(ItemAdapter(item)), ensure_ascii=False)
        self.file.write(line + "\n")
        return item

    def close_spider(self, spider=None):
        self.file.close()


class MySQLPipeline:
    """建立数据表并把 Item 写入 MySQL,主键 stock_no 保证重复运行不重复入库"""

    def open_spider(self, spider=None):
        self.conn = pymysql.connect(**MYSQL_CONFIG)
        self.cur = self.conn.cursor()
        self.cur.execute("CREATE DATABASE IF NOT EXISTS %s "
                         "DEFAULT CHARACTER SET utf8mb4" % DATABASE)
        self.conn.select_db(DATABASE)
        self.cur.execute(f"""
            CREATE TABLE IF NOT EXISTS {TABLE} (
                id            INT AUTO_INCREMENT PRIMARY KEY COMMENT '序号',
                stock_no      VARCHAR(10)  NOT NULL COMMENT '股票代码',
                stock_name    VARCHAR(40)  NOT NULL COMMENT '股票名称',
                latest_price  DECIMAL(10,2) COMMENT '最新报价',
                change_pct    DECIMAL(10,2) COMMENT '涨跌幅(%)',
                change_amount DECIMAL(10,2) COMMENT '涨跌额',
                volume        BIGINT       COMMENT '成交量(手)',
                amplitude     DECIMAL(10,2) COMMENT '振幅(%)',
                high_price    DECIMAL(10,2) COMMENT '最高',
                low_price     DECIMAL(10,2) COMMENT '最低',
                open_price    DECIMAL(10,2) COMMENT '今开',
                prev_close    DECIMAL(10,2) COMMENT '昨收',
                crawl_time    DATETIME     COMMENT '抓取时间',
                UNIQUE KEY uk_stock (stock_no)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='沪深A股行情'
        """)
        self.conn.commit()

    def process_item(self, item, spider=None):
        data = ItemAdapter(item)
        self.cur.execute(f"""
            INSERT INTO {TABLE}
                (stock_no, stock_name, latest_price, change_pct, change_amount,
                 volume, amplitude, high_price, low_price, open_price, prev_close, crawl_time)
            VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
            ON DUPLICATE KEY UPDATE
                stock_name=VALUES(stock_name), latest_price=VALUES(latest_price),
                change_pct=VALUES(change_pct), change_amount=VALUES(change_amount),
                volume=VALUES(volume), amplitude=VALUES(amplitude),
                high_price=VALUES(high_price), low_price=VALUES(low_price),
                open_price=VALUES(open_price), prev_close=VALUES(prev_close),
                crawl_time=VALUES(crawl_time)
        """, (
            data.get("stock_no"), data.get("stock_name"), data.get("latest_price"),
            data.get("change_pct"), data.get("change_amount"), data.get("volume"),
            data.get("amplitude"), data.get("high_price"), data.get("low_price"),
            data.get("open_price"), data.get("prev_close"),
            datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        ))
        self.conn.commit()
        return item

    def close_spider(self, spider=None):
        self.cur.execute(f"SELECT COUNT(*) FROM {TABLE}")
        total = self.cur.fetchone()[0]
        logger.info("MySQL 写入完成,stock 表共 %d 条记录", total)
        self.cur.close()
        self.conn.close()

下载中间件(2/stock_crawl/middlewares.py,Scrapy 异常时用 requests 兜底)

# -*- coding: utf-8 -*-
"""
作业②的下载中间件:东方财富接口对 Scrapy/Twisted 的短连接偶发 RST(Connection lost),
这里在下载异常时用 requests 兜底重新请求(最多 3 次),保证 Spider 稳定拿到 JSON。
"""
import time

import requests
from scrapy.http import HtmlResponse


class RequestsFallbackMiddleware:
    """下载失败时改用 requests 重新请求并返回 Response"""

    def __init__(self, user_agent):
        self.user_agent = user_agent

    @classmethod
    def from_crawler(cls, crawler):
        return cls(crawler.settings.get("USER_AGENT") or "Mozilla/5.0")

    def process_exception(self, request, exception, spider):
        headers = {"User-Agent": self.user_agent, "Referer": "https://quote.eastmoney.com/"}
        for attempt in range(1, 4):
            try:
                resp = requests.get(request.url, headers=headers, timeout=20)
                resp.raise_for_status()
                spider.logger.warning("Scrapy 下载异常,已用 requests 兜底(第 %d 次):%s",
                                      attempt, request.url)
                return HtmlResponse(url=request.url, status=resp.status_code,
                                    body=resp.content, encoding="utf-8", request=request)
            except Exception as err:
                spider.logger.warning("requests 兜底第 %d 次失败:%s", attempt, err)
                time.sleep(2)
        return None

运行入口(2/2.py)

# -*- coding: utf-8 -*-
"""作业②运行入口:python 2.py"""
import os

from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings

from stock_crawl.spiders.stock import StockSpider

os.environ.setdefault("SCRAPY_SETTINGS_MODULE", "stock_crawl.settings")

if __name__ == "__main__":
    process = CrawlerProcess(get_project_settings())
    process.crawl(StockSpider)
    process.start()

运行结果(Scrapy 抓取 5 页共 500 条,JSON Lines 与 MySQL 双份输出)

hw3_task2_result

hw3_task2_mysql

完整运行日志与数据库查询结果见仓库中的 作业3/out_hw3_task2.txt、作业3/out_hw3_task2_mysql.txt。MySQL 连接参数可通过环境变量 MYSQL_HOST、MYSQL_PORT、MYSQL_USER、MYSQL_PASSWORD、MYSQL_DATABASE 覆盖,默认使用本机 127.0.0.1:3307、root/root123、库 hw3。

Gitee 文件夹链接:https://gitee.com/qgnb666/2026/tree/master/作业3

2.1.2 作业③:中国银行外汇牌价(Scrapy + XPath + MySQL)

题目要求:用 Scrapy + XPath + MySQL 爬取外汇网站数据,输出货币名称、TBP、CBP、TSP、CSP 与时间。

实现思路

中国银行牌价页 https://www.boc.cn/sourcedb/whpj/ 是静态 HTML,牌价直接写在表格的 <tr><td> 中,非常适合 XPath:

//table[.//th[contains(normalize-space(.),'现汇买入价')]]//tr[position()>1]

每行 8 个单元格依次是:货币名称、现汇买入价(TBP)、现钞买入价(CBP)、现汇卖出价(TSP)、现钞卖出价(CSP)、中行折算价、发布日期时间、发布时间。页面编码需要按 utf-8/gb18030 兜底解码,否则中文表头匹配不到。

Item 定义(3/forex_crawl/items.py)

# -*- coding: utf-8 -*-
"""作业③的 Item 定义:中国银行外汇牌价"""
import scrapy


class ForexItem(scrapy.Item):
    """一种货币的一条牌价记录"""
    currency = scrapy.Field()      # 货币名称
    tbp = scrapy.Field()           # 现汇买入价 TBP
    cbp = scrapy.Field()           # 现钞买入价 CBP
    tsp = scrapy.Field()           # 现汇卖出价 TSP
    csp = scrapy.Field()           # 现钞卖出价 CSP
    publish_date = scrapy.Field()  # 发布日期
    publish_time = scrapy.Field()  # 发布时间
    crawl_time = scrapy.Field()    # 抓取时间

Spider(3/forex_crawl/spiders/forex.py)

# -*- coding: utf-8 -*-
"""
作业③的 Spider:用 Scrapy + XPath 爬取中国银行外汇牌价。

页面 https://www.boc.cn/sourcedb/whpj/ 是静态 HTML,牌价直接写在
<table> 的 <tr><td> 中,因此用 XPath 定位表格行、再取各单元格文本即可:
    td[0] 货币名称、td[1] 现汇买入价、td[2] 现钞买入价、
    td[3] 现汇卖出价、td[4] 现钞卖出价、td[5] 中行折算价、
    td[6] 发布日期时间、td[7] 发布时间
"""
import scrapy

from forex_crawl.items import ForexItem

PAGE_URL = "https://www.boc.cn/sourcedb/whpj/"


def to_num(value):
    """'-'、'' 等无效值转 None,其余转 float"""
    if not value or value.strip() == "-":
        return None
    try:
        return float(value)
    except ValueError:
        return None


class ForexSpider(scrapy.Spider):
    """中国银行外汇牌价 Spider"""

    name = "forex"
    allowed_domains = ["boc.cn"]

    async def start(self):
        """Scrapy 2.13+ 的入口"""
        yield scrapy.Request(PAGE_URL, callback=self.parse)

    def parse(self, response):
        # 手动按 utf-8 / gb18030 解码,避免 Scrapy 按错误的响应头编码解析导致中文列名匹配失败
        text = None
        for encoding in ("utf-8", "gb18030"):
            try:
                candidate = response.body.decode(encoding)
            except UnicodeDecodeError:
                continue
            if "现汇买入价" in candidate:
                text = candidate
                break
        if text is None:
            text = response.body.decode("utf-8", errors="ignore")

        selector = scrapy.Selector(text=text, type="html")
        # 只取包含“现汇买入价”表头的表格,跳过页面上其它无关表格
        rows = selector.xpath(
            "//table[.//th[contains(normalize-space(.), '现汇买入价')]]//tr[position()>1]")
        self.logger.info("外汇牌价表共匹配到 %d 行", len(rows))

        for row in rows:
            cells = row.xpath("./td")
            if len(cells) < 8:
                continue

            def cell_text(index):
                """取第 index 个单元格的纯文本"""
                return "".join(cells[index].xpath(".//text()").getall()).strip()

            date_time = cell_text(6)                  # 例:2026/11/06 22:32:20
            publish_date = date_time.split(" ")[0].replace("/", "-") if date_time else None
            publish_time = cell_text(7) or (date_time.split(" ")[-1] if date_time else None)

            yield ForexItem(
                currency=cell_text(0),
                tbp=to_num(cell_text(1)),             # 现汇买入价
                cbp=to_num(cell_text(2)),             # 现钞买入价
                tsp=to_num(cell_text(3)),             # 现汇卖出价
                csp=to_num(cell_text(4)),             # 现钞卖出价
                publish_date=publish_date,
                publish_time=publish_time,
            )

Pipeline(3/forex_crawl/pipelines.py)

# -*- coding: utf-8 -*-
"""作业③的 Pipeline:JSON 序列化输出 + MySQL 存储"""
import json
import logging
import os
from datetime import datetime

import pymysql
from itemadapter import ItemAdapter

logger = logging.getLogger(__name__)

# MySQL 连接参数:默认本机 MariaDB(端口 3307),也可用环境变量覆盖
MYSQL_CONFIG = {
    "host": os.getenv("MYSQL_HOST", "127.0.0.1"),
    "port": int(os.getenv("MYSQL_PORT", "3307")),
    "user": os.getenv("MYSQL_USER", "root"),
    "password": os.getenv("MYSQL_PASSWORD", "root123"),
    "charset": "utf8mb4",
}
DATABASE = os.getenv("MYSQL_DATABASE", "hw3")
TABLE = "forex"

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(BASE_DIR, "output")


class JsonLinesPipeline:
    """把外汇牌价 Item 序列化为 JSON Lines"""

    def open_spider(self, spider=None):
        os.makedirs(OUT_DIR, exist_ok=True)
        path = os.path.join(OUT_DIR, "forex_items.jsonl")
        self.file = open(path, "w", encoding="utf-8")
        logger.info("JSON 序列化输出:%s", path)

    def process_item(self, item, spider=None):
        self.file.write(json.dumps(dict(ItemAdapter(item)), ensure_ascii=False) + "\n")
        return item

    def close_spider(self, spider=None):
        self.file.close()


class MySQLPipeline:
    """把外汇牌价写入 MySQL 的 forex 表"""

    def open_spider(self, spider=None):
        self.conn = pymysql.connect(**MYSQL_CONFIG)
        self.cur = self.conn.cursor()
        self.cur.execute("CREATE DATABASE IF NOT EXISTS %s "
                         "DEFAULT CHARACTER SET utf8mb4" % DATABASE)
        self.conn.select_db(DATABASE)
        self.cur.execute(f"""
            CREATE TABLE IF NOT EXISTS {TABLE} (
                id           INT AUTO_INCREMENT PRIMARY KEY COMMENT '序号',
                currency     VARCHAR(40) NOT NULL COMMENT '货币名称',
                tbp          DECIMAL(12,4) COMMENT '现汇买入价',
                cbp          DECIMAL(12,4) COMMENT '现钞买入价',
                tsp          DECIMAL(12,4) COMMENT '现汇卖出价',
                csp          DECIMAL(12,4) COMMENT '现钞卖出价',
                publish_date DATE COMMENT '发布日期',
                publish_time TIME COMMENT '发布时间',
                crawl_time   DATETIME COMMENT '抓取时间',
                UNIQUE KEY uk_currency (currency, publish_date, publish_time)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中国银行外汇牌价'
        """)
        self.conn.commit()

    def process_item(self, item, spider=None):
        data = ItemAdapter(item)
        self.cur.execute(f"""
            INSERT INTO {TABLE}
                (currency, tbp, cbp, tsp, csp, publish_date, publish_time, crawl_time)
            VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
            ON DUPLICATE KEY UPDATE
                tbp=VALUES(tbp), cbp=VALUES(cbp), tsp=VALUES(tsp), csp=VALUES(csp),
                crawl_time=VALUES(crawl_time)
        """, (
            data.get("currency"), data.get("tbp"), data.get("cbp"),
            data.get("tsp"), data.get("csp"), data.get("publish_date"),
            data.get("publish_time"), datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        ))
        self.conn.commit()
        return item

    def close_spider(self, spider=None):
        self.cur.execute(f"SELECT COUNT(*) FROM {TABLE}")
        total = self.cur.fetchone()[0]
        logger.info("MySQL 写入完成,forex 表共 %d 条记录", total)
        self.cur.close()
        self.conn.close()

运行入口(3/3.py)

# -*- coding: utf-8 -*-
"""作业③运行入口:python 3.py"""
import os

from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings

from forex_crawl.spiders.forex import ForexSpider

os.environ.setdefault("SCRAPY_SETTINGS_MODULE", "forex_crawl.settings")

if __name__ == "__main__":
    process = CrawlerProcess(get_project_settings())
    process.crawl(ForexSpider)
    process.start()

运行结果(44 种货币牌价写入 MySQL)

hw3_task3_result

hw3_task3_mysql
完整运行日志与数据库查询结果见仓库中的 作业3/out_hw3_task3.txt、作业3/out_hw3_task3_mysql.txt。

2.2 作业心得

作业②和③让我第一次完整地用 Scrapy 组织爬虫:Spider 只负责“发请求、解析数据、产出 Item”,数据怎么存交给 Pipeline。这样同一份 Item 可以同时被 JSON Lines 序列化管道和 MySQL 管道处理,既方便查看数据,又完成了入库,职责非常清晰。Item 用 scrapy.Field() 声明字段,Pipeline 里用 ItemAdapter 转字典,比直接操作 dict 更规范。

作业②最麻烦的是数据源。东方财富列表页是 JS 渲染的,F12 里能看到接口但直接抓 HTML 什么都拿不到,这让我理解了“找接口比找选择器更重要”。为了仍然体现 XPath 技术路线,我把接口返回的 JSON 记录转成 XML,再用 //stock/f12 这样的 XPath 定位字段;字段名(f2、f3、f12…)也让我学会了看接口文档/响应来确认含义。

作业③则顺利得多:静态表格一行 tr 对应一条牌价,XPath 一次就能定位到整张表。真正踩到的坑是编码——页面要按 utf-8/gb18030 尝试解码,否则中文表头匹配不到,命中的行数为 0。两题对比下来,我最大的体会是:先判断数据是“静态 HTML”还是“接口 JSON”,再决定用 XPath 还是 JSON 解析,比一上来就写代码重要得多。