数据采集第二次作业

数据采集第二次作业

姓名:徐张睿 学号:102402139 
Gitee 仓库:https://gitee.com/V1oletQAQ/violet-qaq/tree/master/%E4%BD%9C%E4%B8%9A2

这次作业的三个小题分别对应三种典型页面:普通 HTML 页面、JSON 列表接口、单次返回全量数据的 JSON 接口。我没有一上来就写代码,而是先按“看页面 → F12 找请求 → 小样本验证 → 批量抓取 → 入库”的顺序做,下面按三个作业分别记录。

一、实验代码与运行截图

1.1 作业①:中国气象网 7 日天气预报

任务要求

在中国气象网给定 3~5 个城市,抓取 7 日天气预报并保存到数据库。

网页分析

中国气象网的城市天气页 URL 形如:

https://www.weather.com.cn/weather/101010100.shtml

其中 101010100 是北京的城市编码。换城市只需要换编码,例如上海 101020100、广州 101280101、成都 101270101、武汉 101200101。我选了这 5 个城市,每个城市 7 天,共 35 条。

按 F12 看 Elements,7 天预报都在 <ul class="t clearfix"> 下面的 7 个 <li> 里,每天的结构是:

<li>
  <h1>6日(今天)</h1>
  <p class="wea">晴转多云</p>
  <p class="tem"><span>14</span>/<i>2℃</i></p>
  <p class="win">
    <span title="东南风"></span>
    <span title="东北风"></span>
    <i>&lt;3级</i>
  </p>
</li>

这里有一个小坑:响应头写的是 ISO-8859-1,但页面实际是 UTF-8。如果直接用 response.text,中文会乱码,所以代码里用 response.content.decode("utf-8")。

数据库设计

表名 weather_forecast,字段如下:

字段 含义
city / city_code 城市名 / 城市编码
forecast_date 预报日期
day_label 网页上的“6日(今天)”标签
weather 天气现象
temp_high / temp_low 最高温 / 最低温
wind / wind_level 风向 / 风力
fetched_at 抓取时间

唯一键是 (city_code, forecast_date),重复运行只更新,不会插入重复数据。

完整代码

点击查看 2_1_weather.py 完整代码
# -*- coding: utf-8 -*-
"""第二次作业-1:中国气象网 7 日天气预报爬虫。

目标:
    抓取 5 个城市在中国气象网(weather.com.cn)的 7 日天气预报,
    解析后写入 SQLite 数据库 homework2.db,并导出 CSV 方便查看。

运行:
    python 2_1_weather.py

说明:
    1. 只依赖 requests,HTML 解析使用 Python 标准库 re,不需要安装 bs4;
    2. 网站返回的编码可能被误判为 ISO-8859-1,因此显式按 UTF-8 解码;
    3. 数据库使用 UPSERT,同一天重复运行不会产生重复记录。
"""

from __future__ import annotations

import argparse
import csv
import re
import sqlite3
import sys
from datetime import date, datetime, timedelta
from html import unescape
from pathlib import Path
from typing import Any
from unicodedata import east_asian_width

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


# ---------------------------------------------------------------------------
# 1. 基础配置
# ---------------------------------------------------------------------------

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "homework2.db"
OUTPUT_DIR = BASE_DIR / "output"

# 城市名 -> 中国气象网城市编码。编码可从天气页面 URL 的 101xxxxxx.shtml 中获得。
CITIES: list[tuple[str, str]] = [
    ("北京", "101010100"),
    ("上海", "101020100"),
    ("广州", "101280101"),
    ("成都", "101270101"),
    ("武汉", "101200101"),
]

WEATHER_URL = "https://www.weather.com.cn/weather/{city_code}.shtml"

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/131.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Referer": "https://www.weather.com.cn/",
}


# ---------------------------------------------------------------------------
# 2. 网络请求
# ---------------------------------------------------------------------------

def build_session() -> requests.Session:
    """创建带自动重试的 requests 会话。"""
    session = requests.Session()
    session.headers.update(HEADERS)

    retry = Retry(
        total=3,
        connect=3,
        read=3,
        backoff_factor=0.8,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset({"GET"}),
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session


def fetch_html(session: requests.Session, url: str) -> str:
    """请求天气页面并返回正确解码的 HTML。"""
    response = session.get(url, timeout=20)
    response.raise_for_status()
    # 该站点响应头常写 ISO-8859-1,但页面实际是 UTF-8;不能直接使用 response.text。
    return response.content.decode("utf-8", errors="replace")


# ---------------------------------------------------------------------------
# 3. HTML 解析
# ---------------------------------------------------------------------------

def strip_tags(fragment: str) -> str:
    """去掉 HTML 标签、转义字符和多余空白,得到纯文本。"""
    text = re.sub(r"<[^>]+>", "", fragment)
    text = unescape(text).replace("\xa0", " ")
    return re.sub(r"\s+", " ", text).strip()


def pad(text: str, width: int) -> str:
    """按终端显示宽度左对齐,避免中文占两个字符导致排版错位。"""
    text = str(text)
    used = sum(2 if east_asian_width(char) in "WF" else 1 for char in text)
    return text + " " * max(0, width - used)


def parse_forecast(
    html: str,
    city: str,
    city_code: str,
    today: date,
) -> list[dict[str, Any]]:
    """从天气页面中解析未来 7 天的预报。

    页面结构(已简化):
        <ul class="t clearfix">
          <li>
            <h1>6日(今天)</h1>
            <p class="wea">晴转多云</p>
            <p class="tem"><span>14</span>/<i>2℃</i></p>
            <p class="win"><span title="东南风"></span><i>&lt;3级</i></p>
          </li>
          ...
        </ul>
    """
    section_match = re.search(
        r'<ul\s+class="t clearfix"[^>]*>(.*?)</ul>',
        html,
        flags=re.S | re.I,
    )
    if not section_match:
        raise ValueError("页面中未找到 ul.t.clearfix,网站结构可能已变化")

    section = section_match.group(1)
    blocks = re.findall(r"<li\b[^>]*>(.*?)</li>", section, flags=re.S | re.I)
    if not blocks:
        raise ValueError("预报列表为空,网站结构可能已变化")

    fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows: list[dict[str, Any]] = []

    for index, block in enumerate(blocks):
        day_match = re.search(r"<h1[^>]*>(.*?)</h1>", block, flags=re.S | re.I)
        weather_match = re.search(
            r'<p[^>]*class="wea"[^>]*>(.*?)</p>', block, flags=re.S | re.I
        )
        temperature_match = re.search(
            r'<p[^>]*class="tem"[^>]*>(.*?)</p>', block, flags=re.S | re.I
        )
        wind_match = re.search(
            r'<p[^>]*class="win"[^>]*>(.*?)</p>', block, flags=re.S | re.I
        )

        if not day_match or not weather_match or not temperature_match:
            continue

        # 温度形如:<span>14</span>/<i>2℃</i>,第一个是最高温,第二个是最低温。
        temperatures = re.findall(r"(-?\d+)", strip_tags(temperature_match.group(1)))
        if len(temperatures) < 2:
            continue

        wind_directions: list[str] = []
        wind_level = ""
        if wind_match:
            wind_html = wind_match.group(1)
            for direction in re.findall(r'<span[^>]*title="([^"]+)"', wind_html):
                direction = unescape(direction).strip()
                if direction and (not wind_directions or wind_directions[-1] != direction):
                    wind_directions.append(direction)
            level_match = re.search(r"<i[^>]*>(.*?)</i>", wind_html, flags=re.S | re.I)
            if level_match:
                wind_level = strip_tags(level_match.group(1))

        # 列表第一项就是“今天”,之后依次递增;这里同时保留网页上的日期标签。
        forecast_date = today + timedelta(days=index)
        rows.append(
            {
                "city": city,
                "city_code": city_code,
                "forecast_date": forecast_date.isoformat(),
                "day_label": strip_tags(day_match.group(1)),
                "weather": strip_tags(weather_match.group(1)),
                "temp_high": int(temperatures[0]),
                "temp_low": int(temperatures[1]),
                "wind": "转".join(wind_directions),
                "wind_level": wind_level,
                "fetched_at": fetched_at,
            }
        )

    if not rows:
        raise ValueError("没有解析到有效预报数据")
    return rows


# ---------------------------------------------------------------------------
# 4. 数据库与文件导出
# ---------------------------------------------------------------------------

def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection:
    """连接 SQLite,并创建天气预报表。"""
    connection = sqlite3.connect(db_path)
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS weather_forecast (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            city          TEXT NOT NULL,
            city_code     TEXT NOT NULL,
            forecast_date TEXT NOT NULL,
            day_label     TEXT,
            weather       TEXT,
            temp_high     INTEGER,
            temp_low      INTEGER,
            wind          TEXT,
            wind_level    TEXT,
            fetched_at    TEXT NOT NULL,
            UNIQUE (city_code, forecast_date)
        )
        """
    )
    connection.commit()
    return connection


def save_forecast(connection: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
    """批量写入预报数据;按(城市编码, 预报日期)更新,避免重复。"""
    sql = """
        INSERT INTO weather_forecast (
            city, city_code, forecast_date, day_label, weather,
            temp_high, temp_low, wind, wind_level, fetched_at
        ) VALUES (
            :city, :city_code, :forecast_date, :day_label, :weather,
            :temp_high, :temp_low, :wind, :wind_level, :fetched_at
        )
        ON CONFLICT(city_code, forecast_date) DO UPDATE SET
            city          = excluded.city,
            day_label     = excluded.day_label,
            weather       = excluded.weather,
            temp_high     = excluded.temp_high,
            temp_low      = excluded.temp_low,
            wind          = excluded.wind,
            wind_level    = excluded.wind_level,
            fetched_at    = excluded.fetched_at
    """
    connection.executemany(sql, rows)
    connection.commit()


def export_csv(rows: list[dict[str, Any]], csv_path: Path) -> None:
    """把本次抓取结果导出为 UTF-8-SIG 编码的 CSV,方便 Excel 打开。"""
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    fieldnames = [
        ("city", "城市"),
        ("city_code", "城市编码"),
        ("forecast_date", "预报日期"),
        ("day_label", "网页日期标签"),
        ("weather", "天气"),
        ("temp_high", "最高温(℃)"),
        ("temp_low", "最低温(℃)"),
        ("wind", "风向"),
        ("wind_level", "风力"),
        ("fetched_at", "抓取时间"),
    ]
    with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
        writer = csv.writer(file)
        writer.writerow([title for _, title in fieldnames])
        for row in rows:
            writer.writerow([row[key] for key, _ in fieldnames])


# ---------------------------------------------------------------------------
# 5. 主流程
# ---------------------------------------------------------------------------

def print_preview(rows: list[dict[str, Any]]) -> None:
    """在终端打印预览,便于截图。"""
    print("\n城市  日期        天气            高温  低温  风向            风力")
    print("-" * 80)
    for row in rows:
        print(
            f"{pad(row['city'], 4)}  {row['forecast_date']}  "
            f"{pad(row['weather'], 14)}  {row['temp_high']:>3}   "
            f"{row['temp_low']:>3}   {pad(row['wind'], 14)} {row['wind_level']}"
        )


def main() -> int:
    parser = argparse.ArgumentParser(description="抓取中国气象网 7 日天气预报")
    parser.add_argument(
        "--db",
        type=Path,
        default=DB_PATH,
        help=f"SQLite 数据库路径(默认:{DB_PATH.name})",
    )
    args = parser.parse_args()

    session = build_session()
    connection = get_connection(args.db)
    all_rows: list[dict[str, Any]] = []
    failed_cities: list[str] = []
    today = date.today()

    try:
        for number, (city, city_code) in enumerate(CITIES, start=1):
            url = WEATHER_URL.format(city_code=city_code)
            print(f"[{number}/{len(CITIES)}] 正在抓取 {city}:{url}")
            try:
                html = fetch_html(session, url)
                rows = parse_forecast(html, city, city_code, today)
                save_forecast(connection, rows)
                all_rows.extend(rows)
                print(
                    f"    成功:{len(rows)} 天,"
                    f"日期 {rows[0]['forecast_date']} ~ {rows[-1]['forecast_date']}"
                )
            except (requests.RequestException, ValueError) as exc:
                failed_cities.append(city)
                print(f"    失败:{exc}", file=sys.stderr)

        if not all_rows:
            print("全部城市抓取失败,请检查网络或网站结构。", file=sys.stderr)
            return 1

        print_preview(all_rows)
        csv_path = OUTPUT_DIR / "weather_forecast.csv"
        export_csv(all_rows, csv_path)

        with connection:
            total = connection.execute(
                "SELECT COUNT(*) FROM weather_forecast"
            ).fetchone()[0]

        print("-" * 78)
        print(f"本次写入/更新:{len(all_rows)} 条;数据库总记录:{total} 条")
        print(f"数据库:{args.db}")
        print(f"CSV   :{csv_path}")
        if failed_cities:
            print(f"失败城市:{'、'.join(failed_cities)}", file=sys.stderr)
            return 1
        return 0
    finally:
        connection.close()


if __name__ == "__main__":
    sys.exit(main())

运行结果

执行:

python 2_1_weather.py

终端会依次输出 5 个城市的抓取进度,最后打印 35 条预览,并生成 homework2.db 和 output/weather_forecast.csv。
以下是运行截图:
网页截图:

4cc3ac2a-9fae-495a-8836-620451738e12
运行结果:
image
数据库:
bb9e04d4-736d-4ec8-b38b-03bb8a3836ff

部分运行结果:

城市 日期 天气 最高温 最低温 风向 风力
北京 2026-11-06 晴转多云 14 2 东南风转东北风 <3级
上海 2026-11-06 阴转多云 18 15 东北风转东风 <3级
广州 2026-11-06 小雨转多云 31 21 无持续风向 <3级
成都 2026-11-06 多云转阴 21 14 无持续风向 <3级
武汉 2026-11-06 晴 23 13 东南风转东风 <3级

上表是运行时抓到的真实数据,日期会随运行日期变化。

这一题的关键点

  1. 城市编码不是随便猜的,从网页 URL 里取最方便;
  2. 7 天数据是服务端直接渲染的 HTML,不需要 Selenium;
  3. 中文编码要显式处理,否则数据库里会存进乱码;
  4. 用 UNIQUE + UPSERT 保证重复运行不重复入库。

1.2 作业②:东方财富网股票行情

任务要求

用 requests 和 json 解析方法定向爬取股票相关信息,并保存到数据库。

F12 抓包过程

  1. 打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board;
  2. 按 F12,切换到 Network,在筛选栏选“全部”或“JS”,不要只选 Fetch/XHR;
  3. 按 Ctrl+R 刷新(或者点一次“下一页”),在 Network 的筛选框里输入 clist 或 push2,会出现 api/qt/clist/get?...&cb=jQuery... 的请求;
  4. 请求名可能只显示成 get?np=1...,点开 Headers 看完整 URL;再点 Preview 看返回内容。

为什么在 Fetch/XHR 里找不到?

东方财富这个页面用的是 jQuery JSONP(源码里是 dataType: "jsonp"、cb=?),浏览器会把它归类为 JS/Script,而不是 Fetch/XHR。所以只勾 Fetch/XHR 时,列表里只有 sidemenu_new.json、portfolio.js 这些请求,看不到行情列表。

另外,带 cb=jQuery... 的响应会被包一层 jQuery...({...}),不是纯 JSON。我们代码里不带 cb 参数,所以接口直接返回纯 JSON,才能用 response.json() 解析。

JSON 数据在 data 里,结构如下:

{
  "rc": 0,
  "data": {
    "total": 5563,
    "diff": [
      {"f12": "000001", "f14": "平安银行", "f2": 11.64, "f3": 0.52}
    ]
  }
}

页面这次请求带的是 fltt=1,所以截图里 f2=4796、f3=16959,对应页面上的 47.96、169.59%——数值被放大了 100 倍。本项目代码用 fltt=2,拿到的是小数,不需要再除。

另外,页面默认是“沪深京A股”口径,total 可能显示 5581;本项目 fs 只选沪深四个市场(不含北交所),入库是 5563。两者口径不同,博客里说明一下即可,不要把 5581 和 5563 混着写。

页面请求用的是 fid=f3(按涨幅排序)、pz=20、fltt=1;代码改成 fid=f12(按代码排序)、pz=100、fltt=2,这样分页稳定,拿到的也是小数。参数可以按作业要求适当调整,这是正常的。

  1. data.total 是股票总数,data.diff 是当前页的列表;
  2. 修改 URL 中的 pn 可以翻页,修改 pz 可以改变每页条数。

请求参数的含义:

参数 作用
pn 页码
pz 每页条数
fid 排序字段,代码里用 f12(按代码排序)
po 排序方向,0 为升序
fs 市场筛选条件,对应沪深主板、创业板、科创板
fields 需要返回的字段编号,用逗号分隔

字段编号和含义:

字段 含义 字段 含义
f12 股票代码 f14 股票名称
f2 最新价 f3 涨跌幅
f4 涨跌额 f5 成交量
f6 成交额 f7 振幅
f8 换手率 f9 市盈率
f10 量比 f15 最高
f16 最低 f17 今开
f18 昨收 f20 总市值
f21 流通市值 f23 市净率

作业要求里提到“URL 中的 f1、f2 可以获取不同数值”,实际就是通过 fields 选择字段编号。字段不是越多越好,只保留需要的字段,响应体会小很多,解析也更清楚。例如只保留代码、名称、最新价、涨跌幅:

...&fields=f12,f14,f2,f3

代码里保留了 18 个字段,是因为数据库表需要完整一点的行情快照;如果只是做课堂演示,完全可以先删到 4~5 个字段,等调通后再加回来。

分页与入库

接口每页最多 100 条,data.total 约为 5563,因此共 56 页。代码先请求第 1 页拿到 total,再计算总页数;--pages 0 表示抓全部,--pages 5 表示只抓前 5 页,方便调试。

数据库表 stock_spot 的唯一键是 (snapshot_date, code)。同一天重复运行会更新当天快照,不会重复插入;隔天运行会新增一天的数据。

完整代码

点击查看 2_2_stock.py 完整代码
# -*- coding: utf-8 -*-
"""第二次作业-2:东方财富网沪深 A 股实时行情爬虫。

目标:
    通过浏览器 F12 找到东方财富行情列表接口,用 requests 请求 JSON,
    解析后写入 SQLite 数据库 homework2.db,并导出 CSV。

运行:
    python 2_2_stock.py                 # 默认抓取前 5 页,每页 100 条
    python 2_2_stock.py --pages 0       # 抓取全部 A 股
    python 2_2_stock.py --pages 10      # 抓取前 10 页

接口关键参数:
    fs     市场/板块筛选条件
    fields 需要返回的字段编号,f12=代码、f14=名称、f2=最新价……
    pn/pz  页码/每页条数
    fid/po 排序字段/排序方向
"""

from __future__ import annotations

import argparse
import csv
import math
import sqlite3
import sys
import time
from datetime import date, datetime
from pathlib import Path
from typing import Any
from unicodedata import east_asian_width

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


# ---------------------------------------------------------------------------
# 1. 基础配置
# ---------------------------------------------------------------------------

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "homework2.db"
OUTPUT_DIR = BASE_DIR / "output"

API_URL = "https://push2.eastmoney.com/api/qt/clist/get"

# 沪深 A 股:深市主板、创业板、沪市主板、科创板。
MARKET_FILTER = "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"

# f12 代码, f14 名称, f2 最新价, f3 涨跌幅, f4 涨跌额, f5 成交量,
# f6 成交额, f7 振幅, f8 换手率, f9 市盈率, f10 量比, f15 最高,
# f16 最低, f17 今开, f18 昨收, f20 总市值, f21 流通市值, f23 市净率。
FIELDS = "f12,f14,f2,f3,f4,f5,f6,f7,f8,f9,f10,f15,f16,f17,f18,f20,f21,f23"

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/131.0.0.0 Safari/537.36"
    ),
    "Referer": "https://quote.eastmoney.com/center/gridlist.html",
    "Accept": "application/json, text/plain, */*",
}


# ---------------------------------------------------------------------------
# 2. 网络请求
# ---------------------------------------------------------------------------

def build_session() -> requests.Session:
    """创建带自动重试的 requests 会话。"""
    session = requests.Session()
    session.headers.update(HEADERS)

    retry = Retry(
        total=3,
        connect=3,
        read=3,
        backoff_factor=0.8,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset({"GET"}),
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session


def fetch_page(session: requests.Session, page: int, page_size: int = 100) -> dict[str, Any]:
    """请求一页股票列表,返回接口中的 data 字段。"""
    params = {
        "pn": page,              # 页码
        "pz": page_size,         # 每页条数
        "po": 0,                 # 0=升序,按代码排序,保证分页结果稳定
        "np": 1,
        "fltt": 2,               # 价格保留两位小数
        "invt": 2,
        "fid": "f12",            # 按代码排序
        "fs": MARKET_FILTER,
        "fields": FIELDS,
    }
    response = session.get(API_URL, params=params, timeout=20)
    response.raise_for_status()
    payload = response.json()

    if payload.get("rc") != 0:
        raise ValueError(f"接口返回异常:{payload}")
    data = payload.get("data")
    if not isinstance(data, dict):
        raise ValueError("接口 data 为空,可能被限流或参数失效")
    return data


# ---------------------------------------------------------------------------
# 3. 数据清洗
# ---------------------------------------------------------------------------

def to_float(value: Any) -> float | None:
    """把接口中的数字或 '-' 转成 float;'-' 表示停牌/未开盘/无数据。"""
    if value in (None, "", "-", "--"):
        return None
    try:
        return float(str(value).replace(",", ""))
    except (TypeError, ValueError):
        return None


def to_int(value: Any) -> int | None:
    """把成交量等字段转成整数。"""
    number = to_float(value)
    return int(number) if number is not None else None


def to_text(value: Any) -> str:
    """清理接口中的字符串字段。"""
    if value is None:
        return ""
    return str(value).replace("\u3000", " ").strip()


def normalize_stock(item: dict[str, Any], snapshot_date: str, fetched_at: str) -> dict[str, Any]:
    """把一行原始 JSON 映射成数据库字段。"""
    return {
        "snapshot_date": snapshot_date,
        "code": to_text(item.get("f12")),
        "name": to_text(item.get("f14")),
        "price": to_float(item.get("f2")),
        "change_pct": to_float(item.get("f3")),
        "change_amount": to_float(item.get("f4")),
        "volume": to_int(item.get("f5")),
        "amount": to_float(item.get("f6")),
        "amplitude": to_float(item.get("f7")),
        "turnover_rate": to_float(item.get("f8")),
        "pe": to_float(item.get("f9")),
        "volume_ratio": to_float(item.get("f10")),
        "high": to_float(item.get("f15")),
        "low": to_float(item.get("f16")),
        "open": to_float(item.get("f17")),
        "prev_close": to_float(item.get("f18")),
        "total_market_cap": to_float(item.get("f20")),
        "float_market_cap": to_float(item.get("f21")),
        "pb": to_float(item.get("f23")),
        "fetched_at": fetched_at,
    }


# ---------------------------------------------------------------------------
# 4. 数据库与文件导出
# ---------------------------------------------------------------------------

def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection:
    """连接 SQLite,并创建股票行情表。"""
    connection = sqlite3.connect(db_path)
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS stock_spot (
            id               INTEGER PRIMARY KEY AUTOINCREMENT,
            snapshot_date    TEXT NOT NULL,
            code             TEXT NOT NULL,
            name             TEXT NOT NULL,
            price            REAL,
            change_pct       REAL,
            change_amount    REAL,
            volume           INTEGER,
            amount           REAL,
            amplitude        REAL,
            turnover_rate    REAL,
            pe               REAL,
            volume_ratio     REAL,
            high             REAL,
            low              REAL,
            open             REAL,
            prev_close       REAL,
            total_market_cap REAL,
            float_market_cap REAL,
            pb               REAL,
            fetched_at       TEXT NOT NULL,
            UNIQUE (snapshot_date, code)
        )
        """
    )
    connection.commit()
    return connection


def save_stocks(connection: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
    """批量写入行情;同一快照日期内按股票代码更新。"""
    sql = """
        INSERT INTO stock_spot (
            snapshot_date, code, name, price, change_pct, change_amount,
            volume, amount, amplitude, turnover_rate, pe, volume_ratio,
            high, low, open, prev_close, total_market_cap,
            float_market_cap, pb, fetched_at
        ) VALUES (
            :snapshot_date, :code, :name, :price, :change_pct, :change_amount,
            :volume, :amount, :amplitude, :turnover_rate, :pe, :volume_ratio,
            :high, :low, :open, :prev_close, :total_market_cap,
            :float_market_cap, :pb, :fetched_at
        )
        ON CONFLICT(snapshot_date, code) DO UPDATE SET
            name             = excluded.name,
            price            = excluded.price,
            change_pct       = excluded.change_pct,
            change_amount    = excluded.change_amount,
            volume           = excluded.volume,
            amount           = excluded.amount,
            amplitude        = excluded.amplitude,
            turnover_rate    = excluded.turnover_rate,
            pe               = excluded.pe,
            volume_ratio     = excluded.volume_ratio,
            high             = excluded.high,
            low              = excluded.low,
            open             = excluded.open,
            prev_close       = excluded.prev_close,
            total_market_cap = excluded.total_market_cap,
            float_market_cap = excluded.float_market_cap,
            pb               = excluded.pb,
            fetched_at       = excluded.fetched_at
    """
    connection.executemany(sql, rows)
    connection.commit()


def export_csv(connection: sqlite3.Connection, snapshot_date: str, csv_path: Path) -> int:
    """导出指定快照日期的全部行情到 CSV。"""
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    cursor = connection.execute(
        """
        SELECT code, name, price, change_pct, change_amount, volume, amount,
               amplitude, turnover_rate, pe, volume_ratio, high, low, open,
               prev_close, total_market_cap, float_market_cap, pb, fetched_at
        FROM stock_spot
        WHERE snapshot_date = ?
        ORDER BY code
        """,
        (snapshot_date,),
    )
    rows = cursor.fetchall()
    headers = [
        "代码", "名称", "最新价", "涨跌幅(%)", "涨跌额", "成交量(手)",
        "成交额(元)", "振幅(%)", "换手率(%)", "市盈率", "量比", "最高",
        "最低", "今开", "昨收", "总市值(元)", "流通市值(元)", "市净率", "抓取时间",
    ]
    with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
        writer = csv.writer(file)
        writer.writerow(headers)
        writer.writerows(rows)
    return len(rows)


# ---------------------------------------------------------------------------
# 5. 主流程
# ---------------------------------------------------------------------------

def pad(text: str, width: int) -> str:
    """按终端显示宽度左对齐,避免中文占两个字符导致排版错位。"""
    text = str(text)
    used = sum(2 if east_asian_width(char) in "WF" else 1 for char in text)
    return text + " " * max(0, width - used)


def print_preview(rows: list[dict[str, Any]]) -> None:
    """按总市值取前 15 只打印,便于检查字段和截图。"""
    preview = sorted(
        rows,
        key=lambda row: row["total_market_cap"] or 0,
        reverse=True,
    )[:15]

    print("\n总市值前 15 只(* 表示最新价暂无,展示昨收)")
    print("代码    名称            最新价   涨跌幅    成交额(亿)  总市值(亿)")
    print("-" * 76)
    for row in preview:
        amount = row["amount"] / 1e8 if row["amount"] is not None else None
        market_cap = (
            row["total_market_cap"] / 1e8
            if row["total_market_cap"] is not None
            else None
        )
        change = (
            f"{row['change_pct']:+.2f}%"
            if row["change_pct"] is not None
            else "--"
        )
        if row["price"] is not None:
            price = f"{row['price']:.2f}"
        elif row["prev_close"] is not None:
            price = f"{row['prev_close']:.2f}*"
        else:
            price = "--"
        print(
            f"{row['code']:<8}{pad(row['name'], 14)}"
            f"{price:>9}  {change:>8}  "
            f"{(f'{amount:,.2f}' if amount is not None else '--'):>10}  "
            f"{(f'{market_cap:,.0f}' if market_cap is not None else '--'):>10}"
        )


def main() -> int:
    parser = argparse.ArgumentParser(description="抓取东方财富沪深 A 股行情")
    parser.add_argument(
        "--pages",
        type=int,
        default=5,
        help="抓取页数;0 表示抓取全部(默认 5 页,每页 100 条)",
    )
    parser.add_argument(
        "--page-size",
        type=int,
        default=100,
        help="每页条数(默认 100)",
    )
    parser.add_argument(
        "--delay",
        type=float,
        default=0.25,
        help="翻页间隔秒数(默认 0.25,避免请求过快)",
    )
    parser.add_argument(
        "--db",
        type=Path,
        default=DB_PATH,
        help=f"SQLite 数据库路径(默认:{DB_PATH.name})",
    )
    args = parser.parse_args()

    if args.pages < 0:
        parser.error("--pages 不能小于 0")
    if not 1 <= args.page_size <= 100:
        parser.error("--page-size 必须在 1~100 之间")

    snapshot_date = date.today().isoformat()
    fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    session = build_session()
    connection = get_connection(args.db)
    all_rows: list[dict[str, Any]] = []

    try:
        first_page = fetch_page(session, 1, args.page_size)
        total = int(first_page.get("total") or 0)
        total_pages = math.ceil(total / args.page_size) if total else 0
        pages_to_fetch = total_pages if args.pages == 0 else min(args.pages, total_pages)

        print(f"接口返回股票总数:{total},共 {total_pages} 页")
        print(f"本次抓取:{pages_to_fetch} 页(每页 {args.page_size} 条)")

        for page in range(1, pages_to_fetch + 1):
            data = first_page if page == 1 else fetch_page(session, page, args.page_size)
            items = data.get("diff") or []
            if not items:
                print(f"第 {page} 页没有数据,提前结束")
                break

            rows = [normalize_stock(item, snapshot_date, fetched_at) for item in items]
            rows = [row for row in rows if row["code"] and row["name"]]
            save_stocks(connection, rows)
            all_rows.extend(rows)
            print(
                f"  第 {page:>2}/{pages_to_fetch} 页:"
                f"解析 {len(rows):>3} 条,累计 {len(all_rows):>4} 条"
            )
            if page < pages_to_fetch:
                time.sleep(args.delay)

        if not all_rows:
            print("没有抓到任何股票数据。", file=sys.stderr)
            return 1

        print_preview(all_rows)
        csv_path = OUTPUT_DIR / f"stock_spot_{snapshot_date}.csv"
        exported = export_csv(connection, snapshot_date, csv_path)

        with connection:
            db_total = connection.execute(
                "SELECT COUNT(*) FROM stock_spot WHERE snapshot_date = ?",
                (snapshot_date,),
            ).fetchone()[0]

        print("-" * 76)
        print(f"本次写入/更新:{len(all_rows)} 条;数据库当日记录:{db_total} 条")
        print(f"数据库:{args.db}")
        print(f"CSV   :{csv_path}({exported} 条)")
        if any(row["price"] is None for row in all_rows):
            print("提示:部分股票最新价为 --,通常是未开盘、停牌或接口暂无行情。")
        return 0
    except (requests.RequestException, ValueError) as exc:
        print(f"抓取失败:{exc}", file=sys.stderr)
        return 1
    finally:
        connection.close()


if __name__ == "__main__":
    sys.exit(main())

运行结果

执行:

python 2_2_stock.py --pages 0

控制台会显示总页数、每一页的进度,最后按总市值展示前 15 只股票:

接口返回股票总数:5563,共 56 页
本次抓取:56 页(每页 100 条)
  第  1/56 页:解析 100 条,累计  100 条
  ...
  第 56/56 页:解析  63 条,累计 5563 条

以下为截图:
行情列表接口:

7ce088e9-3d8d-42a7-b8c4-5b3847e8d243
Headers 完整 URL:
9e0cfcd6-f096-4ef9-bc66-750c56a1d9d2
运行结果:
image
数据库:
6488840a-61c5-473a-ab68-696f2963d81b
如果运行时间在开盘前,f2 会是 -,代码会存成 NULL,终端用 * 显示昨收;开盘后重新运行就能得到最新价。这一点在调试时很容易误以为是代码写错了。

这一题的关键点

  1. 股票列表是 JSONP/JSON 接口返回的,不需要解析 HTML;
  2. 先请求第 1 页拿总数,再循环翻页,避免死循环;
  3. "-" 不能直接转 float,要统一转成 None;
  4. 每次请求加 time.sleep,控制频率;
  5. 入库用“快照日期 + 代码”做唯一键,保证幂等。

1.3 作业③:软科中国大学排名 2021 主榜

任务要求

爬取软科中国大学 2021 主榜所有院校信息,保存到数据库,并把浏览器 F12 调试分析过程录成 GIF 放进博客。

F12 抓包过程

  1. 打开 https://www.shanghairanking.cn/rankings/bcur/2021(注意最后是 2021,不要多打一个 1);
  2. F12 → Network → 选“全部”,勾选 Disable cache 和 Preserve log;
  3. 按 Ctrl+R 刷新。首屏是 Nuxt 服务端渲染(SSR),Fetch/XHR 里可能只有 collect、list 之类的统计请求,不一定能看到 bcur,这是正常的;
  4. 这时不要一直刷新,改用下面两种方式之一,主动请求接口:

方式 A:新标签页直接打开接口(不用写代码)

  1. 新开一个标签页;
  2. 先按 F12,切到 Network,筛选栏选“全部”;
  3. 在地址栏输入:
https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021
  1. 回车后,Network 里会出现一条 bcur?bcur_type=11&year=2021,点 Preview 或 Response 就能看到 JSON。

如果先打开了接口才按 F12,请求已经结束,按 Ctrl+R 刷新一次即可。

也可以不新开标签页:保持当前 F12 打开,直接把地址栏改成接口 URL 回车。F12 会跟着当前标签页走,Network 里同样会出现 bcur;看完点浏览器“后退”就回到排名页。

方式 B:在 Console 里 fetch(可选,熟悉代码再用)

fetch('/api/pub/v1/bcur?bcur_type=11&year=2021')
  .then((response) => response.json())
  .then((data) => console.log(data.data.rankings.length, data.data.rankings[0]));

执行后,Network 的 Fetch/XHR 里也会出现 bcur 请求。不会写代码的话,用方式 A 就够了。

不管用哪种方式,Preview 展开 data → rankings,单条结构如下(共 582 条):

{
  "ranking": "1",
  "univNameCn": "清华大学",
  "province": "北京",
  "univCategory": "综合",
  "score": 969.2,
  "univTags": ["双一流", "985", "211"]
}

这里和 2020 年的旧页面不太一样:旧页面可能是在 payload.js 里放数据;2021 页面是 Nuxt SSR,首屏数据可能已经由服务端渲染好,所以 Network 里不一定会出现接口请求。真正的数据接口是 api/pub/v1/bcur,一次返回全部 582 条,不需要翻页;bcur_type=11 就是“中国大学排名(主榜)”。用 Console fetch 或直接打开接口,就能稳定复现。

数据库设计

表名 university_ranking:

字段 含义
year 年份
rank 排名
school 学校名称
province 省市
category 类型
score 总分
tags 双一流/985/211 等标签
english_name 英文名
fetched_at 抓取时间

唯一键是 (year, school),同一年重复运行只更新。

完整代码

点击查看 2_3_ranking.py 完整代码
# -*- coding: utf-8 -*-
"""第二次作业-3:软科中国大学排名(2021 主榜)爬虫。

目标:
    抓取软科中国大学排名 2021 主榜全部院校,字段包括排名、学校、
    省市、类型、总分,写入 SQLite 数据库 homework2.db,并导出 CSV。

运行:
    python 2_3_ranking.py
    python 2_3_ranking.py --year 2021

接口来源:
    浏览器 F12 -> Network -> Fetch/XHR,页面加载时请求
    https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021
    bcur_type=11 表示“中国大学排名(主榜)”。
"""

from __future__ import annotations

import argparse
import csv
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from unicodedata import east_asian_width

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry


# ---------------------------------------------------------------------------
# 1. 基础配置
# ---------------------------------------------------------------------------

BASE_DIR = Path(__file__).resolve().parent
DB_PATH = BASE_DIR / "homework2.db"
OUTPUT_DIR = BASE_DIR / "output"

API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
BCUR_TYPE_MAIN = "11"          # 11 = 中国大学排名主榜
DEFAULT_YEAR = 2021

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/131.0.0.0 Safari/537.36"
    ),
    "Accept": "application/json, text/plain, */*",
    "Referer": "https://www.shanghairanking.cn/rankings/bcur/2021",
}


# ---------------------------------------------------------------------------
# 2. 网络请求
# ---------------------------------------------------------------------------

def build_session() -> requests.Session:
    """创建带自动重试的 requests 会话。"""
    session = requests.Session()
    session.headers.update(HEADERS)

    retry = Retry(
        total=3,
        connect=3,
        read=3,
        backoff_factor=0.8,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset({"GET"}),
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount("https://", adapter)
    session.mount("http://", adapter)
    return session


def fetch_rankings(session: requests.Session, year: int) -> list[dict[str, Any]]:
    """请求排名接口,返回 data.rankings 列表。"""
    params = {"bcur_type": BCUR_TYPE_MAIN, "year": year}
    response = session.get(API_URL, params=params, timeout=30)
    response.raise_for_status()
    payload = response.json()

    if payload.get("code") != 200:
        raise ValueError(f"接口返回异常:code={payload.get('code')}, msg={payload.get('msg')}")

    data = payload.get("data") or {}
    rankings = data.get("rankings")
    if not isinstance(rankings, list) or not rankings:
        raise ValueError("接口中没有 rankings 数据,参数或网站结构可能已变化")
    return rankings


# ---------------------------------------------------------------------------
# 3. 数据清洗
# ---------------------------------------------------------------------------

def to_text(value: Any) -> str:
    """把 None 或其他类型统一成去空白字符串。"""
    if value is None:
        return ""
    return str(value).replace("\u3000", " ").strip()


def to_float(value: Any) -> float | None:
    """把总分转成 float;'--'、空值等返回 None。"""
    if value in (None, "", "-", "--"):
        return None
    try:
        return float(str(value).replace(",", ""))
    except (TypeError, ValueError):
        return None


def normalize_ranking(item: dict[str, Any], year: int, fetched_at: str) -> dict[str, Any]:
    """把一条排名 JSON 映射成数据库字段。"""
    tags = item.get("univTags") or []
    if not isinstance(tags, list):
        tags = [str(tags)]
    return {
        "year": year,
        "rank": to_text(item.get("ranking") or item.get("rankOverall")),
        "school": to_text(item.get("univNameCn")),
        "province": to_text(item.get("province")),
        "category": to_text(item.get("univCategory")),
        "score": to_float(item.get("score")),
        "tags": "/".join(to_text(tag) for tag in tags if to_text(tag)),
        "english_name": to_text(item.get("univNameEn")),
        "fetched_at": fetched_at,
    }


# ---------------------------------------------------------------------------
# 4. 数据库与文件导出
# ---------------------------------------------------------------------------

def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection:
    """连接 SQLite,并创建大学排名表。"""
    connection = sqlite3.connect(db_path)
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS university_ranking (
            id           INTEGER PRIMARY KEY AUTOINCREMENT,
            year         INTEGER NOT NULL,
            rank         TEXT,
            school       TEXT NOT NULL,
            province     TEXT,
            category     TEXT,
            score        REAL,
            tags         TEXT,
            english_name TEXT,
            fetched_at   TEXT NOT NULL,
            UNIQUE (year, school)
        )
        """
    )
    connection.commit()
    return connection


def save_rankings(connection: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
    """批量写入排名;同一年份内按学校名更新,避免重复。"""
    sql = """
        INSERT INTO university_ranking (
            year, rank, school, province, category, score,
            tags, english_name, fetched_at
        ) VALUES (
            :year, :rank, :school, :province, :category, :score,
            :tags, :english_name, :fetched_at
        )
        ON CONFLICT(year, school) DO UPDATE SET
            rank         = excluded.rank,
            province     = excluded.province,
            category     = excluded.category,
            score        = excluded.score,
            tags         = excluded.tags,
            english_name = excluded.english_name,
            fetched_at   = excluded.fetched_at
    """
    connection.executemany(sql, rows)
    connection.commit()


def export_csv(connection: sqlite3.Connection, year: int, csv_path: Path) -> int:
    """导出指定年份排名到 CSV。"""
    csv_path.parent.mkdir(parents=True, exist_ok=True)
    cursor = connection.execute(
        """
        SELECT rank, school, province, category, score, tags, english_name
        FROM university_ranking
        WHERE year = ?
        ORDER BY CAST(rank AS INTEGER), school
        """,
        (year,),
    )
    rows = cursor.fetchall()
    headers = ["排名", "学校", "省市", "类型", "总分", "标签", "英文名"]
    with csv_path.open("w", newline="", encoding="utf-8-sig") as file:
        writer = csv.writer(file)
        writer.writerow(headers)
        writer.writerows(rows)
    return len(rows)


# ---------------------------------------------------------------------------
# 5. 主流程
# ---------------------------------------------------------------------------

def print_preview(rows: list[dict[str, Any]], limit: int = 20) -> None:
    """打印前若干名和兰州大学,便于对照网页结果。"""
    print("\n排名  学校                    省市    类型    总分")
    print("-" * 62)
    for row in rows[:limit]:
        score = f"{row['score']:.1f}" if row["score"] is not None else "--"
        print(
            f"{pad(row['rank'], 5)} {pad(row['school'], 24)} "
            f"{pad(row['province'], 8)} {pad(row['category'], 8)} {score:>6}"
        )

    target = next((row for row in rows if row["school"] == "兰州大学"), None)
    if target is not None:
        score = f"{target['score']:.1f}" if target["score"] is not None else "--"
        print("-" * 62)
        print(
            f"定位:第 {target['rank']} 名  {target['school']}  "
            f"{target['province']}  {target['category']}  {score}"
        )


def pad(text: str, width: int) -> str:
    """按终端显示宽度左对齐,避免中文占两个字符导致排版错位。"""
    text = str(text)
    used = sum(2 if east_asian_width(char) in "WF" else 1 for char in text)
    return text + " " * max(0, width - used)


def main() -> int:
    parser = argparse.ArgumentParser(description="抓取软科中国大学排名 2021 主榜")
    parser.add_argument(
        "--year",
        type=int,
        default=DEFAULT_YEAR,
        help=f"排名年份(默认 {DEFAULT_YEAR})",
    )
    parser.add_argument(
        "--db",
        type=Path,
        default=DB_PATH,
        help=f"SQLite 数据库路径(默认:{DB_PATH.name})",
    )
    args = parser.parse_args()

    fetched_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    session = build_session()
    connection = get_connection(args.db)

    try:
        print(f"正在请求软科 {args.year} 中国大学排名主榜接口……")
        raw_rankings = fetch_rankings(session, args.year)
        print(f"接口返回原始记录:{len(raw_rankings)} 条")

        rows = [
            normalize_ranking(item, args.year, fetched_at)
            for item in raw_rankings
        ]
        rows = [row for row in rows if row["school"]]
        save_rankings(connection, rows)

        print_preview(rows)
        csv_path = OUTPUT_DIR / f"university_ranking_{args.year}.csv"
        exported = export_csv(connection, args.year, csv_path)

        with connection:
            db_total = connection.execute(
                "SELECT COUNT(*) FROM university_ranking WHERE year = ?",
                (args.year,),
            ).fetchone()[0]

        print("-" * 62)
        print(f"本次写入/更新:{len(rows)} 条;数据库 {args.year} 年记录:{db_total} 条")
        print(f"数据库:{args.db}")
        print(f"CSV   :{csv_path}({exported} 条)")
        return 0
    except (requests.RequestException, ValueError) as exc:
        print(f"抓取失败:{exc}", file=sys.stderr)
        return 1
    finally:
        connection.close()


if __name__ == "__main__":
    sys.exit(main())

运行结果

执行:

python 2_3_ranking.py

终端输出:

正在请求软科 2021 中国大学排名主榜接口……
接口返回原始记录:582 条
排名  学校                    省市    类型    总分
--------------------------------------------------------------
1     清华大学                北京    综合   969.2
2     北京大学                北京    综合   855.3
3     浙江大学                浙江    综合   768.7
...
定位:第 39 名  兰州大学  甘肃  综合  353.4
--------------------------------------------------------------
本次写入/更新:582 条;数据库 2021 年记录:582 条

前 10 名:

排名 学校 省市 类型 总分
1 清华大学 北京 综合 969.2
2 北京大学 北京 综合 855.3
3 浙江大学 浙江 综合 768.7
4 上海交通大学 上海 综合 723.4
5 南京大学 江苏 综合 654.8
6 复旦大学 上海 综合 649.7
7 中国科学技术大学 安徽 理工 577.0
8 华中科技大学 湖北 综合 574.3
9 武汉大学 湖北 综合 567.9
10 西安交通大学 陕西 综合 537.9

以下为截图:
F12 截图:

image
F12分析过程:
ranking_f12
运行结果:
image
数据库截图:
00f6d21e-33f2-4a9d-9843-e79466540273
这一题的关键点

  1. 排名页不一定把数据写在 HTML 里,优先看 XHR;
  2. 接口一次返回全部数据时,不需要模拟翻页,直接遍历 rankings;
  3. 排名字段是字符串,不能直接按数字排序,导出 CSV 时用 CAST(rank AS INTEGER);
  4. 要保留 univTags,它能体现“双一流/985/211”,是排名的有用补充。

二、心得体会

2.1 先找数据,再写代码

这次最大的感受是:写代码之前,先确认数据在哪里。第一题的数据在 HTML 里,第二题和第三题在 JSON 接口里。如果一开始就急着用 BeautifulSoup 解析整个页面,股票和排名这两题会走很多弯路。

F12 的 Network 面板比 Elements 更值得先看。尤其要勾选 Preserve log,否则页面一跳转,之前的请求就没了;再配合 Disable cache,可以避免拿到缓存,误以为接口没有触发。

这次还踩了一个坑:东方财富页面明明有行情数据,Fetch/XHR 里却只有几个无关请求。原因是它用 jQuery JSONP(dataType: "jsonp"、cb=?),Chrome 把请求归到了 JS/Script。所以找接口时不要只盯 Fetch/XHR,先切到“全部”,再按关键词过滤;请求名也可能被截成 get?np=1...,要点开 Headers 看完整 URL。

2.2 请求头、编码和异常处理

三个网站都对 User-Agent 有要求。代码里统一加了浏览器 UA,股票和排名接口还加了 Referer。天气页面则是编码问题:响应头写 ISO-8859-1,实际是 UTF-8,必须手动解码。

另外,requests 默认不会重试。我给会话挂了一个 Retry,对 429、5xx 和连接错误重试 3 次。这样偶尔网络抖动时不用从头再跑。

2.3 JSON 里的 "-" 很坑

股票接口在开盘前、停牌时会返回 "-",直接 float("-") 会报错。我写了 to_float() 统一处理:"-"、""、None 都转成 None,数据库里存 NULL。终端展示时再用 * 标出“这是昨收,不是最新价”。

这让我意识到,爬虫代码的一大半工作量在数据清洗,而不是发请求。字段类型、空值、单位(元/亿元、手/股)都要提前想清楚。

2.4 入库要考虑“重复运行”

三个脚本都用了 UNIQUE 约束 + ON CONFLICT ... DO UPDATE:

  • 天气:(城市编码, 预报日期);
  • 股票:(快照日期, 股票代码);
  • 排名:(年份, 学校)。

这样调试时反复运行不会把数据库撑大,也不会出现重复记录。以前我会直接 INSERT,跑几次就有重复数据,后面查询很麻烦。

2.5 对“接口爬虫”的理解

第二题和第三题让我体会到,很多看起来复杂的网页,数据其实来自一个很普通的 JSON 接口。只要在 F12 里找到它,分析清楚参数,爬虫就变成了“发请求 → 取字段 → 入库”,比解析 HTML 稳定得多。

但接口也有风险:参数可能变、字段可能删、频率高了会被限流。所以代码里保留了完整的字段映射和异常提示,网站改版时能较快定位问题。这次作业的代码不是一次写成的,而是先用 pz=5 小样本调通,再改成一页 100 条、最后抓全部。

2.6 不足与改进

  1. 股票数据只保存了抓取时刻的快照,没有做历史行情的定时采集;
  2. 没有使用代理池,访问频率高时仍可能被限制;
  3. 大学排名接口如果以后改成需要签名参数,当前代码需要跟着调整;
  4. 博客里的 GIF 和截图要自己录、自己截,不能直接拿别人的图,否则既学不到东西,也有抄袭风险。

总的来说,这次作业让我把“浏览器里能看到的数据”变成了“数据库里能查询的数据”,也让我更熟悉 F12 和 SQLite。代码量不大,但每一步都能看到结果,比较有成就感。


posted @ 2026-11-07 11:46  VioletQAQ  阅读(11)  评论(0)    收藏  举报