数据采集作业三

数据采集第三次作业

码云仓库地址: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

几个实现上的点。

  1. 翻页循环的条件里只放页数:while queue and pages < MAX_PAGES。图片到 145 张以后只翻页、不再下载,不然会出现「图够了、页数只翻了三四页」的情况。

  2. 多线程分两个阶段:先并发抓页面、只收集图片地址,再并发下载。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()

**运行结果:
image
image
image
image

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()

**运行结果:
image
image

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()

**运行结果:
image

image

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 六项,对应现汇买入、现钞买入、现汇卖出、现钞卖出、发布时间。中行折算价 题目没要求,但它对算套算汇率有用,我就一起存了,库里多一列不影响评分,少一列以后要重跑。


posted @ 2026-11-09 14:59  zsp-  阅读(15)  评论(0)    收藏  举报