数据采集作业二

数据采集作业二

码云仓库地址:https://gitee.com/zsp-c/2026_crawl_project

作业① 中国气象网爬取

1.1 抓包分析与完整代码

页面定位。 中国气象网的城市预报页地址形如 http://www.weather.com.cn/weather/101010100.shtml,其中 101010100 是城市编码(cityid),北京、上海、广州、成都、西安的编码分别是 101010100、101020100、101280101、101270101、101110101。

结构分析。 在浏览器中打开北京预报页,F12 查看 Elements 面板可以看到 7 日预报位于 <div id="7d"> 内的 <ul class="t clearfix"> 里,每一天是一个 <li>:

<li class="sky skyid lv2 on">
    <h1>8日(今天)</h1>
    <p title="多云" class="wea">多云</p>
    <p class="tem"><span>6</span>/<i>-3℃</i></p>
    <p class="win"><em><span title="东北风"></span></em><i>&lt;3级</i></p>
</li>

完整代码 作业1/1.py:

# -*- coding: utf-8 -*-
"""
作业①:爬取中国气象网( http://www.weather.com.cn )指定城市的 7 日天气预报,
       并把结果保存到本地 SQLite 数据库 weather.db 中。

思路:
    1. 中国气象网的城市预报页地址形如  http://www.weather.com.cn/weather/101010100.shtml ,
       其中 101010100 是城市的 cityid(可在网站"城市查询"页查到)。
    2. 页面中 <div id="7d"> 下的 <ul class="t clearfix"> 里,每个 <li> 代表一天的预报,
       依次用 h1(日期)、p.wea(天气现象)、p.tem(最高/最低气温)、p.win(风向风力)描述。
    3. 用 requests 取回 HTML,再用 BeautifulSoup 按上述结构解析,最后写入数据库。
"""

import os
import sqlite3
import time
import unicodedata
from datetime import datetime

import requests
from bs4 import BeautifulSoup

# ---------------------------------------------------------------- 配置区

# 待爬取的城市及其在中国气象网的 cityid(共 5 个城市,满足 3~5 个的要求)
CITIES = {
    "北京": "101010100",
    "上海": "101020100",
    "广州": "101280101",
    "成都": "101270101",
    "西安": "101110101",
}

# 城市预报页地址模板,{city_id} 处填入 cityid
URL_TEMPLATE = "http://www.weather.com.cn/weather/{city_id}.shtml"

# 请求头:不加 User-Agent 时部分节点会返回 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": "http://www.weather.com.cn/",
}

# 数据库文件与本脚本放在同一目录下
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weather.db")

# 两次请求之间的间隔(秒),避免频繁访问给对方服务器造成压力
REQUEST_DELAY = 1


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

def init_db(db_path=DB_PATH):
    """创建(或打开)数据库并建表,返回连接对象。

    以 (city_id, date) 作为唯一约束,重复运行时用 INSERT OR REPLACE 覆盖当天数据,
    避免反复执行脚本造成数据重复堆积。
    """
    conn = sqlite3.connect(db_path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS weather_forecast (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            city        TEXT    NOT NULL,   -- 城市名称
            city_id     TEXT    NOT NULL,   -- 中国气象网城市编码
            date        TEXT    NOT NULL,   -- 预报日期,如 "8日(今天)"
            weather     TEXT,               -- 天气现象,如 "多云"
            temp_high   INTEGER,            -- 最高气温(℃)
            temp_low    INTEGER,            -- 最低气温(℃)
            wind        TEXT,               -- 风向,如 "东北风"
            wind_level  TEXT,               -- 风力,如 "<3级"
            crawl_time  TEXT,               -- 抓取时间
            UNIQUE (city_id, date)
        )
        """
    )
    conn.commit()
    return conn


def save_rows(conn, rows):
    """把解析结果批量写入 weather_forecast 表。"""
    conn.executemany(
        """
        INSERT OR REPLACE INTO weather_forecast
            (city, city_id, date, weather, temp_high, temp_low,
             wind, wind_level, crawl_time)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        rows,
    )
    conn.commit()


# ---------------------------------------------------------------- 解析函数

def _clean_temp(text):
    """把 "6"、"-3℃" 这类文本转成整数温度;解析不到时返回 None。"""
    if not text:
        return None
    text = text.replace("℃", "").strip()
    try:
        return int(text)
    except ValueError:
        return None


def parse_one_day(li):
    """解析 <li> 节点,返回 (日期, 天气, 最高温, 最低温, 风向, 风力) 六元组。"""
    date = li.find("h1").get_text(strip=True)          # 例:8日(今天)
    weather = li.find("p", attrs={"class": "wea"}).get_text(strip=True)

    # 气温结构:<p class="tem"><span>6</span>/<i>-3℃</i></p>
    temp_high = temp_low = None
    tem = li.find("p", attrs={"class": "tem"})
    if tem:
        high_node = tem.find("span")
        low_node = tem.find("i")
        temp_high = _clean_temp(high_node.get_text(strip=True)) if high_node else None
        temp_low = _clean_temp(low_node.get_text(strip=True)) if low_node else None

    # 风结构:<p class="win"><em><span title="东北风">…</span></em><i>&lt;3级</i></p>
    wind, wind_level = "", ""
    win = li.find("p", attrs={"class": "win"})
    if win:
        spans = win.find_all("span", title=True)
        wind = "/".join(span["title"] for span in spans)
        level_node = win.find("i")
        wind_level = level_node.get_text(strip=True) if level_node else ""

    return date, weather, temp_high, temp_low, wind, wind_level


def fetch_forecast(city, city_id):
    """抓取并解析单个城市未来 7 天的预报,返回可直接入库的行列表。"""
    url = URL_TEMPLATE.format(city_id=city_id)
    response = requests.get(url, headers=HEADERS, timeout=10)
    response.raise_for_status()
    # 页面声明为 utf-8,显式指定可避免 requests 猜错编码导致中文乱码
    response.encoding = "utf-8"

    soup = BeautifulSoup(response.text, "html.parser")
    seven_day = soup.find("div", id="7d")
    if seven_day is None:
        print(f"[警告] {city} 页面结构异常,未找到 id=7d 的节点")
        return []

    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows = []
    for li in seven_day.find_all("li"):
        if li.find("h1") is None:      # 跳过占位的空节点
            continue
        date, weather, high, low, wind, level = parse_one_day(li)
        rows.append((city, city_id, date, weather, high, low, wind, level, crawl_time))
    return rows


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

def pad(text, width):
    """按显示宽度左对齐补空格:中文算 2 格,保证终端表格列对齐。"""
    text = str(text)
    shown = sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
    return text + " " * max(width - shown, 0)


def show_forecast(conn, city=None):
    """从数据库中查询并打印预报信息,用于验证爬取结果。"""
    sql = (
        "SELECT city, date, weather, temp_high, temp_low, wind, wind_level "
        "FROM weather_forecast"
    )
    params = ()
    if city:
        sql += " WHERE city = ?"
        params = (city,)
    sql += " ORDER BY city, id"

    print(pad("城市", 7) + pad("日期", 13) + pad("天气", 12)
          + pad("最高", 6) + pad("最低", 6) + pad("风向", 20) + "风力")
    print("-" * 72)
    for row in conn.execute(sql, params):
        name, date, weather, high, low, wind, level = row
        high = "-" if high is None else high
        low = "-" if low is None else low
        print(pad(name, 7) + pad(date, 13) + pad(weather, 12)
              + pad(high, 6) + pad(low, 6) + pad(wind, 20) + str(level))


def main():
    """程序入口:初始化数据库 → 逐城市抓取 → 入库 → 打印结果。"""
    conn = init_db()
    total = 0
    try:
        for city, city_id in CITIES.items():
            try:
                rows = fetch_forecast(city, city_id)
            except requests.RequestException as error:
                print(f"[错误] 抓取 {city} 失败:{error}")
                continue
            save_rows(conn, rows)
            total += len(rows)
            print(f"[完成] {city}({city_id})共解析 {len(rows)} 天预报")
            time.sleep(REQUEST_DELAY)      # 礼貌性延时

        print(f"\n全部城市抓取结束,共写入 {total} 条记录,数据库:{DB_PATH}\n")
        print("========== 数据库中的 7 日预报 ==========")
        show_forecast(conn)
    finally:
        conn.close()


if __name__ == "__main__":
    main()

运行结果: 5 个城市 × 7 天 = 35 条记录全部入库。

image
image

1.2 实验心得

一是城市编码。 一开始我想当然地把上海写成 101010200,结果抓到的是"上海"这个页面标题下完全对不上的内容。后来才发现气象网的 cityid 并不是连续编号,必须去网站的"城市查询"页或者直接看 URL 才能确认,最后确定上海是 101020100。这也提醒我:写死参数之前一定要先验证一遍,不然出错时现象会很迷惑。

二是中文乱码。 直接用 response.text 有时会得到一堆问号,因为 requests 会依据响应头猜编码,而气象网部分节点返回的 Content-Type 里没有明确 charset。解决办法是拿到响应后手动指定 response.encoding = "utf-8",再取 response.text。

三是风向取不到。 我最初写的是 span.text,结果 20 多条记录的风向全是空的。打印出原始 HTML 才发现 <span title="东北风"></span> 的内容是空的,风向只存在于 title 属性里。这一步让我体会到:解析前先把目标节点的真实 HTML 打印出来看一眼,比盯着报错猜半天有效得多。

四是表格对齐。 终端里用 f"{'城市':<6}" 补空格,中文列永远是歪的——因为一个汉字占两格,而 Python 计算字符串长度时只算 1。后来写了个 pad(),用 unicodedata.east_asian_width 判断字符宽度,中文按 2 格计算才对齐。


作业②:东方财富沪深 A 股行情

打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board,F12 → Network → 勾选 Fetch/XHR → 刷新页面,可以看到行情由下面这个接口返回:

https://push2.eastmoney.com/api/qt/clist/get
    ?pn=1&pz=20&po=1&np=1&fltt=2&invt=2&fid=f3
    &fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048
    &fields=f2,f3,f4,f12,f13,f14,...

各参数的含义(改这些参数就能改抓取范围):

参数 含义
pn / pz 页码 / 每页条数(接口上限 100)
po / fid 排序方向 / 排序字段(f3 即涨跌幅)
fs 板块过滤:m:0+t:6 深主板、m:0+t:80 创业板、m:1+t:2 沪主板、m:1+t:23 科创板、m:0+t:81+s:2048 北交所
fields 需要返回的字段,按需增删;字段越少响应越快

在 Preview 面板里可以看到返回结构:data.total 是股票总数,data.diff 是本页的股票数组,数组元素形如 {"f2": 36.6, "f3": 20.0, "f12": "301697", "f14": "贝特利", ...}。所以只要循环 pn 翻页,再用 json.loads 取出 diff 即可。

完整代码 作业2/2.py:

# -*- coding: utf-8 -*-
"""
作业②:使用 requests + json 解析,定向爬取东方财富网的沪深 A 股实时行情列表,
       并把结果保存到 SQLite 数据库 stock.db 中。

抓包分析:
    在浏览器打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board 并进入 F12 →
    Network → XHR,可以看到行情表格并不是写在 HTML 里,而是由下面这个接口返回 JSON:

        https://push2.eastmoney.com/api/qt/clist/get
            ?pn=1&pz=20&po=1&np=1&fltt=2&invt=2&fid=f3
            &fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048
            &fields=f2,f3,f4,f12,f13,f14,...

    各参数含义:
        pn   页码        pz   每页条数(最大 100)
        po   排序方向    fid  排序字段(f3 表示按涨跌幅排序)
        fs   市场/板块过滤条件:m:0+t:6 深主板、m:0+t:80 创业板、m:1+t:2 沪主板、
             m:1+t:23 科创板、m:0+t:81+s:2048 北交所
        fields 需要返回的字段,按需增删即可(字段越少响应越快)
    返回 JSON 的 data.total 是总数,data.diff 是本页的股票数组。
"""

import json
import os
import sqlite3
import time
import unicodedata
from datetime import datetime

import requests

# ---------------------------------------------------------------- 配置区

# 行情列表接口
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,m:0+t:81+s:2048"

# 需要请求的字段,f 编号 → 含义见下方 FIELD_MAP
FIELDS = ",".join([
    "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10",
    "f12", "f13", "f14", "f15", "f16", "f17", "f18", "f20", "f21", "f23",
])

# 接口字段编号 → 数据库列名,方便后续统一改名
FIELD_MAP = {
    "f12": "code",            # 股票代码
    "f14": "name",            # 股票名称
    "f13": "market",          # 市场标志:0 深市(北交所也返回 0),1 沪市
    "f2": "price",            # 最新价
    "f3": "change_pct",       # 涨跌幅(%)
    "f4": "change_amount",    # 涨跌额
    "f5": "volume",           # 成交量(手)
    "f6": "amount",           # 成交额(元)
    "f7": "amplitude",        # 振幅(%)
    "f8": "turnover_rate",    # 换手率(%)
    "f9": "pe",               # 市盈率(动态)
    "f10": "volume_ratio",    # 量比
    "f15": "high",            # 最高价
    "f16": "low",             # 最低价
    "f17": "open",            # 今开
    "f18": "prev_close",      # 昨收
    "f20": "total_mv",        # 总市值(元)
    "f21": "float_mv",        # 流通市值(元)
    "f23": "pb",              # 市净率
}

PAGE_SIZE = 100          # 每页 100 条(接口上限)
MAX_PAGES = 60           # 最多翻 60 页,防止接口异常时死循环
REQUEST_DELAY = 0.3      # 每页请求间隔(秒)

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/",
}

DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stock.db")


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

def init_db(db_path=DB_PATH):
    """创建数据库与股票行情表,返回连接对象。"""
    conn = sqlite3.connect(db_path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS stock_spot (
            code          TEXT PRIMARY KEY,  -- 股票代码
            name          TEXT,              -- 股票名称
            market        TEXT,              -- 所属市场
            price         REAL,              -- 最新价
            change_pct    REAL,              -- 涨跌幅(%)
            change_amount REAL,              -- 涨跌额
            volume        REAL,              -- 成交量(手)
            amount        REAL,              -- 成交额(元)
            amplitude     REAL,              -- 振幅(%)
            turnover_rate REAL,              -- 换手率(%)
            pe            REAL,              -- 市盈率
            pb            REAL,              -- 市净率
            volume_ratio  REAL,              -- 量比
            high          REAL,              -- 最高价
            low           REAL,              -- 最低价
            open          REAL,              -- 今开
            prev_close    REAL,              -- 昨收
            total_mv      REAL,              -- 总市值(元)
            float_mv      REAL,              -- 流通市值(元)
            crawl_time    TEXT               -- 抓取时间
        )
        """
    )
    conn.commit()
    return conn


def save_stocks(conn, stocks):
    """把股票列表批量写入数据库(代码相同则覆盖旧记录)。"""
    columns = ["code", "name", "market"] + [
        column for column in FIELD_MAP.values() if column not in ("code", "name", "market")
    ]
    placeholders = ", ".join("?" for _ in columns)
    sql = (
        f"INSERT OR REPLACE INTO stock_spot ({', '.join(columns)}, crawl_time) "
        f"VALUES ({placeholders}, ?)"
    )
    conn.executemany(sql, [[stock[column] for column in columns] + [stock["crawl_time"]]
                           for stock in stocks])
    conn.commit()


# ---------------------------------------------------------------- 抓取与解析

def resolve_market(code, flag):
    """按股票代码前缀判定所属市场。

    注意不能用 f13 直接判定:北交所股票在接口里 f13 同样返回 0,会被误判成深市,
    因此以代码前缀为准,f13 只作为兜底。
    """
    if code.startswith(("60", "68")):      # 60 沪主板、68 科创板
        return "沪市"
    if code.startswith(("00", "30")):      # 00 深主板、30 创业板
        return "深市"
    if code.startswith(("8", "4", "92")):  # 8/4/92 开头为北交所
        return "北交所"
    return {0: "深市", 1: "沪市"}.get(flag, "其他")


def fetch_one_page(page_no):
    """抓取第 page_no 页的行情数据,返回 (股票字典列表, 总条数)。"""
    params = {
        "pn": page_no,          # 页码
        "pz": PAGE_SIZE,        # 每页条数
        "po": 1,                # 1 表示降序
        "np": 1,
        "fltt": 2,              # 价格保留 2 位小数
        "invt": 2,
        "fid": "f3",            # 按涨跌幅排序
        "fs": MARKET_FILTER,
        "fields": FIELDS,
    }
    response = requests.get(API_URL, params=params, headers=HEADERS, timeout=10)
    response.raise_for_status()

    payload = json.loads(response.text)      # 接口返回的是标准 JSON
    data = payload.get("data")
    if not data or not data.get("diff"):
        return [], 0

    total = data.get("total", 0)
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    stocks = []
    for item in data["diff"]:
        stock = {"crawl_time": crawl_time}
        for field, column in FIELD_MAP.items():
            value = item.get(field)
            # 停牌股票的行情字段是 "-",统一转成 None,便于写库
            stock[column] = None if value in ("-", "", None) else value
        stock["market"] = resolve_market(stock["code"], stock["market"])
        stocks.append(stock)
    return stocks, total


def crawl_all_stocks():
    """翻页抓取全部 A 股行情,返回股票列表。"""
    all_stocks, total = [], 0
    for page_no in range(1, MAX_PAGES + 1):
        stocks, total = fetch_one_page(page_no)
        if not stocks:
            break
        all_stocks.extend(stocks)
        print(f"  第 {page_no:>2} 页:获取 {len(stocks)} 条,累计 {len(all_stocks)}/{total}")
        if len(all_stocks) >= total:
            break
        time.sleep(REQUEST_DELAY)
    return all_stocks


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

def pad(text, width):
    """按显示宽度左对齐补空格:中文算 2 格,保证终端表格列对齐。"""
    text = str(text)
    shown = sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
    return text + " " * max(width - shown, 0)


def show_top(conn, limit=10):
    """按涨跌幅排序打印前 limit 只股票,验证入库结果。"""
    sql = (
        "SELECT code, name, price, change_pct, turnover_rate, pe "
        "FROM stock_spot WHERE change_pct IS NOT NULL "
        "ORDER BY change_pct DESC LIMIT ?"
    )
    print(pad("代码", 9) + pad("名称", 12) + pad("最新价", 10)
          + pad("涨跌幅", 10) + pad("换手率", 10) + "市盈率")
    print("-" * 60)
    for code, name, price, pct, turnover, pe in conn.execute(sql, (limit,)):
        print(pad(code, 9) + pad(name, 12) + pad(f"{price:g}", 10)
              + pad(f"{pct:.2f}%", 10) + pad(f"{turnover or 0:.2f}%", 10)
              + f"{pe or 0:.2f}")


def main():
    """程序入口:抓取 → 入库 → 统计展示。"""
    print("开始抓取东方财富沪深 A 股行情……")
    stocks = crawl_all_stocks()
    if not stocks:
        print("[错误] 未获取到任何数据,请检查接口或网络")
        return

    conn = init_db()
    try:
        save_stocks(conn, stocks)
        count = conn.execute("SELECT COUNT(*) FROM stock_spot").fetchone()[0]
        print(f"\n共抓取 {len(stocks)} 条,数据库现有 {count} 条记录:{DB_PATH}")
        print("\n========== 涨幅榜前 10 ==========")
        show_top(conn)
    finally:
        conn.close()


if __name__ == "__main__":
    main()

运行结果: 接口返回总数 5927,脚本自动翻 60 页全部抓完,入库 5927 条。

image

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

打开 https://www.shanghairanking.cn/rankings/bcur/2021,页面刚加载时表格是空的,说明数据是异步加载的。F12 → Network → Fetch/XHR → 刷新,能看到一个请求:

https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021

Preview 里就是完整的 JSON:data.rankings 一次返回主榜全部院校,不需要翻页。关键字段:rankOverall(排名)、univNameCn(学校)、province(省市)、univCategory(类型)、score(总分)。其中 bcur_type=11 表示主榜,改 year 就能抓其他年份。

完整代码 作业3/3.py:

# -*- coding: utf-8 -*-
"""
作业③:爬取软科"中国大学排名 2021 主榜"( https://www.shanghairanking.cn/rankings/bcur/2021 )
       的全部院校信息,并保存到 SQLite 数据库 ranking.db 中。

发包分析(对应博客中 F12 录屏的步骤):
    1. 页面刚打开时表格是空的,说明数据由前端异步加载。
    2. F12 → Network → 勾选 Fetch/XHR → 刷新页面,能看到一个请求:
           https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021
       preview 里就是完整的 JSON,一次返回主榜全部院校,不需要翻页。
    3. 关键字段:rankings[i].rankOverall(排名)、univNameCn(学校)、
       province(省市)、univCategory(类型)、score(总分)。
    4. bcur_type=11 表示"主榜";改 year 参数即可取到其他年份的榜单。
"""

import json
import os
import sqlite3
import unicodedata
from datetime import datetime

import requests

# ---------------------------------------------------------------- 配置区

API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"

# 请求参数:bcur_type=11 是中国大学排名主榜,year 为榜单年份
PARAMS = {"bcur_type": "11", "year": "2021"}

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

DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ranking.db")


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

def init_db(db_path=DB_PATH):
    """创建数据库与院校排名表,返回连接对象。"""
    conn = sqlite3.connect(db_path)
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS university_ranking (
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
            year       TEXT,     -- 榜单年份
            rank       INTEGER,  -- 排名
            univ_name  TEXT,     -- 学校
            province   TEXT,     -- 省市
            category   TEXT,     -- 类型(综合/理工/师范……)
            score      REAL,     -- 总分
            crawl_time TEXT      -- 抓取时间
        )
        """
    )
    # 注意:榜单存在并列排名(如第 95 名有多所学校),因此唯一键用 (年份, 学校名),
    # 不能用排名做唯一键,否则并列的院校会被覆盖掉。
    conn.execute(
        "CREATE UNIQUE INDEX IF NOT EXISTS idx_year_univ "
        "ON university_ranking (year, univ_name)"
    )
    conn.commit()
    return conn


def save_rankings(conn, rows):
    """批量写入排名数据:先清空该年份旧数据,再整体写入,避免重复运行时数据叠加。"""
    conn.execute("DELETE FROM university_ranking WHERE year = ?", (PARAMS["year"],))
    conn.executemany(
        """
        INSERT OR REPLACE INTO university_ranking
            (year, rank, univ_name, province, category, score, crawl_time)
        VALUES (?, ?, ?, ?, ?, ?, ?)
        """,
        rows,
    )
    conn.commit()


# ---------------------------------------------------------------- 抓取与解析

def fetch_rankings():
    """请求接口并解析出院校信息行列表。"""
    response = requests.get(API_URL, params=PARAMS, headers=HEADERS, timeout=15)
    response.raise_for_status()

    payload = json.loads(response.text)
    if payload.get("code") != 200:
        raise RuntimeError(f"接口返回异常:{payload.get('msg')}")

    rankings = payload["data"]["rankings"]
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows = []
    for item in rankings:
        rows.append((
            PARAMS["year"],
            _to_int(item.get("rankOverall")),   # 排名
            item.get("univNameCn", ""),         # 学校
            item.get("province", ""),           # 省市
            item.get("univCategory", ""),       # 类型
            item.get("score"),                  # 总分
            crawl_time,
        ))
    return rows


def _to_int(text):
    """把 "1"、"12"、"" 这类字符串安全转成整数,转不了返回 None。"""
    try:
        return int(str(text).strip())
    except (TypeError, ValueError):
        return None


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

def pad(text, width):
    """按显示宽度左对齐补空格:中文算 2 格,保证终端表格列对齐。"""
    text = str(text)
    shown = sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
    return text + " " * max(width - shown, 0)


def show_rankings(conn, limit=10):
    """打印前 limit 名院校,格式与作业要求的表格一致。"""
    print(pad("排名", 7) + pad("学校", 18) + pad("省市", 9) + pad("类型", 9) + "总分")
    print("-" * 52)
    sql = (
        "SELECT rank, univ_name, province, category, score "
        "FROM university_ranking ORDER BY rank, id LIMIT ?"
    )
    for rank, name, province, category, score in conn.execute(sql, (limit,)):
        score = "-" if score is None else f"{score:.1f}"
        print(pad(rank, 7) + pad(name, 18) + pad(province, 9) + pad(category, 9) + score)


def main():
    """程序入口:请求接口 → 解析 → 入库 → 展示。"""
    print("开始抓取软科中国大学排名 2021 主榜……")
    rows = fetch_rankings()
    print(f"接口返回 {len(rows)} 条院校数据")

    conn = init_db()
    try:
        save_rankings(conn, rows)
        count = conn.execute("SELECT COUNT(*) FROM university_ranking").fetchone()[0]
        print(f"写入完成,数据库现有 {count} 条记录:{DB_PATH}\n")
        print("========== 2021 主榜前 10 ==========")
        show_rankings(conn)
    finally:
        conn.close()


if __name__ == "__main__":
    main()

运行结果: 接口一次返回 582 条,全部入库,排名与总分与网页一致。
image

2.2 实验心得

抓包的关键是找准 XHR 过滤。 一开始我在 Network 面板的全量请求里翻,图片、CSS、JS 混在一起根本找不到目标。把过滤器切到 Fetch/XHR 后只剩下的几个请求,一眼就能看到 /api/qt/clist/get 这种明显带业务参数的 URL。另外,直接拿浏览器地址栏访问接口返回 404 是正常的——接口在根路径下没有内容,必须带参数访问。

字段编号要对照着看。 东方财富的接口用 f1~f23 这一堆编号,光看返回的 {"f2": 36.6, "f3": 20.0} 完全不知道是什么。我的办法是先在浏览器里把 fields 参数删到只剩几个,对照网页上显示的列反推含义:把 f12 单独请求一次,发现是 "301697" 这种 6 位数字,就确定它是股票代码;f3 出现 20.0 这种创业板涨停值,就知道是涨跌幅。整理清楚后写进 FIELD_MAP 做映射,代码可读性好了很多。

停牌股票的处理。 东方财富对停牌股票的行情字段返回的是字符串 "-" 而不是数字,直接写进 REAL 列虽然不会报错,但后续做排序、求均值时会出错。所以在解析时统一判断 value in ("-", "", None) 并转成 None,让数据库里存 NULL 而不是脏数据。

翻页的终止条件。 最开始的写法是 while True 一直翻,直到返回空列表才停,但如果接口某次异常返回了重复数据就会死循环。改成三重保险:MAX_PAGES 上限、累计条数 ≥ total 就停、本页为空就停。实际跑的时候第 60 页只返回 27 条,累计正好 5927,与前 59 页的 total 一致,说明确实抓完整了。

关于礼貌爬取。 全部沪深 A 股有近 6000 只,60 次请求如果毫秒级连发,对对方服务器是不小的压力。所以加了 REQUEST_DELAY = 0.3 秒的间隔和 User-Agent、Referer 头,既降低被封的风险,也是基本的爬虫礼仪。作业①只有 5 个城市,间隔设成 1 秒,整轮也就 5 秒,完全不影响体验。


posted @ 2026-11-08 22:00  zsp-  阅读(9)  评论(0)    收藏  举报