大数据采集与融合技术实验2

实验2:用 requests 和 json 解析方法爬取股票信息

这次实验的核心是不解析 HTML,直接抓接口的 JSON:股票、天气、院校排名这三类数据,
页面上的表格都是 JavaScript 异步渲染出来的,用 requests 拿到的那份 HTML 里根本没有数据。
所以整个实验的关键动作是同一个——按 F12 打开开发者工具,在 Network 面板里刷新页面,
找到真正返回数据的那条请求,把它的 URL 和参数抄下来,然后用 requests 请求它、用 json 解析它。

实验环境:Python 3.12、requests 2.32、BeautifulSoup 4.13、SQLite(Python 内置 sqlite3)。
源码都放在 Gitee 上,按小题分了文件夹,每个脚本可以独立运行。

作业2/
├── 2.py            实验2   东方财富行情,打印到屏幕
├── 2.1/2.1.py      实验2.1 中国气象网 7 日预报 → weather.db
├── 2.2/2.2.py      实验2.2 股票行情 → stock.db
├── 2.3/2.3.py      实验2.3 软科 2021 主榜全部院校 → ranking.db
└── 查看数据库.py    把三个库的表结构和数据打印出来

一、东方财富行情接口的抓包与 json 解析

(1)抓包定位接口,json 解析实现

打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board,这个页面右键"查看网页源代码"是看不到股票数据的,
表格全部由 JavaScript 请求接口后填充。F12 → 网络(Network) → 勾选 Fetch/XHR → 刷新页面,
列表里就会出现 push2.eastmoney.com/api/qt/clist/get 这条请求,响应体是一段纯 JSON。

把这条请求的 URL 拆开看,参数各有用处:

参数 作用
fs 市场板块,m:0+t:6 是深市主板、m:1+t:2 是沪市主板,拼起来是全部 A 股
fields 要返回的字段,原始 URL 里有三十多个,删到只留 8 个
pn / pz 第几页、每页多少条
fid + po 排序字段和方向,fid=f3&po=1 就是按涨跌幅降序
ut 前端硬编码的固定 token,抄下来即可
np 设成 1 返回纯 JSON,否则会套一层 JSONP 回调

字段名都是 f 加数字,含义要靠对照:f2 最新价、f3 涨跌幅、f4 涨跌额、f5 成交量(手)、
f6 成交额(元)、f8 换手率、f12 代码、f14 名称。所以代码里只要一次 response.json() 就能拿到全部数据,
不用再写一堆 find/select。

# -*- coding: utf-8 -*-
"""实验2:用 requests + json 解析方法爬取东方财富网股票行情信息。

数据来源:东方财富网行情中心 A 股列表
( https://quote.eastmoney.com/center/gridlist.html#hs_a_board )
该页面的表格由 JavaScript 异步渲染,直接请求页面拿不到数据;
按课件提示用浏览器 F12 抓包,可以看到真正的数据接口是
https://push2.eastmoney.com/api/qt/clist/get ,它返回的是 JSON,
因此这里用 requests 请求接口 + json 解析,不再需要解析 HTML。
"""

import sys
import unicodedata

import requests

# 抓包得到的行情列表接口(东方财富有多个镜像域名,主域名被限流时可换下一个)
API_TEMPLATE = "https://{host}/api/qt/clist/get"
API_HOSTS = ("push2.eastmoney.com", "33.push2.eastmoney.com", "82.push2.eastmoney.com")
# 接口要求携带的固定 token,直接照抄抓包 URL 里的 ut 参数
UT = "fa5fd1943c7b386f172d6893dbfba10b"
# 市场筛选条件:沪深主板、创业板、科创板、北交所的 A 股
MARKET_FS = (
    "m:0+t:6+f:!2,m:0+t:80+f:!2,"
    "m:1+t:2+f:!2,m:1+t:23+f:!2,"
    "m:0+t:81+s:262144+f:!2"
)
# 只保留需要的字段(抓包 URL 里有三十多个字段,多余的可以删掉):
# f12 代码、f14 名称、f2 最新价、f3 涨跌幅、f4 涨跌额、
# f5 成交量(手)、f6 成交额(元)、f8 换手率
FIELDS = "f2,f3,f4,f5,f6,f8,f12,f14"
# 请求头:带上 Referer 和 UA,否则接口可能返回空数据
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/",
}
# 单次请求的超时时间(秒)
TIMEOUT = 15
# 打印的表头
TITLES = ("序号", "代码", "名称", "最新价", "涨跌幅", "涨跌额", "成交量", "成交额", "换手率")


def fetch_stocks(page: int = 1, page_size: int = 20) -> dict:
    """请求一页股票行情数据。

    :param page: 页码,从 1 开始
    :param page_size: 每页条数
    :return: 接口返回的 data 部分,含 total(总数)和 diff(股票列表)
    """
    params = {
        "pn": page,          # 页码
        "pz": page_size,     # 每页条数
        "po": 1,             # 1 表示降序
        "np": 1,             # 返回格式:1 为纯 JSON(不要 JSONP 回调)
        "fltt": 2,           # 价格保留 2 位小数
        "invt": 2,
        "fid": "f3",         # 按涨跌幅排序
        "ut": UT,
        "fs": MARKET_FS,
        "fields": FIELDS,
    }
    last_error = None
    for host in API_HOSTS:
        try:
            response = requests.get(
                API_TEMPLATE.format(host=host), params=params, headers=HEADERS, timeout=TIMEOUT
            )
            response.raise_for_status()
            payload = response.json()  # 接口本身就是 JSON,直接解析
            if payload.get("rc") == 0:
                return payload.get("data") or {}
            last_error = RuntimeError(f"接口返回异常:{payload.get('msg')}")
        except (requests.RequestException, ValueError) as exc:
            last_error = exc
    raise RuntimeError(f"行情接口不可用:{last_error}")


def parse_stocks(data: dict) -> list:
    """把接口返回的字段映射成可读的记录。

    接口字段名是 f+数字,含义由东方财富前端约定:
    f2 最新价 / f3 涨跌幅 / f4 涨跌额 / f5 成交量 / f6 成交额
    / f8 换手率 / f12 代码 / f14 名称。停牌股的数值可能返回 "-"。

    :param data: fetch_stocks() 返回的 data
    :return: 每只股票一个 dict 的列表
    """
    rows = []
    for item in data.get("diff") or []:
        rows.append({
            "code": item.get("f12", "-"),
            "name": item.get("f14", "-"),
            "price": item.get("f2"),
            "change_pct": item.get("f3"),
            "change_amt": item.get("f4"),
            "volume": item.get("f5"),
            "amount": item.get("f6"),
            "turnover": item.get("f8"),
        })
    return rows


def fmt_num(value, suffix: str = "", digits: int = 2) -> str:
    """把数值格式化成字符串,停牌等缺失值统一显示为“-”。

    :param value: 原始数值
    :param suffix: 单位后缀,如 “%”
    :param digits: 保留的小数位数
    :return: 格式化后的字符串
    """
    if not isinstance(value, (int, float)):
        return "-"
    return f"{value:.{digits}f}{suffix}"


def fmt_amount(value) -> str:
    """把成交额(元)换算成“万 / 亿”单位,方便阅读。

    :param value: 成交额,单位元
    :return: 带单位的字符串
    """
    if not isinstance(value, (int, float)):
        return "-"
    if value >= 1e8:
        return f"{value / 1e8:.2f}亿"
    if value >= 1e4:
        return f"{value / 1e4:.1f}万"
    return f"{value:.0f}"


def display_width(text: str) -> int:
    """计算字符串在终端中占用的列数(中文等全角字符占 2 列)。

    :param text: 待计算的字符串
    :return: 显示宽度
    """
    return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)


def pad(text: str, width: int) -> str:
    """按显示宽度右侧补空格,让中英文混排的表格对齐。

    :param text: 单元格内容
    :param width: 该列宽度
    :return: 补齐后的字符串
    """
    return text + " " * max(0, width - display_width(text))


def print_table(rows: list) -> None:
    """按表格形式打印股票行情。

    :param rows: parse_stocks() 返回的记录列表
    """
    table = []
    for index, row in enumerate(rows, 1):
        table.append((
            str(index),
            row["code"],
            row["name"],
            fmt_num(row["price"]),
            fmt_num(row["change_pct"], "%"),
            fmt_num(row["change_amt"]),
            fmt_num(row["volume"], digits=0),
            fmt_amount(row["amount"]),
            fmt_num(row["turnover"], "%"),
        ))

    widths = [display_width(t) for t in TITLES]
    for row in table:  # 列宽取表头与数据行的最大值
        for i, cell in enumerate(row):
            widths[i] = max(widths[i], display_width(cell))

    print("  ".join(pad(t, widths[i]) for i, t in enumerate(TITLES)))
    print("-" * (sum(widths) + 2 * (len(widths) - 1)))
    for row in table:
        print("  ".join(pad(cell, widths[i]) for i, cell in enumerate(row)))


def main() -> None:
    """程序入口:请求接口 → 解析 JSON → 打印行情。"""
    print("正在请求东方财富行情接口……")
    try:
        data = fetch_stocks(page=1, page_size=20)
    except (requests.RequestException, ValueError, RuntimeError) as exc:
        message = str(exc)
        if "ProxyError" in message or "proxy" in message.lower():
            # 本机开了 *** 之类的代理软件时,push2.eastmoney.com 常被规则拦掉
            print("数据获取失败:东方财富行情接口 push2.eastmoney.com 被本机代理拦截。")
            print("处理办法:在代理软件里把 eastmoney.com 设为直连(DIRECT),")
            print("或者临时把代理切到“直连模式”,然后重新运行本脚本。")
        else:
            print(f"数据获取失败:{message}")
        sys.exit(1)

    rows = parse_stocks(data)
    if not rows:
        print("接口没有返回股票数据,可能参数已失效")
        sys.exit(1)

    print(f"沪深京 A 股共 {data.get('total')} 只,下面按涨跌幅排序显示第 1 页:\n")
    print_table(rows)
    print(f"\n本页 {len(rows)} 条,数据来源:东方财富网行情中心")


if __name__ == "__main__":
    main()

运行结果,抓到的总数是 5580 只,这里按涨跌幅降序显示第 1 页:

2-dongfang

(2)心得体会

这次最直接的感受是:抓包比解析 HTML 省事太多。之前爬网页要一层层找标签、处理空格和换行,
数据一改版就全废;接口返回的是结构化 JSON,字段稳定、类型清楚,代码量少一半。

过程中踩了两个坑。第一个是停牌股:f2 这些数值字段在停牌时返回的是字符串 "-",直接参与格式化会报错,
所以写了个 fmt_num 先判断类型再格式化。

另外中英文混排的表格对齐也折腾了一会儿。f-string 的 :<10 是按字符数补空格的,
而一个汉字在终端里占两列,所以"学校名称"和"北京"这种列会错位。后来用 unicodedata.east_asian_width
先算出每个字符串的显示宽度,再按宽度补齐空格,表格才真正对齐。


二、把抓到的数据写进 SQLite 数据库

(1)气象预报与股票行情的入库实现

这一部分要做两件事:气象网的 7 日预报、股票行情,都要落到数据库里。数据库用的是 Python 自带的 sqlite3,
不用装 MySQL,建库就是建一个 .db 文件,交作业时直接跟着代码一起提交。

实验 2.1 中国气象网 7 日天气预报。 城市代码可以从气象网的城市搜索里拿到,福州是 101230101,
把代码填进 http://www.weather.com.cn/weather/{code}.shtml 就得到对应城市的页面。
这个页面的数据是服务端直接渲染的,用 BeautifulSoup 解析 ul.t.clearfix 下的每个 li:
h1 是日期、p.wea 是天气现象、p.tem 里的 span 和 i 分别是最高温和最低温、
p.win 里 span 的 title 是风向、i 是风力等级。

# -*- coding: utf-8 -*-
"""实验2.1:爬取中国气象网给定城市的 7 日天气预报,并保存到 SQLite 数据库。

数据来源:中国气象网( http://www.weather.com.cn )
页面地址规则:http://www.weather.com.cn/weather/{城市代码}.shtml
城市代码可以在中国气象网的城市搜索里找到,如福州 101230101。
"""

import os
import sqlite3
import sys
from datetime import datetime

import requests
from bs4 import BeautifulSoup

# 数据库文件:放在脚本同目录下,随脚本一起提交
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weather.db")
# 页面地址模板
URL_TEMPLATE = "http://www.weather.com.cn/weather/{code}.shtml"
# 给定城市集合:城市名 -> 中国气象网城市代码
CITY_CODES = {
    "福州": "101230101",
    "厦门": "101230201",
    "北京": "101010100",
    "上海": "101020100",
    "广州": "101280101",
}
# 请求头,模拟浏览器
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"
    )
}
TIMEOUT = 15

# 建表语句:同一城市同一天只保留一条记录,方便重复运行
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS weather (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    city       TEXT NOT NULL,   -- 城市名
    city_code  TEXT NOT NULL,   -- 气象网城市代码
    date       TEXT NOT NULL,   -- 日期,如“31日(今天)”
    weather    TEXT,            -- 天气现象,如“多云转晴”
    temp_high  TEXT,            -- 最高气温(℃)
    temp_low   TEXT,            -- 最低气温(℃)
    wind       TEXT,            -- 风向
    wind_level TEXT,            -- 风力
    crawl_time TEXT,            -- 抓取时间
    UNIQUE (city, date)
)
"""


def fetch_html(url: str) -> str:
    """下载天气预报页面。

    :param url: 页面地址
    :return: 页面 HTML 文本
    """
    response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()
    response.encoding = "utf-8"  # 气象网页面是 utf-8,自动识别容易出错
    return response.text


def parse_forecast(html: str) -> list:
    """解析 7 日天气预报列表。

    页面结构:<ul class="t clearfix"> 下每个 <li> 是一天,
    <h1> 日期、<p class="wea"> 天气、<p class="tem"> 高低温、
    <p class="win"> 里 <em> 中的 <span title> 是风向、<i> 是风力。

    :param html: 页面 HTML 文本
    :return: 每天一条记录的列表
    """
    soup = BeautifulSoup(html, "lxml")
    ul = soup.find("ul", class_="t")
    if ul is None:
        raise RuntimeError("页面结构异常,没有找到 7 日预报列表")

    days = []
    for li in ul.find_all("li", recursive=False):
        date_tag = li.find("h1")
        wea_tag = li.find("p", class_="wea")
        tem_tag = li.find("p", class_="tem")
        if not (date_tag and wea_tag and tem_tag):
            continue

        # 高低温:<span>27</span>/<i>20℃</i>
        high_tag = tem_tag.find("span")
        low_tag = tem_tag.find("i")
        temp_high = high_tag.get_text(strip=True) if high_tag else ""
        temp_low = low_tag.get_text(strip=True).replace("℃", "") if low_tag else ""

        # 风向取 <em> 中两个 <span title>,风力取 <i>
        wind_tag = li.find("p", class_="win")
        winds, wind_level = [], ""
        if wind_tag:
            winds = [s.get("title", "") for s in wind_tag.find_all("span") if s.get("title")]
            level_tag = wind_tag.find("i")
            wind_level = level_tag.get_text(strip=True) if level_tag else ""

        days.append({
            "date": date_tag.get_text(strip=True),
            "weather": wea_tag.get_text(strip=True),
            "temp_high": temp_high,
            "temp_low": temp_low,
            "wind": "转".join(winds) if winds else "",
            "wind_level": wind_level,
        })
    return days


def save_to_db(city: str, city_code: str, days: list) -> None:
    """把某城市 7 日预报写入数据库。

    :param city: 城市名
    :param city_code: 城市代码
    :param days: parse_forecast() 返回的记录列表
    """
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute(CREATE_SQL)
        conn.executemany(
            """INSERT OR REPLACE INTO weather
               (city, city_code, date, weather, temp_high, temp_low, wind, wind_level, crawl_time)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            [
                (city, city_code, d["date"], d["weather"], d["temp_high"],
                 d["temp_low"], d["wind"], d["wind_level"], now)
                for d in days
            ],
        )


def print_forecast(city: str, days: list) -> None:
    """在屏幕上打印某城市的 7 日预报。

    :param city: 城市名
    :param days: 预报记录列表
    """
    print(f"\n【{city}】7 日天气预报")
    for d in days:
        temp = f"{d['temp_high']}~{d['temp_low']}℃"
        print(f"  {d['date']:<10}{d['weather']:<8}{temp:<10}{d['wind']} {d['wind_level']}")


def main() -> None:
    """程序入口:遍历城市集合,抓取并入库。"""
    print(f"开始抓取 {len(CITY_CODES)} 个城市的 7 日天气预报……")
    total = 0
    for city, code in CITY_CODES.items():
        url = URL_TEMPLATE.format(code=code)
        try:
            days = parse_forecast(fetch_html(url))
        except (requests.RequestException, RuntimeError) as exc:
            print(f"[{city}] 抓取失败:{exc}")
            continue
        save_to_db(city, code, days)
        print_forecast(city, days)
        total += len(days)

    with sqlite3.connect(DB_PATH) as conn:
        count = conn.execute("SELECT COUNT(*) FROM weather").fetchone()[0]
    print(f"\n本次写入 {total} 条记录,数据库 weather.db 中共 {count} 条记录")


if __name__ == "__main__":
    main()

五个城市各 7 天,一共 35 条记录写进 weather.db:

2

实验 2.2 股票行情入库。 这里换成了新浪财经的行情接口
(Market_Center.getHQNodeData),它返回一个 JSON 数组,参数 node=hs_a 表示沪深 A 股、
num=100 是每页条数、page 翻页,翻 5 页就是 500 条。

# -*- coding: utf-8 -*-
"""实验2.2:用 requests + json 定向爬取股票行情信息,并保存到 SQLite 数据库。

候选网站里选择新浪股票( http://finance.sina.com.cn/stock/ )。
按课件提示用 F12 抓包,可以看到行情列表页的数据由这个接口异步返回:

    https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/
        Market_Center.getHQNodeData?page=1&num=100&sort=changepercent&asc=0&node=hs_a

参数含义:
    page / num  分页,num 每页条数(最大 100)
    node        hs_a 表示沪深 A 股全部股票
    sort        排序字段,changepercent 为涨跌幅
    asc         0 表示降序
接口直接返回 JSON 数组,用 json 解析即可,不用再解析 HTML。
"""

import json
import os
import sqlite3
import sys
import time
from datetime import datetime

import requests

# 新浪行情列表接口
API_URL = (
    "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
    "Market_Center.getHQNodeData"
)
# 每页条数(接口上限 100)与最多抓取页数
PAGE_SIZE = 100
MAX_PAGES = 5
# 数据库文件:与脚本同目录
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stock.db")
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://finance.sina.com.cn/",
}
TIMEOUT = 15

CREATE_SQL = """
CREATE TABLE IF NOT EXISTS stock_quote (
    code       TEXT PRIMARY KEY,  -- 股票代码(6 位)
    name       TEXT NOT NULL,     -- 股票名称
    price      REAL,              -- 最新价
    change_pct REAL,              -- 涨跌幅(%)
    change_amt REAL,              -- 涨跌额
    volume     INTEGER,           -- 成交量(股)
    amount     REAL,              -- 成交额(元)
    turnover   REAL,              -- 换手率(%)
    crawl_time TEXT               -- 抓取时间
)
"""


def fetch_page(page: int, page_size: int = PAGE_SIZE) -> list:
    """抓取一页行情数据。

    :param page: 页码,从 1 开始
    :param page_size: 每页条数
    :return: 该页的股票记录列表(接口原始 JSON 数组)
    """
    params = {
        "page": page,
        "num": page_size,
        "sort": "symbol",         # 按代码排序,这样每页取到的是连续区间的股票
        "asc": 1,                 # 1 表示升序
        "node": "hs_a",           # 沪深 A 股
        "symbol": "",
        "_s_r_a": "page",
    }
    response = requests.get(API_URL, params=params, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()
    response.encoding = "utf-8"
    data = json.loads(response.text)  # 接口返回的就是 JSON 数组
    return data if isinstance(data, list) else []


def to_float(value) -> float:
    """把接口里的字符串数值转成 float,无法转换时返回 None。

    :param value: 原始值,如 "5.570"
    :return: 浮点数或 None
    """
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def parse_page(items: list) -> list:
    """把一页原始数据整理成入库记录。

    新浪字段:code 代码、name 名称、trade 最新价、changepercent 涨跌幅、
    pricechange 涨跌额、volume 成交量(股)、amount 成交额(元)、
    turnoverratio 换手率。

    :param items: fetch_page() 返回的原始列表
    :return: 每只股票一个 dict 的列表
    """
    rows = []
    for item in items:
        code = (item.get("code") or "").strip()
        if not code:
            continue
        rows.append({
            "code": code,
            "name": (item.get("name") or "").strip(),
            "price": to_float(item.get("trade")),
            "change_pct": to_float(item.get("changepercent")),
            "change_amt": to_float(item.get("pricechange")),
            "volume": int(to_float(item.get("volume")) or 0),
            "amount": to_float(item.get("amount")),
            "turnover": to_float(item.get("turnoverratio")),
        })
    return rows


def save_to_db(rows: list) -> int:
    """把股票记录写入数据库(代码相同则覆盖)。

    :param rows: parse_page() 返回的记录列表
    :return: 本次写入条数
    """
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute(CREATE_SQL)
        conn.executemany(
            """INSERT OR REPLACE INTO stock_quote
               (code, name, price, change_pct, change_amt, volume, amount, turnover, crawl_time)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            [
                (r["code"], r["name"], r["price"], r["change_pct"], r["change_amt"],
                 r["volume"], r["amount"], r["turnover"], now)
                for r in rows
            ],
        )
    return len(rows)


def print_summary(rows: list) -> None:
    """打印本批数据中涨幅前 5 名与涨跌家数统计。

    :param rows: 股票记录列表
    """
    valid = [r for r in rows if r["change_pct"] is not None]
    up = [r for r in valid if r["change_pct"] > 0]
    down = [r for r in valid if r["change_pct"] < 0]
    flat = len(valid) - len(up) - len(down)

    print(f"\n本批涨幅前 5 名:")
    print(f"{'代码':<10}{'名称':<12}{'最新价':<10}{'涨跌幅':<10}{'成交额':<12}换手率")
    print("-" * 70)
    for row in sorted(valid, key=lambda r: r["change_pct"], reverse=True)[:5]:
        amount = row["amount"] or 0
        amount_text = f"{amount / 1e8:.2f}亿" if amount >= 1e8 else f"{amount / 1e4:.1f}万"
        print(f"{row['code']:<10}{row['name']:<12}{row['price']:<10.2f}"
              f"{row['change_pct']:<10.2f}{amount_text:<12}{row['turnover']:.2f}%")

    print(f"\n统计:上涨 {len(up)} 只,下跌 {len(down)} 只,平盘 {flat} 只")


def main() -> None:
    """程序入口:逐页抓取 → 入库 → 打印摘要。"""
    print(f"开始抓取新浪财经 A 股行情,共 {MAX_PAGES} 页 × {PAGE_SIZE} 条……")
    all_rows = []
    for page in range(1, MAX_PAGES + 1):
        try:
            items = fetch_page(page)
        except (requests.RequestException, ValueError) as exc:
            print(f"[第 {page} 页] 抓取失败:{exc}")
            break
        if not items:
            print(f"[第 {page} 页] 没有数据,已抓完全部股票")
            break
        rows = parse_page(items)
        save_to_db(rows)
        all_rows.extend(rows)
        print(f"  第 {page} 页:{len(rows)} 条,已写入数据库")
        time.sleep(1)  # 适当停顿,避免请求过快

    if not all_rows:
        print("没有抓到任何数据,请检查网络或接口参数")
        sys.exit(1)

    print_summary(all_rows)
    with sqlite3.connect(DB_PATH) as conn:
        total = conn.execute("SELECT COUNT(*) FROM stock_quote").fetchone()[0]
    print(f"数据库 stock.db 中共 {total} 条股票记录")


if __name__ == "__main__":
    main()

500 条数据入库,涨 80 只、跌 411 只、平盘 9 只:

2

两个库建好之后可以用 python 查看数据库.py 一次把三个库的表结构和数据都打出来核对:

# -*- coding: utf-8 -*-
"""辅助脚本:一次性打印实验2 三个数据库的表结构和数据,方便截图写博客。

用法:在“作业2”目录下执行 python 查看数据库.py
(只是查库,不联网、不修改数据)
"""

import os
import sqlite3

# 脚本所在目录(作业2),三个 db 都在它的子目录里
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# (数据库相对路径, 表名, 打印几行, 需要显示的列)
TARGETS = [
    ("2.1/weather.db", "weather", 7,
     ("city", "date", "weather", "temp_high", "temp_low", "wind", "wind_level")),
    ("2.2/stock.db", "stock_quote", 6,
     ("code", "name", "price", "change_pct", "volume", "amount", "turnover")),
    ("2.3/ranking.db", "university_rank", 6,
     ("ranking", "name_cn", "province", "category", "score", "tags")),
]


def format_field_names(fields: list, width: int = 64) -> list:
    """把字段名拼成不超过指定宽度的若干行,避免一行太长不好截图。

    :param fields: PRAGMA table_info 的结果
    :param width: 每行最多多少字符
    :return: 折行后的字符串列表
    """
    names = [f[1] for f in fields if f[1] != "id"]
    lines, current = [], ""
    for name in names:
        piece = name if not current else ", " + name
        if len(current) + len(piece) > width:
            lines.append(current)
            current = name
        else:
            current += piece
    if current:
        lines.append(current)
    return lines


def show_db(rel_path: str, table: str, limit: int, columns: tuple) -> None:
    """打印一个数据库的表结构、总记录数和前若干行数据。

    :param rel_path: 相对“作业2”目录的数据库路径
    :param table: 表名
    :param limit: 打印多少行
    :param columns: 需要显示的列
    """
    db_path = os.path.join(BASE_DIR, rel_path)
    print("=" * 72)
    if not os.path.exists(db_path):
        print(f"{rel_path} 不存在,请先运行对应的爬虫脚本")
        return

    with sqlite3.connect(db_path) as conn:
        total = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
        print(f"{rel_path}  →  表 {table} 共 {total} 条记录")

        # 表结构:字段名(类型见源码建表语句)
        fields = conn.execute(f"PRAGMA table_info({table})").fetchall()
        lines = format_field_names(fields)
        print("表结构:" + lines[0])
        for line in lines[1:]:
            print("        " + line)

        # 数据预览
        sql = f"SELECT {', '.join(columns)} FROM {table} LIMIT {limit}"
        print("-" * 72)
        print(" | ".join(columns))
        for row in conn.execute(sql):
            print(" | ".join("" if v is None else str(v) for v in row))
    print()


def main() -> None:
    """依次打印三个数据库。"""
    for rel_path, table, limit, columns in TARGETS:
        show_db(rel_path, table, limit, columns)


if __name__ == "__main__":
    main()

db-preview

(2)心得体会

建表的时候我特意加了唯一约束:气象表用 UNIQUE(city, date),股票表用 code 作主键。
配合 INSERT OR REPLACE,脚本重复运行多少次都不会产生重复记录——这一点在做实验的时候很关键,
因为调代码难免要反复跑,如果每次都往里追加,最后统计出来的条数就完全不可信了。"可重复执行"应该是写爬虫脚本的默认要求。

另一个教训是关于排序参数的。2.2 最初我用 sort=changepercent&asc=0(按涨跌幅降序)抓前 5 页,
结果统计出来"上涨 500 只,下跌 0 只"——因为我抓的本来就是涨幅榜的前 500 名,这个统计毫无意义。
改成 sort=symbol&asc=1 按代码排序,抓的才是连续区间里的股票,涨跌分布才有代表性。
数据的统计口径要和取数方式对得上,不然容易得出荒谬的结论。

还有个小细节:东财的成交量单位是"手"(1 手 = 100 股),新浪的 volume 是"股"。
两边混用的话数值会差 100 倍,入库前必须统一,我在表结构注释里写清楚了单位。


三、软科 2021 中国大学排名全量抓取

(1)从 30 条到 582 条:接口分析

实验 2.3 要求爬 2021 主榜的所有院校。直接请求 https://www.shanghairanking.cn/rankings/bcur/2021
看起来是成功的,但用 BeautifulSoup 数一下 table 里的 tr,只有 31 行——表头 1 行加 30 所学校,
和页面上"共 582 所学校"对不上。也就是说这个页面的 HTML 只带了第一页的数据。

按 F12 抓包(这一步的过程我录了 Gif,见文末),刷新页面后在网络面板里能看到两种情况:
一条是 rankings/bcur/2021/payload.js(238 KB,页面预渲染用的数据包),
另一条是我最终采用的接口 api/pub/v1/bcur?bcur_type=11&year=2021。后者的参数很直白:
bcur_type=11 表示中国大学排名主榜,year=2021 是年份,返回的 data.rankings 数组里就是全部 582 条。

每条记录的字段:univNameCn 中文名、univNameEn 英文名、province 省市、univCategory 学校类型、
score 总分、ranking 排名、univTags 标签(双一流/985/211),另外 indData 里是各项细分指标的分数。
入库时我把标签用 / 拼成一个字符串,并列排名形如 "10-12" 的取前面的整数。

# -*- coding: utf-8 -*-
"""实验2.3:爬取软科 2021 中国大学排名(主榜)全部院校信息并存入数据库。

思路:排名页面 https://www.shanghairanking.cn/rankings/bcur/2021 的表格由
前端异步渲染,直接请求 HTML 只能拿到第一页 30 条。按课件提示用 F12 抓包,
可以发现页面真正请求的数据接口是
https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021 ,
它一次返回主榜全部院校的 JSON 数据,因此直接请求该接口即可。
"""

import json
import os
import sqlite3
import sys
from datetime import datetime

import requests

# 软科排名数据接口
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
# 请求参数:bcur_type=11 表示中国大学排名主榜,year=2021 表示 2021 年
API_PARAMS = {"bcur_type": 11, "year": 2021}
# 榜单年份,入库时一并保存
YEAR = 2021
# 数据库文件:与脚本同目录
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ranking.db")
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"
    )
}
TIMEOUT = 30

CREATE_SQL = """
CREATE TABLE IF NOT EXISTS university_rank (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    year       INTEGER NOT NULL,  -- 榜单年份
    ranking    INTEGER,           -- 排名
    name_cn    TEXT NOT NULL,     -- 学校中文名
    name_en    TEXT,              -- 学校英文名
    province   TEXT,              -- 省市
    category   TEXT,              -- 学校类型(综合/理工/师范……)
    score      REAL,              -- 总分
    tags       TEXT,              -- 标签,如“双一流/985/211”
    crawl_time TEXT,              -- 抓取时间
    UNIQUE (year, name_cn)
)
"""


def fetch_rankings() -> list:
    """请求接口并返回全部院校记录。

    :return: 接口 data.rankings 中的院校列表
    """
    response = requests.get(API_URL, params=API_PARAMS, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()
    payload = response.json()  # 接口直接返回 JSON,用 json 解析即可
    if payload.get("code") != 200:
        raise RuntimeError(f"接口返回异常:{payload.get('msg')}")
    rankings = (payload.get("data") or {}).get("rankings") or []
    if not rankings:
        raise RuntimeError("接口没有返回院校数据,参数可能已失效")
    return rankings


def parse_universities(rankings: list) -> list:
    """把接口数据整理成入库用的记录。

    :param rankings: fetch_rankings() 返回的院校列表
    :return: 每所院校一个 dict 的列表
    """
    rows = []
    for item in rankings:
        ranking = item.get("ranking")
        try:
            ranking = int(str(ranking).split("-")[0])  # 并列排名可能形如“10-12”
        except (TypeError, ValueError):
            ranking = None
        rows.append({
            "ranking": ranking,
            "name_cn": (item.get("univNameCn") or "").strip(),
            "name_en": (item.get("univNameEn") or "").strip(),
            "province": item.get("province") or "",
            "category": item.get("univCategory") or "",
            "score": item.get("score"),
            "tags": "/".join(item.get("univTags") or []),
        })
    return [row for row in rows if row["name_cn"]]


def save_to_db(rows: list) -> int:
    """把院校记录写入数据库。

    :param rows: parse_universities() 返回的记录列表
    :return: 本次写入的条数
    """
    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    with sqlite3.connect(DB_PATH) as conn:
        conn.execute(CREATE_SQL)
        conn.executemany(
            """INSERT OR REPLACE INTO university_rank
               (year, ranking, name_cn, name_en, province, category, score, tags, crawl_time)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            [
                (YEAR, r["ranking"], r["name_cn"], r["name_en"], r["province"],
                 r["category"], r["score"], r["tags"], now)
                for r in rows
            ],
        )
    return len(rows)


def print_summary(rows: list) -> None:
    """打印前 10 名和省份、类型分布,方便核对爬取结果。

    :param rows: 院校记录列表
    """
    print(f"\n{'排名':<6}{'学校名称':<14}{'省市':<8}{'类型':<6}{'总分':<8}标签")
    print("-" * 76)
    for row in sorted(rows, key=lambda r: r["ranking"] or 9999)[:10]:
        print(f"{row['ranking']:<8}{row['name_cn']:<14}{row['province']:<8}"
              f"{row['category']:<6}{row['score']:<8}{row['tags']}")

    provinces = {}
    categories = {}
    for row in rows:
        provinces[row["province"]] = provinces.get(row["province"], 0) + 1
        categories[row["category"]] = categories.get(row["category"], 0) + 1

    top_province = sorted(provinces.items(), key=lambda kv: kv[1], reverse=True)[:5]
    print("\n院校数量前 5 的省市:" + "、".join(f"{k}({v})" for k, v in top_province))
    print("按学校类型统计:" + "、".join(f"{k}({v})" for k, v in
                                  sorted(categories.items(), key=lambda kv: kv[1], reverse=True)))


def main() -> None:
    """程序入口:请求接口 → 解析 → 入库 → 打印摘要。"""
    print(f"正在请求软科 {YEAR} 中国大学排名(主榜)接口……")
    try:
        rankings = fetch_rankings()
    except (requests.RequestException, ValueError, RuntimeError) as exc:
        print(f"数据获取失败:{exc}")
        sys.exit(1)

    rows = parse_universities(rankings)
    count = save_to_db(rows)
    print(f"接口返回 {len(rankings)} 条,成功写入数据库 {count} 条")
    print_summary(rows)

    with sqlite3.connect(DB_PATH) as conn:
        total = conn.execute("SELECT COUNT(*) FROM university_rank WHERE year = ?", (YEAR,)).fetchone()[0]
    print(f"\n数据库 ranking.db 中 {YEAR} 年主榜共 {total} 条记录")


if __name__ == "__main__":
    main()

入库 582 条,前 10 名和省份分布如下:

2

(2)心得体会

这个实验让我搞清楚了"页面能打开"和"数据在页面里"是两回事。上一步的东财是纯前端渲染,
连表格都没有;软科这里是服务端渲染了第一页,看起来有数据,但只有 30 条,很容易误以为已经爬全了。
如果我没有去核对"共 582 所"这个数字,就会交一个只有 30 条的结果上去。爬完之后一定要用页面上的总数量核对一遍。

软科这个站还有个有意思的地方:页面实际加载的是 Nuxt 预渲染生成的 payload.js,而公开的 API 是另一条路。
两条路拿到的数据是一致的(都是 582 条),但接口返回的是干净的 JSON,字段名也更友好,所以我选了接口。
写论文式的爬虫时,能拿到官方 JSON 接口就不要去解析 HTML,这是最省事也最稳定的做法。

F12 抓包的过程我录成了 Gif,附在这里:

动画


附:源码仓库

全部代码、数据库文件和运行截图都在 Gitee 上(https://gitee.com/zzq-1419/shujucaiji),
按小题分了目录,每个脚本可以单独运行:

作业2/
├── 2.py                 实验2   东方财富行情
├── 2.1/2.1.py           实验2.1 气象预报入库
├── 2.2/2.2.py           实验2.2 股票行情入库
├── 2.3/2.3.py           实验2.3 软科排名入库
├── 查看数据库.py         数据库内容预览
└── screenshots/         运行截图与抓包 Gif

依赖只有 requests、beautifulsoup4、lxml,数据库用 Python 内置的 sqlite3,不需要额外安装。

posted on 2026-10-31 13:36  Tables  阅读(14)  评论(0)    收藏  举报