数据采集作业三

环境:Windows 11 + Python 3.13 + requests + beautifulsoup4 + SQLite
仓库结构:作业1/1.py、作业2/2.py、作业3/3.py,每个文件夹内附脚本运行后生成的 .db 数据库文件
Gitee 文件夹链接:作业14总目录

一、实验过程与代码实现

(一)实验环境、技术选型与数据库设计

1. 技术选型

环节 选型 原因
网络请求 requests 相比 urllib 更简洁,支持 params 字典自动拼接 URL,支持自定义请求头,方便应对 Referer 校验
HTML 解析 BeautifulSoup + html.parser 作业① 的气象网页面是服务端渲染的静态 HTML,DOM 结构清晰,用 CSS 选择器比正则更稳
数据解析 json(json.loads) 作业二、三的目标都是返回 JSON 的接口,直接解析字段比解析 HTML 可靠得多
数据存储 sqlite3(Python 内置) 免安装、免配置、单文件,随作业文件夹一起提交即可复现;SQL 语法与 MySQL 一致,换成 pymysql 只需改变连接方式

三份代码都遵循同一套工程结构:

常量配置区  →  网络请求函数  →  解析函数  →  建表/入库函数  →  展示函数  →  main()

所有函数都写了 docstring,关键行有行内注释;脚本末尾统一用 if __name__ == "__main__": main() 作为入口。

2. 数据库设计

三份作业各自使用一个独立的数据库文件,与脚本放在同一目录,避免互相干扰。

weather.db — weather_forecast(7 日天气预报)

字段 类型 说明
id INTEGER 自增主键
city_name / city_code TEXT 城市名 / 气象网城市编码
forecast_date TEXT 预报日期,如 9日(今天)
weather TEXT 天气现象
temp_high / temp_low INTEGER 最高温 / 最低温(℃),今天无最低温时为 NULL
wind_dir / wind_level TEXT 风向 / 风力
crawl_time TEXT 抓取时间

唯一索引建在 (city_code, forecast_date) 上,配合 INSERT OR REPLACE,重复运行只是刷新当天的预报,不会产生重复行。

stock.db — stock_quote(股票行情快照)

以 code(股票代码)为主键,共 19 个业务字段:名称、市场、最新价、涨跌幅、涨跌额、成交量、成交额、振幅、换手率、量比、市盈率、市净率、最高/最低/今开/昨收、总市值/流通市值、抓取时间。

ranking.db — university_rank(大学排名)

字段 类型 说明
id INTEGER 自增主键
rank_no INTEGER 排名(不唯一,存在并列名次)
univ_name TEXT UNIQUE 学校名称,作为唯一键
province / univ_category TEXT 省市 / 类型
total_score REAL 总分
crawl_time TEXT 抓取时间

这里有一个踩坑点,详见作业三。


(二)三个爬虫的实现过程与运行结果

作业一 中国气象网:5 个城市的 7 日天气预报

(1)目标与 URL 规律

中国气象网的天气页面地址形如:

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

其中 101010100 是城市编码(北京)。在首页搜索框输入城市名跳转后,地址栏里就能看到对应编码,因此"爬多个城市"本质上就是"替换 URL 中的这一段数字"。本次选取 5 个城市:

城市 编码 城市 编码
北京 101010100 成都 101270101
上海 101020100 西安 101110101
广州 101280101

(2)页面结构分析(F12 Elements)

用 F12 打开 Elements 面板,定位到未来 7 天的那一排卡片,可以发现 7 天的数据全部在同一个 <ul class="t clearfix"> 里,7 个 <li> 就是 7 天:

<ul class="t clearfix">
  <li>
    <h1>9日(今天)</h1>                     
    <p title="晴" class="wea">晴</p>          
    <p class="tem"><span>4℃</span>/<i>-3℃</i></p>  
    <p class="win"><em><span title="西南风"></span></em><i>&lt;3级</i></p> 
  </li>
  ...
</ul>

两个细节需要注意:

  • 今天这一天的温度只有一个 <i>(只有最高温),其它日期才是 最高/最低 两段,所以温度字段必须按 / 切分并允许最低温为空;
  • 风向不在文本里,而在 <span> 的 title 属性上,得用 tag.get("title") 取。

**(3)实现思路

爬取中国气象网指定城市(5 个)的 7 日天气预报,并保存到 SQLite 数据库。

  • 中国气象网的城市天气页 URL 形如 http://www.weather.com.cn/weather/101010100.shtml ,其中 101010100 是城市编码(北京),不同城市只需替换该编码。
  • 页面中 7 日预报位于<ul class="t clearfix">下的 7 个标签<li>内,使用 requests 获取源码、BeautifulSoup 解析 DOM,即可得到日期/天气/温度/风向/风力。
  • 解析结果写入 SQLite 表 weather_forecast,(city_code, forecast_date) 建唯一索引,重复运行同一城市同一天的数据会被覆盖,便于反复调试。

运行环境:Python 3.8+,依赖 requests、beautifulsoup4
pip install requests beautifulsoup4

(4)代码

# -*- coding: utf-8 -*-

import re
import sqlite3
import time
from datetime import datetime
from pathlib import Path

import requests
from bs4 import BeautifulSoup

# --------------------------------------------------------------------------- #
# 全局配置
# --------------------------------------------------------------------------- #

# 城市名 -> 中国气象网城市编码(编码可在页面搜索框中输入城市后从 URL 中获得)
CITY_CODES = {
    "北京": "101010100",
    "上海": "101020100",
    "广州": "101280101",
    "成都": "101270101",
    "西安": "101110101",
}

# 天气页地址模板,%s 处填入城市编码
WEATHER_URL = "http://www.weather.com.cn/weather/{}.shtml"

# 伪装成浏览器,避免被服务端直接拒绝
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    ),
    "Referer": "http://www.weather.com.cn/",
}

# 数据库文件与本脚本放在同一目录,便于随文件夹一起提交
DB_PATH = Path(__file__).with_name("weather.db")

# 每个城市请求之间的间隔(秒),控制访问频率,避免给对方服务器造成压力
REQUEST_INTERVAL = 1


# --------------------------------------------------------------------------- #
# 网络请求与解析
# --------------------------------------------------------------------------- #


def fetch_html(city_code: str) -> str:
    """请求指定城市编码的天气页面,返回解码后的 HTML 文本。

    :param city_code: 中国气象网城市编码,例如 "101010100"
    :return: 页面 HTML 源码
    """
    url = WEATHER_URL.format(city_code)
    # 气象网页面为 UTF-8 编码,requests 有时会按 ISO-8859-1 猜测,这里显式指定
    response = requests.get(url, headers=HEADERS, timeout=15)
    response.raise_for_status()
    response.encoding = "utf-8"
    return response.text


def _to_int(text: str) -> int | None:
    """从 "26℃"、" -3℃ " 这类字符串中提取整数温度,提取失败返回 None。

    :param text: 含温度数字的字符串
    :return: 整型温度值或 None
    """
    if not text:
        return None
    matched = re.search(r"-?\d+", text)
    return int(matched.group()) if matched else None


def parse_forecast(html: str, city_name: str, city_code: str) -> list[dict]:
    """解析天气页面,抽取未来 7 天的预报信息。

    页面结构(简化):
        <ul class="t clearfix">
            <li>
                <h1>9日(今天)</h1>
                <p class="wea">晴</p>
                <p class="tem"><span>4℃</span>/<i>-3℃</i></p>
                <p class="win"><em><span title="西南风"></span></em><i>&lt;3级</i></p>
            </li>
            ...
        </ul>

    :param html: 页面 HTML 源码
    :param city_name: 城市中文名,用于入库
    :param city_code: 城市编码,用于入库
    :return: 每个元素为一条 7 日预报记录的字典列表
    """
    soup = BeautifulSoup(html, "html.parser")
    week_list = soup.find("ul", class_="t clearfix")
    if week_list is None:
        # 页面结构变化或城市编码错误时,返回空列表由调用方处理
        return []

    records = []
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    for item in week_list.find_all("li", recursive=False):
        # 日期,例如 "9日(今天)"
        date_tag = item.find("h1")
        date_text = date_tag.get_text(strip=True) if date_tag else ""

        # 天气现象,例如 "晴" / "多云" / "小雨"
        weather_tag = item.find("p", class_="wea")
        weather = weather_tag.get_text(strip=True) if weather_tag else ""

        # 温度,今天只有最高温(单个 i),其余日期为 "最高温/最低温"
        tem_tag = item.find("p", class_="tem")
        tem_text = tem_tag.get_text(strip=True) if tem_tag else ""
        high_text, _, low_text = tem_text.partition("/")
        temp_high = _to_int(high_text)
        temp_low = _to_int(low_text) if low_text else None

        # 风向与风力:风向在 span 的 title 属性里,风力在 <i> 文本中
        wind_dir_tag = item.select_one("p.win em span")
        wind_dir = wind_dir_tag.get("title", "") if wind_dir_tag else ""
        wind_level_tag = item.select_one("p.win i")
        wind_level = wind_level_tag.get_text(strip=True) if wind_level_tag else ""

        records.append(
            {
                "city_name": city_name,
                "city_code": city_code,
                "forecast_date": date_text,
                "weather": weather,
                "temp_high": temp_high,
                "temp_low": temp_low,
                "wind_dir": wind_dir,
                "wind_level": wind_level,
                "crawl_time": crawl_time,
            }
        )

    return records


# --------------------------------------------------------------------------- #
# 数据库操作
# --------------------------------------------------------------------------- #


def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
    """创建数据库连接并建表(表已存在时不重复创建)。

    :param db_path: 数据库文件路径
    :return: sqlite3 连接对象
    """
    conn = sqlite3.connect(db_path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS weather_forecast (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            city_name     TEXT    NOT NULL,
            city_code     TEXT    NOT NULL,
            forecast_date TEXT    NOT NULL,
            weather       TEXT,
            temp_high     INTEGER,
            temp_low      INTEGER,
            wind_dir      TEXT,
            wind_level    TEXT,
            crawl_time    TEXT,
            UNIQUE (city_code, forecast_date)
        )
        """
    )
    conn.commit()
    return conn


def save_records(conn: sqlite3.Connection, records: list[dict]) -> int:
    """把预报记录写入数据库,同一城市同一天的数据做覆盖更新。

    :param conn: 数据库连接
    :param records: 解析得到的记录列表
    :return: 实际写入的记录条数
    """
    if not records:
        return 0

    sql = """
        INSERT OR REPLACE INTO weather_forecast
            (city_name, city_code, forecast_date, weather,
             temp_high, temp_low, wind_dir, wind_level, crawl_time)
        VALUES
            (:city_name, :city_code, :forecast_date, :weather,
             :temp_high, :temp_low, :wind_dir, :wind_level, :crawl_time)
    """
    conn.executemany(sql, records)
    conn.commit()
    return len(records)


# --------------------------------------------------------------------------- #
# 结果展示
# --------------------------------------------------------------------------- #


def _display_width(text: str) -> int:
    """计算字符串在终端的显示宽度(中文占两列),用于对齐表格。"""
    width = 0
    for char in str(text):
        width += 2 if ord(char) > 127 else 1
    return width


def print_table(headers: list[str], rows: list[list]) -> None:
    """以等宽表格形式打印结果,方便在控制台直观查看。

    :param headers: 表头
    :param rows: 数据行
    """
    str_rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
    widths = [max(_display_width(h), *(_display_width(r[i]) for r in str_rows)) if str_rows else _display_width(h)
              for i, h in enumerate(headers)]

    def format_row(cells: list[str]) -> str:
        padded = [cell + " " * (widths[i] - _display_width(cell)) for i, cell in enumerate(cells)]
        return "| " + " | ".join(padded) + " |"

    separator = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
    print(separator)
    print(format_row(headers))
    print(separator)
    for row in str_rows:
        print(format_row(row))
    print(separator)


# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #


def main() -> None:
    """遍历城市集合,抓取 7 日天气预报并写入数据库。"""
    conn = init_db()
    total = 0

    for city_name, city_code in CITY_CODES.items():
        print(f"[抓取] {city_name}({city_code})...", end=" ")
        try:
            html = fetch_html(city_code)
            records = parse_forecast(html, city_name, city_code)
        except requests.RequestException as error:
            print(f"失败:{error}")
            continue

        saved = save_records(conn, records)
        total += saved
        print(f"解析到 {len(records)} 天预报,已入库 {saved} 条")
        time.sleep(REQUEST_INTERVAL)

    # 从数据库中读回数据做整体展示,验证确实写入了数据库
    rows = conn.execute(
        """
        SELECT city_name, forecast_date, weather, temp_high, temp_low, wind_dir, wind_level
        FROM weather_forecast
        ORDER BY city_name, id
        """
    ).fetchall()
    conn.close()

    print(f"\n本次共写入 {total} 条记录,数据库当前共有 {len(rows)} 条记录:\n")
    print_table(
        ["城市", "日期", "天气", "最高温", "最低温", "风向", "风力"],
        [list(row) for row in rows],
    )
    print(f"\n数据文件:{DB_PATH}")


if __name__ == "__main__":
    main()

(5)运行结果截图

image
image

5 个城市 × 7 天 = 35 条记录全部入库。脚本最后并不是直接打印内存中的列表,而是重新从数据库 SELECT 出来再打印,这样表格本身就是"数据确实落库了"的证据。


作业二 东方财富网:股票行情的定向爬取

(1)F12 抓包定位数据接口

打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board(沪深 A 股列表页),按 F12 切到 Network → Fetch/XHR,再在页面上点"下一页"或切换排序字段(例如点"涨跌幅"表头),就能看到一条反复出现的请求:

https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=20&po=1&np=1
    &ut=bd1d9ddb04089700cf9c27f6f7426281&fltt=2&invt=2
    &fid=f6&fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23
    &fields=f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23

也就是说:整页 HTML 里根本没有股票数据,列表是前端用这个 JSON 接口渲染出来的。这一步关键——找到接口,就不用再去解析那堆满屏的 <div>。

(2)参数分析

参数 含义 说明
pn / pz 页码 / 每页条数 翻页靠它,pz 最大可调到几百
po 排序方向 1 降序,0 升序
fid 排序字段 f3 按涨跌幅,f6 按成交额,f12 按代码
fs 市场过滤 m:0+t:6 深主板、m:0+t:80 创业板、m:1+t:2 沪主板、m:1+t:23 科创板
fields 返回字段 可自由增删,只要返回体需要的 f 编号,响应体积能小一个数量级
ut 客户端标识 抓包所得,缺了会被拒绝

(3)字段编号对照(data.diff 中的 f 键)

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

f13 的 0/1 分别代表深市、沪市,落库时转换成了中文。

(4)实现思路

"""
作业:使用 requests + json 解析爬取东方财富网股票行情数据,并保存到 SQLite 数据库。

抓包分析(Chrome F12 -> Network -> XHR):
    打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board (沪深 A 股列表),
    页面滚动/翻页时会不断请求下面这个接口:

        https://push2.eastmoney.com/api/qt/clist/get
            ?pn=1              # 页码
            &pz=20             # 每页条数
            &po=1              # 排序方向,1 为降序
            &fid=f3            # 排序字段,f3 为涨跌幅
            &fs=m:0+t:6,...    # 市场/板块过滤器,沪深 A 股
            &fields=f1,f2,...  # 需要返回的字段,可增删

    返回值是 JSON:data.total 为总条数,data.diff 为当期页的股票数组,
    数组元素中的键 f1~f152 就是字段编号,对照关系见 FIELD_MAP。

    请求参数可裁剪:只保留需要的 fields 能让响应体积大幅减小,
    这也是“定向爬取”的关键——不是抓整个页面,而是直接向数据接口要数据。

"""

(5)代码

# -*- coding: utf-8 -*-

import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path

import requests

# --------------------------------------------------------------------------- #
# 全局配置
# --------------------------------------------------------------------------- #

# 行情列表接口(东方财富行情中心的公开 JSON 接口)
API_URL = "https://push2.eastmoney.com/api/qt/clist/get"

# 抓包得到的固定参数:ut 为客户端标识,fs 为市场过滤条件(沪深 A 股)
BASE_PARAMS = {
    "po": 1,  # 排序方向:1 降序
    "np": 1,  # 返回格式标记
    "ut": "bd1d9ddb04089700cf9c27f6f7426281",
    "fltt": 2,  # 价格保留 2 位小数
    "invt": 2,
    "fid": "f6",  # 排序字段:f6 成交额(改成 f3 即按涨跌幅排序,可自由切换)
    # m:0+t:6 深市主板, m:0+t:80 创业板, m:1+t:2 沪市主板, m:1+t:23 科创板
    "fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23",
    # 只请求需要的字段,f12 代码、f14 名称是必需项
    "fields": "f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23",
}

# 每页条数与抓取页数(以我学号的最后三位147定义,14 条/页 * 7 页 = 98 条;)
PAGE_SIZE = 14
MAX_PAGES = 7

# 请求头:东方财富接口会校验 Referer,缺失时可能返回空数据
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/",
}

# 字段编号 -> (数据库列名, 中文含义),字段编号含义来自对接口返回值的分析
FIELD_MAP = {
    "f2": ("price", "最新价"),
    "f3": ("change_pct", "涨跌幅(%)"),
    "f4": ("change_amt", "涨跌额"),
    "f5": ("volume", "成交量(手)"),
    "f6": ("amount", "成交额(元)"),
    "f7": ("amplitude", "振幅(%)"),
    "f8": ("turnover", "换手率(%)"),
    "f9": ("pe", "市盈率(动)"),
    "f10": ("volume_ratio", "量比"),
    "f12": ("code", "股票代码"),
    "f13": ("market_id", "市场编号"),
    "f14": ("name", "股票名称"),
    "f15": ("high", "最高价"),
    "f16": ("low", "最低价"),
    "f17": ("open", "今开"),
    "f18": ("pre_close", "昨收"),
    "f20": ("total_mv", "总市值"),
    "f21": ("float_mv", "流通市值"),
    "f23": ("pb", "市净率"),
}

# 市场编号 -> 中文市场名
MARKET_NAMES = {0: "深市", 1: "沪市"}

# 数据库文件与本脚本同目录
DB_PATH = Path(__file__).with_name("stock.db")

# 翻页间隔(秒),控制请求频率
REQUEST_INTERVAL = 0.5


# --------------------------------------------------------------------------- #
# 网络请求与解析
# --------------------------------------------------------------------------- #


def fetch_page(page: int) -> dict:
    """请求接口的指定页,返回解析后的 JSON 字典。

    :param page: 页码,从 1 开始
    :return: 接口返回的 JSON 数据(已转为 Python 字典)
    """
    params = dict(BASE_PARAMS, pn=page, pz=PAGE_SIZE)
    response = requests.get(API_URL, params=params, headers=HEADERS, timeout=15)
    response.raise_for_status()
    # 接口返回的 Content-Type 是 text/plain,因此用 json.loads 手动解析文本
    return json.loads(response.text)


def parse_stock(item: dict, crawl_time: str) -> dict:
    """把接口返回的一条股票数据(f 字段字典)转换成数据库记录。

    :param item: data.diff 中的单个元素,形如 {"f2": 26.21, "f12": "301010", ...}
    :param crawl_time: 本次抓取时间
    :return: 字段名与数据库列名一一对应的字典
    """
    record = {}
    for field, (column, _) in FIELD_MAP.items():
        record[column] = item.get(field)
    # 接口用 "-" 表示停牌等无数据情况,统一转为 None 便于入库
    for column, value in record.items():
        if value == "-":
            record[column] = None
    record["market"] = MARKET_NAMES.get(record.pop("market_id"), "未知")
    record["crawl_time"] = crawl_time
    return record


def crawl_stocks(max_pages: int = MAX_PAGES) -> list[dict]:
    """按页循环抓取股票行情,直到达到页数上限或数据取完。

    :param max_pages: 最多抓取的页数
    :return: 股票记录列表
    """
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    records = []

    for page in range(1, max_pages + 1):
        data = fetch_page(page)
        payload = data.get("data") or {}
        items = payload.get("diff") or []

        if not items:
            print(f"[第 {page} 页] 无数据,结束抓取")
            break

        total = payload.get("total", 0)
        records.extend(parse_stock(item, crawl_time) for item in items)
        print(f"[第 {page} 页] 获取 {len(items)} 条,累计 {len(records)} 条(全市场共 {total} 只)")

        # 已经取完全部数据时提前退出
        if len(records) >= total:
            break
        time.sleep(REQUEST_INTERVAL)

    return records


# --------------------------------------------------------------------------- #
# 数据库操作
# --------------------------------------------------------------------------- #


def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
    """创建数据库连接并建表。

    :param db_path: 数据库文件路径
    :return: sqlite3 连接对象
    """
    conn = sqlite3.connect(db_path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS stock_quote (
            code         TEXT PRIMARY KEY,   -- 股票代码,唯一
            name         TEXT    NOT NULL,   -- 股票名称
            market       TEXT,               -- 沪市 / 深市
            price        REAL,               -- 最新价
            change_pct   REAL,               -- 涨跌幅(%)
            change_amt   REAL,               -- 涨跌额
            volume       REAL,               -- 成交量(手)
            amount       REAL,               -- 成交额(元)
            amplitude    REAL,               -- 振幅(%)
            turnover     REAL,               -- 换手率(%)
            volume_ratio REAL,               -- 量比
            pe           REAL,               -- 市盈率(动)
            pb           REAL,               -- 市净率
            high         REAL,               -- 最高价
            low          REAL,               -- 最低价
            open         REAL,               -- 今开
            pre_close    REAL,               -- 昨收
            total_mv     REAL,               -- 总市值
            float_mv     REAL,               -- 流通市值
            crawl_time   TEXT                -- 抓取时间
        )
        """
    )
    conn.commit()
    return conn


def save_records(conn: sqlite3.Connection, records: list[dict]) -> int:
    """写入股票行情数据,同一代码重复抓取时覆盖为最新快照。

    :param conn: 数据库连接
    :param records: 股票记录列表
    :return: 写入条数
    """
    if not records:
        return 0

    columns = list(records[0].keys())
    placeholders = ", ".join(f":{column}" for column in columns)
    sql = (
        f"INSERT OR REPLACE INTO stock_quote ({', '.join(columns)}) "
        f"VALUES ({placeholders})"
    )
    conn.executemany(sql, records)
    conn.commit()
    return len(records)


# --------------------------------------------------------------------------- #
# 结果展示
# --------------------------------------------------------------------------- #


def _display_width(text: str) -> int:
    """计算字符串的终端显示宽度(中文占两列)。"""
    return sum(2 if ord(char) > 127 else 1 for char in str(text))


def print_table(headers: list[str], rows: list[list]) -> None:
    """打印等宽表格,便于控制台核对数据。

    :param headers: 表头
    :param rows: 数据行
    """
    str_rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
    widths = [
        max([_display_width(headers[i])] + [_display_width(row[i]) for row in str_rows])
        for i in range(len(headers))
    ]

    def format_row(cells: list[str]) -> str:
        padded = [cell + " " * (widths[i] - _display_width(cell)) for i, cell in enumerate(cells)]
        return "| " + " | ".join(padded) + " |"

    separator = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
    print(separator)
    print(format_row(headers))
    print(separator)
    for row in str_rows:
        print(format_row(row))
    print(separator)


def report(conn: sqlite3.Connection) -> None:
    """统计并打印入库结果:总数、涨跌分布、平均涨幅、成交额榜前 10。

    :param conn: 数据库连接
    """
    total = conn.execute("SELECT COUNT(*) FROM stock_quote").fetchone()[0]
    up = conn.execute("SELECT COUNT(*) FROM stock_quote WHERE change_pct > 0").fetchone()[0]
    down = conn.execute("SELECT COUNT(*) FROM stock_quote WHERE change_pct < 0").fetchone()[0]
    flat = total - up - down
    avg_pct, sum_amount = conn.execute(
        "SELECT ROUND(AVG(change_pct), 2), ROUND(SUM(amount) / 1e8, 2) FROM stock_quote"
    ).fetchone()
    print(f"\n入库合计 {total} 条:上涨 {up} 只,下跌 {down} 只,平盘 {flat} 只")
    print(f"平均涨跌幅 {avg_pct}% ,百强成交额合计 {sum_amount} 亿元\n")

    # 成交额榜前 10(抓取时已按 f6 成交额降序排列)
    rows = conn.execute(
        """
        SELECT code, name, market, price, change_pct, turnover, amount
        FROM stock_quote
        ORDER BY amount DESC
        LIMIT 10
        """
    ).fetchall()
    print("成交额榜前 10:")
    print_table(
        ["代码", "名称", "市场", "最新价", "涨跌幅%", "换手率%", "成交额(亿)"],
        [[r[0], r[1], r[2], r[3], r[4], r[5], round((r[6] or 0) / 1e8, 2)] for r in rows],
    )


# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #


def main() -> None:
    """抓取股票行情 -> 解析 -> 入库 -> 打印统计。"""
    records = crawl_stocks()
    if not records:
        print("未抓取到数据,请检查网络或接口参数。")
        return

    conn = init_db()
    saved = save_records(conn, records)
    print(f"\n已写入数据库 {saved} 条记录。")
    report(conn)
    conn.close()
    print(f"\n数据文件:{DB_PATH}")


if __name__ == "__main__":
    main()

(5)运行结果

image
入库数据
image
image

说明:fid 一开始设的是 f3(涨跌幅降序),结果抓回来的 100 只全是涨停或大涨的票,涨跌分布统计出来"上涨 100、下跌 0",毫无信息量。改成 f6(成交额降序)后抓的是资金最活跃的 100 只,涨跌分布才有参考意义。这也说明排序参数的选择会直接影响数据分布。


作业三 软科中国大学排名 2021 主榜(全部院校)

(1)实现思路

爬取软科中国大学排名 2021 主榜的全部院校信息(排名、学校、省市、类型、总分),并保存到 SQLite 数据库。

* **数据来源**:站点公开的 JSON 接口 `https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021`,其中 `bcur_type=11` 表示主榜、`year` 表示年份。该接口一次返回全部 582 所院校,没有分页,因此不需要写翻页循环;把 `year` 改成 2026 会返回 590 所,可直观验证参数含义。
* **请求与解析**:用 `requests.get(API_URL, params=API_PARAMS, headers=HEADERS)` 发起请求,请求头带浏览器 `User-Agent` 和 `Referer`(接口有校验,缺失可能返回 403);用 `json.loads(response.text)` 解析文本,取 `payload["data"]["rankings"]` 得到院校列表。`fetch_rankings()` 会检查 `code == 200`,并在网络异常或字段缺失时自动重试 3 次、每次间隔 2 秒。
* **字段清洗**:`parse_rankings()` 只保留作业要求的 5 个字段——`ranking` 排名、`univNameCn` 学校、`province` 省市、`univCategory` 类型、`score` 总分;`_to_number()` 把 `"-"`、空字符串等无数据情况统一转成 `None`,排名缺失时退化为列表顺序,避免脏数据写进数据库。
* **入库设计**:写入与脚本同目录的 `ranking.db`,表 `university_rank` 用自增 `id` 作主键、`univ_name` 设 `UNIQUE`,配合 `INSERT OR REPLACE`,重复运行只刷新数据、不产生重复行。**&#8203;不能拿 `rank_no` 当主键**:榜单存在并列名次,会覆盖掉 72 所院校,详见(5)踩坑。
* **结果验证**:`report()` 不是打印内存里的列表,而是重新从数据库 `SELECT` 出前 20 名、上榜数量前 10 的省市和各类型院校分布,表格本身就是"数据确实落库"的证据。

整体按「常量配置 → 请求 → 解析 → 建表/入库 → 展示 → `main()`」分层,所有函数都写了 docstring,接口地址、参数、请求头、重试次数等常量集中在文件顶部,换年份只需改 `API_PARAMS` 一处。

(2)代码

# -*- coding: utf-8 -*-

import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path

import requests

# --------------------------------------------------------------------------- #
# 全局配置
# --------------------------------------------------------------------------- #

# 排行榜数据接口(由 F12 抓包得到)
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"

# 请求参数:bcur_type=11 表示中国大学排名主榜
API_PARAMS = {"bcur_type": 11, "year": 2021}

# 接口对 Referer 有校验,缺省时可能返回 403
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.shanghairanking.cn/rankings/bcur/2021",
    "Accept": "application/json, text/plain, */*",
}

# 数据库文件与本脚本同目录
DB_PATH = Path(__file__).with_name("ranking.db")

# 失败重试次数与重试间隔(秒)
MAX_RETRY = 3
RETRY_INTERVAL = 2


# --------------------------------------------------------------------------- #
# 网络请求与解析
# --------------------------------------------------------------------------- #


def fetch_rankings(params: dict = API_PARAMS) -> list[dict]:
    """请求排行榜接口,返回原始院校列表;失败时自动重试。

    :param params: 请求参数,默认为 2021 主榜
    :return: data.rankings 对应的院校字典列表
    """
    for attempt in range(1, MAX_RETRY + 1):
        try:
            response = requests.get(API_URL, params=params, headers=HEADERS, timeout=20)
            response.raise_for_status()
            payload = json.loads(response.text)  # 接口返回 JSON 文本,用 json 模块解析
            if payload.get("code") != 200:
                raise ValueError(f"接口返回异常:{payload.get('msg')}")
            return payload["data"]["rankings"]
        except (requests.RequestException, ValueError, KeyError) as error:
            print(f"  第 {attempt} 次请求失败:{error}")
            if attempt == MAX_RETRY:
                raise
            time.sleep(RETRY_INTERVAL)
    return []


def _to_number(value, cast=float):
    """把接口中的数值字段转换为目标类型,缺失或非法值返回 None。

    :param value: 原始值,可能是数字、数字字符串或 "-"
    :param cast: 目标类型,int 或 float
    :return: 转换后的数值或 None
    """
    if value in (None, "", "-"):
        return None
    try:
        return cast(float(value))
    except (TypeError, ValueError):
        return None


def parse_rankings(raw_list: list[dict], crawl_time: str) -> list[dict]:
    """把接口返回的原始数据整理成数据库记录(排名/学校/省市/类型/总分)。

    :param raw_list: 接口返回的原始院校列表
    :param crawl_time: 本次抓取时间
    :return: 数据库记录列表
    """
    records = []
    for index, item in enumerate(raw_list, start=1):
        rank = _to_number(item.get("ranking"), int)
        records.append(
            {
                # 若接口未给出排名,则退化为列表顺序
                "rank_no": rank if rank is not None else index,
                "univ_name": (item.get("univNameCn") or "").strip(),
                "province": (item.get("province") or "").strip(),
                "univ_category": (item.get("univCategory") or "").strip(),
                "total_score": _to_number(item.get("score"), float),
                "crawl_time": crawl_time,
            }
        )
    return records


# --------------------------------------------------------------------------- #
# 数据库操作
# --------------------------------------------------------------------------- #


def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
    """创建数据库连接并建表。

    :param db_path: 数据库文件路径
    :return: sqlite3 连接对象
    """
    conn = sqlite3.connect(db_path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS university_rank (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            rank_no       INTEGER NOT NULL,     -- 排名(存在并列名次,故不唯一)
            univ_name     TEXT NOT NULL UNIQUE, -- 学校名称,唯一键,重复抓取时覆盖
            province      TEXT,                 -- 省市
            univ_category TEXT,                 -- 类型(综合/理工/师范...)
            total_score   REAL,                 -- 总分
            crawl_time    TEXT                  -- 抓取时间
        )
        """
    )
    conn.commit()
    return conn


def save_records(conn: sqlite3.Connection, records: list[dict]) -> int:
    """写入院校排名数据,重复运行时按排名覆盖旧数据。

    :param conn: 数据库连接
    :param records: 院校记录列表
    :return: 写入条数
    """
    if not records:
        return 0

    sql = """
        INSERT OR REPLACE INTO university_rank
            (rank_no, univ_name, province, univ_category, total_score, crawl_time)
        VALUES
            (:rank_no, :univ_name, :province, :univ_category, :total_score, :crawl_time)
    """
    conn.executemany(sql, records)
    conn.commit()
    return len(records)


# --------------------------------------------------------------------------- #
# 结果展示
# --------------------------------------------------------------------------- #


def _display_width(text: str) -> int:
    """计算字符串的终端显示宽度(中文占两列)。"""
    return sum(2 if ord(char) > 127 else 1 for char in str(text))


def print_table(headers: list[str], rows: list[list], limit: int | None = None) -> None:
    """打印等宽表格,limit 用于只展示前 N 行。

    :param headers: 表头
    :param rows: 数据行
    :param limit: 最多展示的行数,None 表示全部
    """
    if limit is not None:
        rows = rows[:limit]
    str_rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
    widths = [
        max([_display_width(headers[i])] + [_display_width(row[i]) for row in str_rows])
        for i in range(len(headers))
    ]

    def format_row(cells: list[str]) -> str:
        padded = [cell + " " * (widths[i] - _display_width(cell)) for i, cell in enumerate(cells)]
        return "| " + " | ".join(padded) + " |"

    separator = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
    print(separator)
    print(format_row(headers))
    print(separator)
    for row in str_rows:
        print(format_row(row))
    print(separator)


def report(conn: sqlite3.Connection) -> None:
    """打印入库统计:总数、前 20 名、各省上榜数量、各类型占比。

    :param conn: 数据库连接
    """
    total = conn.execute("SELECT COUNT(*) FROM university_rank").fetchone()[0]
    print(f"\n数据库中共 {total} 所院校。\n")

    print("主榜前 20 名:")
    top20 = conn.execute(
        """
        SELECT rank_no, univ_name, province, univ_category, total_score
        FROM university_rank ORDER BY rank_no LIMIT 20
        """
    ).fetchall()
    print_table(["排名", "学校", "省市", "类型", "总分"], [list(r) for r in top20])

    print("\n上榜数量前 10 的省市:")
    provinces = conn.execute(
        """
        SELECT province, COUNT(*) AS cnt
        FROM university_rank GROUP BY province ORDER BY cnt DESC LIMIT 10
        """
    ).fetchall()
    print_table(["省市", "上榜院校数"], [list(r) for r in provinces])

    print("\n各类型院校分布:")
    categories = conn.execute(
        """
        SELECT univ_category, COUNT(*) AS cnt
        FROM university_rank GROUP BY univ_category ORDER BY cnt DESC
        """
    ).fetchall()
    print_table(["类型", "数量"], [list(r) for r in categories])


# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #


def main() -> None:
    """抓取 2021 软科中国大学排名主榜全部院校 -> 解析 -> 入库 -> 打印统计。"""
    print("[抓取] 软科中国大学排名 2021 主榜 ...")
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    raw_list = fetch_rankings()
    print(f"  接口返回 {len(raw_list)} 条院校数据")

    records = parse_rankings(raw_list, crawl_time)
    conn = init_db()
    saved = save_records(conn, records)
    print(f"  已写入数据库 {saved} 条")

    report(conn)
    conn.close()
    print(f"\n数据文件:{DB_PATH}")


if __name__ == "__main__":
    main()

(3)运行结果

image
image
入库数据
image
image

(4)踩坑:主键选错导致 72 所院校凭空消失

第一版把 rank_no 设成了主键,结果接口明明返回 582 条、executemany 也执行了 582 次,SELECT COUNT(*) 却是 510。原因是软科榜单存在并列名次(比如两所学校同为第 100 名),INSERT OR REPLACE 遇到主键冲突后把前一条覆盖掉了,582 - 510 = 72 条数据就这样"蒸发"。

修正方法:主键改成自增 id,把 univ_name 设为唯一键——学校名才是天然不重复的标识,而排名不是。改完之后数字与接口完全对上。


二、实验总结与心得

(一)技术要点与踩坑复盘

# 现象 原因 解决办法
1 requests 拿到中文乱码 响应头缺 charset,requests 按 ISO-8859-1 猜测编码 手动 response.encoding = "utf-8" 后再取 .text
2 气象网"今天"一列没有最低温 页面结构如此,今天只有 <i>最高温</i> 用 partition("/") 切分,最低温允许为 None
3 东方财富接口返回空 data 缺少 Referer / ut 参数 请求头带上 Referer: https://quote.eastmoney.com/,参数保留抓包得到的 ut
4 同样请求浏览器能开、代码报 403 没带 User-Agent,被识别为脚本 统一设置浏览器 UA
5 软科 582 条只入库 510 条 并列排名导致主键冲突被覆盖 主键换自增 id,univ_name 建唯一索引
6 反复调试时数据库越跑越脏 每次运行都 INSERT INSERT OR REPLACE + 业务唯一键,跑多少次结果都一致

几个可以复用的经验:

  1. 先抓包,再写代码。 三份作业里真正花时间的不是写 Python,而是判断"数据到底从哪个 URL 来"。气象网是服务端渲染(数据在 HTML 里),东方财富和软科是前后端分离(数据在 XHR 返回的 JSON 里)。判断错了,后面全是白费功夫。
  2. 能用接口就不要解析 HTML。 作业二三直接拿 JSON,字段明确、结构稳定,比在几百个 <div> 里找文本可靠得多;fields 参数还能裁剪字段。
  3. 请求参数是有语义的,删改前要想清楚。 fid 换个字段,抓回来的数据分布完全不同;pz 调大能少翻几页,但响应体也会变大。
  4. 控制访问频率。 三份代码里都加了 time.sleep(),每次请求间隔 0.5~1 秒。爬虫是客,别把人家服务器打挂。
  5. 入库后要"读回来"验证。 打印内存里的列表说明"解析对了",只有 SELECT 出来再打印才能说明"存对了"——作业三那 72 条数据丢失,正是靠 SELECT COUNT(*) 才发现的。

(二)心得体会

这次作业最大的感受是:爬虫的难点在"分析",不在"编码"。
写第一个作业(气象网)时,我还在用"先 requests 拿全文,再到处找正则"的老办法,代码又长又脆。到作业② 才开始真正用 F12 的 Network 面板:点一下"涨跌幅"表头,看哪条请求被重新发出,参数里哪个字段变了,很快就能锁定 fid 是排序字段、 fields 是返回字段。这种"观察请求—改参数—对比响应"的循环,比读十篇教程都管用。作业三更进一步:页面源码里一个学校名都搜不到,但 Preview 里 JSON 明明整整齐齐,这一刻才真正理解"前端渲染"是什么意思。

第二个感受是数据结构设计不能想当然。软科那 72 条数据丢失,报错信息一条都没有,程序还打印了"已写入 582 条",看起来完美无缺。如果不是顺手做了个 COUNT(*),这个 bug 会一直带到交作业。这让我意识到:写爬虫不能只看"有没有报错",要看"数量对不对、字段对不对"——数据质量校验应该成为流程的一部分,而不是事后补救。

第三个感受是规范带来的好处。把常量(城市编码、字段映射、请求头)统一提到文件顶部,把请求、解析、入库拆成独立函数后,改需求变得非常轻松:要多加一个城市,只改 CITY_CODES 字典;要改抓取页数,只改 MAX_PAGES;想看看换个排序字段效果如何,只改 fid 一行。如果全写在一个 main 里,每次改动都得重新读一遍自己的代码。

最后,这次也踩到了"反爬"的边:User-Agent、Referer、请求间隔、重试机制,这些在教科书上只是几个名词,真正遇到 403 和空响应时才知道为什么要加。当然,爬虫要有边界感——控制频率、只取公开数据、不绕过登录和验证码,这是基本的分寸。

posted @ 2026-11-10 18:07  102402147郑宇嘉  阅读(14)  评论(0)    收藏  举报