学号:052404147  姓名:刘怀正

本次作业的三道题目都围绕“请求—解析—入库”展开:作业①用 requests + 正则解析中国气象网的 7 日天气预报,作业②用 requests + json 爬取股票行情,作业③用 requests + json 解析软科中国大学排名接口,三者最后都把数据写入 SQLite 数据库,方便后续查询与统计。运行环境为 Python 3.13、requests、SQLite3,代码在 PyCharm/VS Code 中直接运行即可。
Gitee 仓库(作业2 文件夹):https://gitee.com/qgnb666/2026/tree/master/作业2

一、作业①与作业②:七日天气预报与股票行情爬取

1.1 作业代码与运行结果

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

题目要求:在中国气象网( http://www.weather.com.cn )爬取给定城市集的 7 日天气预报,并保存在数据库中。

实现思路

  1. 中国气象网的每个城市都有一个城市代码,7 日预报页面的地址是 http://www.weather.com.cn/weather/{城市代码}.shtml,例如北京是 101010100;本次选取北京、上海、广州、成都、武汉 5 个城市。
  2. 页面中 <ul class="t clearfix"> 里固定有 7 个 <li>,依次对应今天、明天、后天等 7 天,每个 <li> 内包含日期、天气现象、最高/最低气温、风向和风力。
  3. 用正则表达式按 <li class="sky skyid..."> 切分每个预报日,再在块内分别提取 p.wea 的天气、p.tem 的气温、p.win 的风向与风力。
  4. 页面是 UTF-8 编码,但响应头没有声明字符集,因此必须显式设置 resp.encoding = "utf-8",否则中文会乱码。
  5. 数据写入 SQLite 表 weather,以“城市 + 日期”作为唯一键,重复运行会自动覆盖当天的旧数据。

完整代码(1/1.py)

# -*- coding: utf-8 -*-
"""
数据采集 第二次作业 —— 作业①
在中国气象网( http://www.weather.com.cn )爬取给定城市集的 7 日天气预报,并保存到 SQLite 数据库。

实现说明:
    1) 每个城市对应一个城市代码,7 日预报页面为
       http://www.weather.com.cn/weather/{城市代码}.shtml
    2) 页面 <ul class="t clearfix"> 中固定有 7 个 <li>,依次是今天、明天、后天……
       每个 <li> 中包含日期、天气现象、最高/最低气温、风向与风力;
    3) 用正则从每个 <li> 中提取字段,按“城市 + 日期”写入 SQLite,重复运行会覆盖旧数据。

学号:052404147   姓名:刘怀正
"""
import os
import re
import sqlite3
import sys
from datetime import date, datetime, timedelta

import requests

# ---------------- 基本参数 ----------------
# 给定城市集(3~5 个城市):城市名 -> 中国气象网城市代码
CITIES = {
    "北京": "101010100",
    "上海": "101020100",
    "广州": "101280101",
    "成都": "101270101",
    "武汉": "101200101",
}

BASE_URL = "http://www.weather.com.cn/weather/{code}.shtml"

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/156.0 Safari/537.36"),
    "Referer": "http://www.weather.com.cn/",
}

# 数据库放在脚本所在目录,运行日志放在作业2根目录,避免受当前工作目录影响
BASE_DIR = os.path.dirname(os.path.abspath(__file__))     # 当前脚本所在目录(作业2/1)
PARENT_DIR = os.path.dirname(BASE_DIR)                     # 作业2 根目录,用于放运行日志
DB_PATH = os.path.join(BASE_DIR, "weather.db")
OUT_PATH = os.path.join(PARENT_DIR, "out_hw2_task1.txt")

OUT = open(OUT_PATH, "w", encoding="utf-8")


def log(*args):
    """同时输出到屏幕和 txt 文件,方便截图与写博客"""
    text = " ".join(str(a) for a in args)
    print(text, file=sys.stdout)
    print(text, file=OUT)


def pad(text, width):
    """按显示宽度补空格:中文按 2 列计算,保证控制台表格对齐"""
    real = sum(2 if ord(ch) > 127 else 1 for ch in str(text))
    return str(text) + " " * max(0, width - real)


def fetch_page(city_code):
    """获取某个城市 7 日预报页面的 HTML 源码"""
    url = BASE_URL.format(code=city_code)
    resp = requests.get(url, headers=HEADERS, timeout=20)
    resp.raise_for_status()
    resp.encoding = "utf-8"          # 页面为 utf-8,不显式指定会出现中文乱码
    return url, resp.text


def parse_7days(html):
    """
    解析 7 日预报,返回列表,每项为字典:
        date_label  页面上的日期文字(如“6日(今天)”)
        weather     天气现象(如“晴转多云”)
        temp_high   最高气温(整数)
        temp_low    最低气温(整数)
        wind_dir    风向(白天转夜间)
        wind_level  风力(如“<3级”)
    """
    # 每个预报日是一个 <li class="sky skyid lvX ..."> 块
    blocks = re.findall(r'<li class="sky skyid[^"]*">(.*?)</li>', html, re.S)
    days = []
    for block in blocks[:7]:
        # 日期文字,例如“6日(今天)”
        m_label = re.search(r"<h1>(.*?)</h1>", block, re.S)
        date_label = m_label.group(1).strip() if m_label else ""

        # 天气现象优先取 p 标签的 title 属性,避免混入图标文字
        m_wea = re.search(r'<p title="([^"]*)"\s+class="wea">', block)
        weather = m_wea.group(1).strip() if m_wea else ""

        # 气温:<p class="tem"><span>14</span>/<i>2℃</i></p>
        m_tem = re.search(r'<p class="tem">(.*?)</p>', block, re.S)
        temp_high, temp_low = None, None
        if m_tem:
            tem = m_tem.group(1)
            m_high = re.search(r"<span>(-?\d+)</span>", tem)
            m_low = re.search(r"<i>(-?\d+)℃</i>", tem)
            temp_high = int(m_high.group(1)) if m_high else None
            if m_low:
                temp_low = int(m_low.group(1))
            else:
                # 少数日期低温写成 <span>25</span>,取最后一个 span
                spans = re.findall(r"<span>(-?\d+)</span>", tem)
                temp_low = int(spans[-1]) if spans else None

        # 风向与风力:<em><span title="东南风"></span><span title="东北风"></span></em><i>&lt;3级</i>
        m_win = re.search(r'<p class="win">(.*?)</p>', block, re.S)
        wind_dir, wind_level = "", ""
        if m_win:
            win = m_win.group(1)
            dirs = re.findall(r'<span title="([^"]+)"', win)
            if len(dirs) >= 2:
                wind_dir = dirs[0] if dirs[0] == dirs[1] else f"{dirs[0]}转{dirs[1]}"
            elif dirs:
                wind_dir = dirs[0]
            m_level = re.search(r"<i>(.*?)</i>", win, re.S)
            wind_level = m_level.group(1).strip() if m_level else ""

        days.append({
            "date_label": date_label,
            "weather": weather,
            "temp_high": temp_high,
            "temp_low": temp_low,
            "wind_dir": wind_dir,
            "wind_level": wind_level,
        })
    return days


def init_db(conn):
    """创建天气表:同一城市同一天只保留一条记录"""
    conn.execute("""
        CREATE TABLE IF NOT EXISTS weather (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            city          TEXT NOT NULL,
            city_code     TEXT NOT NULL,
            forecast_date TEXT NOT NULL,
            date_label    TEXT,
            weather       TEXT,
            temp_high     INTEGER,
            temp_low      INTEGER,
            wind_dir      TEXT,
            wind_level    TEXT,
            crawl_time    TEXT,
            UNIQUE (city, forecast_date)
        )
    """)


def save_days(conn, city, city_code, days, crawl_time):
    """把某城市 7 天预报写入数据库,返回写入条数"""
    today = date.today()
    rows = []
    for offset, day in enumerate(days):
        forecast_date = (today + timedelta(days=offset)).isoformat()
        rows.append((
            city, city_code, forecast_date, day["date_label"], day["weather"],
            day["temp_high"], day["temp_low"], day["wind_dir"], day["wind_level"],
            crawl_time,
        ))
    conn.executemany("""
        INSERT INTO weather
            (city, city_code, forecast_date, date_label, weather,
             temp_high, temp_low, wind_dir, wind_level, crawl_time)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(city, forecast_date) DO UPDATE SET
            city_code=excluded.city_code,
            date_label=excluded.date_label,
            weather=excluded.weather,
            temp_high=excluded.temp_high,
            temp_low=excluded.temp_low,
            wind_dir=excluded.wind_dir,
            wind_level=excluded.wind_level,
            crawl_time=excluded.crawl_time
    """, rows)
    conn.commit()
    return len(rows)


def print_city(city, days, start_date):
    """打印一个城市的 7 日预报表格"""
    log(f"\n【{city}】")
    log(pad("日期", 12) + pad("天气", 16) + pad("最高", 6) + pad("最低", 6)
        + pad("风向", 18) + "风力")
    log("-" * 64)
    for offset, day in enumerate(days):
        d = start_date + timedelta(days=offset)
        log(pad(d.strftime("%m-%d"), 12) + pad(day["weather"], 16)
            + pad(day["temp_high"], 6) + pad(day["temp_low"], 6)
            + pad(day["wind_dir"], 18) + day["wind_level"])


if __name__ == "__main__":
    conn = sqlite3.connect(DB_PATH)
    init_db(conn)

    log("=" * 66)
    log("作业①:中国气象网 7 日天气预报   城市数:%d" % len(CITIES))
    log("数据库:" + DB_PATH)
    log("=" * 66)

    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    today = date.today()
    total = 0

    for city, code in CITIES.items():
        url, html = fetch_page(code)
        days = parse_7days(html)
        log(f"\n请求地址:{url}")
        log(f"解析到 {len(days)} 天预报")
        if len(days) != 7:
            log("警告:该城市未解析出 7 天数据,请检查页面结构是否变化")
        print_city(city, days, today)
        total += save_days(conn, city, code, days, crawl_time)

    # 从数据库回查,验证确实已经入库
    count = conn.execute("SELECT COUNT(*) FROM weather").fetchone()[0]
    cities = conn.execute("SELECT COUNT(DISTINCT city) FROM weather").fetchone()[0]
    log("\n" + "=" * 66)
    log(f"入库完成:本次写入 {total} 条,数据库现有 {count} 条,覆盖 {cities} 个城市")
    log("=" * 66)
    conn.close()
    OUT.close()

运行结果(5 个城市 × 7 天 = 35 条记录)
hw2_task1_result
hw2_task1_page

数据库校验:运行结束后程序会自动回查,输出“本次写入 35 条,数据库现有 35 条,覆盖 5 个城市”,说明每个城市的 7 天数据都已正确入库。
Gitee 文件夹链接:https://gitee.com/qgnb666/2026/tree/master/作业2

1.1.2 作业②:新浪股票行情定向爬虫

题目要求:用 requests 和 json 解析方法定向爬取股票相关信息,并存储在数据库中,候选网站为东方财富网或新浪股票。

F12 抓包过程

  1. 打开新浪股票行情页 https://finance.sina.com.cn/stock/,按 F12 打开开发者工具,切到 Network(网络) 面板;
  2. 刷新页面,在筛选框中选 Fetch/XHR,可以看到页面通过下面的接口加载沪深 A 股列表:
    https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData
  3. 观察请求参数:page 是页码,num 是每页条数,sort=symbol&asc=1 表示按代码升序,node=hs_a 表示沪深 A 股;
  4. 查看 Response,返回的是标准 JSON 数组,每一条包含 code、name、trade、pricechange、changepercent、settlement、open、high、low、volume、amount、per、pb、mktcap、nmc、turnoverratio 等字段;
  5. 另有一个 Market_Center.getHQNodeStockCount?node=hs_a 接口返回股票总数(5578 只),用它计算总页数,避免写死页码。

接口返回的 JSON 数据(浏览器直接打开接口地址)

hw2_task2_api

实现思路

  1. 用 requests.get() 携带 UA 与新浪的 Referer,按页请求接口;
  2. 用 json.loads() 把响应解析成列表,再逐条取出需要的字段,"-"、"" 等无效值统一转换成 None;
  3. 按股票代码去重入库(code 作为主键),遇到 504、超时等临时错误自动重试;
  4. 最后按成交额排序输出前 10 名,并从数据库回查记录数做校验。

完整代码(2/2.py)

# -*- coding: utf-8 -*-
"""
数据采集 第二次作业 —— 作业②
用 requests 和 json 解析方法定向爬取股票相关信息,并保存到 SQLite 数据库。

实现说明:
    1) 在浏览器 F12 的 Network 面板中抓包发现,新浪股票行情列表由下面的 JSON 接口返回:
       https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/
       Market_Center.getHQNodeData?page=1&num=100&sort=symbol&asc=1&node=hs_a
       其中 page 为页码、num 为每页条数、node=hs_a 表示沪深 A 股;
       另外用 Market_Center.getHQNodeStockCount?node=hs_a 可取得股票总数,用于计算总页数。
    2) 接口返回 JSON 数组,每条记录包含代码、名称、最新价、涨跌额、涨跌幅、
       昨收、今开、最高、最低、成交量、成交额、市盈率、市净率、总市值、流通市值、换手率等字段;
    3) 程序按页循环请求,用 json.loads() 解析后写入 SQLite 表 stocks。

学号:052404147   姓名:刘怀正
"""
import json
import math
import os
import sqlite3
import sys
import time
from datetime import datetime

import requests

# ---------------- 基本参数 ----------------
BASE_API = ("https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
            "Market_Center.getHQNodeData")
COUNT_API = ("https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
             "Market_Center.getHQNodeStockCount")
NODE = "hs_a"          # 沪深 A 股
PER_PAGE = 100         # 每页条数(实测 num=100 可用)

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/156.0 Safari/537.36"),
    "Referer": "https://finance.sina.com.cn/stock/",
}

BASE_DIR = os.path.dirname(os.path.abspath(__file__))     # 当前脚本所在目录(作业2/2)
PARENT_DIR = os.path.dirname(BASE_DIR)                     # 作业2 根目录,用于放运行日志
DB_PATH = os.path.join(BASE_DIR, "stock.db")
OUT_PATH = os.path.join(PARENT_DIR, "out_hw2_task2.txt")

OUT = open(OUT_PATH, "w", encoding="utf-8")


def log(*args):
    """同时输出到屏幕和 txt 文件,方便截图与写博客"""
    text = " ".join(str(a) for a in args)
    print(text, file=sys.stdout)
    print(text, file=OUT)


def pad(text, width):
    """按显示宽度补空格:中文按 2 列计算,保证控制台表格对齐"""
    real = sum(2 if ord(ch) > 127 else 1 for ch in str(text))
    return str(text) + " " * max(0, width - real)


def to_num(value):
    """把接口里的 '-'、'' 等无效值统一转成 None,其余转成 float"""
    if value in ("-", "", None):
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def get_stock_count():
    """获取沪深 A 股总数(接口返回的是带引号的数字字符串,如 "5578")"""
    resp = requests.get(COUNT_API, params={"node": NODE}, headers=HEADERS, timeout=20)
    resp.raise_for_status()
    return int(resp.text.strip().strip('"'))


def get_page(page, retries=4):
    """请求一页股票列表,返回 json 解析后的列表;遇到 504 等临时错误自动重试"""
    params = {
        "page": page,
        "num": PER_PAGE,
        "sort": "symbol",      # 按代码排序,保证翻页不重不漏
        "asc": 1,
        "node": NODE,
        "symbol": "",
        "_s_r_a": "page",
    }
    last_error = None
    for attempt in range(1, retries + 1):
        try:
            resp = requests.get(BASE_API, params=params, headers=HEADERS, timeout=30)
            resp.raise_for_status()
            # 接口返回 application/json,内容为 JSON 数组,可直接用 json.loads 解析
            return json.loads(resp.text)
        except (requests.RequestException, json.JSONDecodeError) as err:
            last_error = err
            log(f"    第 {page} 页请求失败(第 {attempt} 次):{err},稍后重试")
            time.sleep(1.5 * attempt)
    raise RuntimeError(f"第 {page} 页重试 {retries} 次仍失败:{last_error}")


def parse_item(item):
    """把一条 JSON 记录转换成数据库需要的元组"""
    return (
        item.get("code"),                                    # 股票代码
        item.get("symbol"),                                  # 带交易所前缀的代码
        item.get("name"),                                    # 股票名称
        to_num(item.get("trade")),                            # 最新价
        to_num(item.get("pricechange")),                     # 涨跌额
        to_num(item.get("changepercent")),                   # 涨跌幅(%)
        to_num(item.get("settlement")),                      # 昨收
        to_num(item.get("open")),                            # 今开
        to_num(item.get("high")),                            # 最高
        to_num(item.get("low")),                             # 最低
        to_num(item.get("volume")),                          # 成交量(股)
        to_num(item.get("amount")),                          # 成交额(元)
        to_num(item.get("per")),                             # 市盈率
        to_num(item.get("pb")),                              # 市净率
        to_num(item.get("mktcap")),                          # 总市值(万元)
        to_num(item.get("nmc")),                             # 流通市值(万元)
        to_num(item.get("turnoverratio")),                   # 换手率(%)
        item.get("ticktime"),                                # 行情时间
    )


def init_db(conn):
    """创建股票表,以股票代码作为主键"""
    conn.execute("""
        CREATE TABLE IF NOT EXISTS stocks (
            code          TEXT PRIMARY KEY,
            symbol        TEXT,
            name          TEXT NOT NULL,
            price         REAL,
            change_amount REAL,
            change_pct    REAL,
            prev_close    REAL,
            open_price    REAL,
            high_price    REAL,
            low_price     REAL,
            volume        REAL,
            amount        REAL,
            pe            REAL,
            pb            REAL,
            market_cap    REAL,
            float_cap     REAL,
            turnover_rate REAL,
            tick_time     TEXT,
            crawl_time    TEXT
        )
    """)


def save_items(conn, rows, crawl_time):
    """批量写入数据库,重复运行时用新数据覆盖旧记录"""
    data = [row + (crawl_time,) for row in rows]
    conn.executemany("""
        INSERT INTO stocks
            (code, symbol, name, price, change_amount, change_pct,
             prev_close, open_price, high_price, low_price, volume, amount,
             pe, pb, market_cap, float_cap, turnover_rate, tick_time, crawl_time)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(code) DO UPDATE SET
            symbol=excluded.symbol, name=excluded.name, price=excluded.price,
            change_amount=excluded.change_amount, change_pct=excluded.change_pct,
            prev_close=excluded.prev_close, open_price=excluded.open_price,
            high_price=excluded.high_price, low_price=excluded.low_price,
            volume=excluded.volume, amount=excluded.amount, pe=excluded.pe,
            pb=excluded.pb, market_cap=excluded.market_cap, float_cap=excluded.float_cap,
            turnover_rate=excluded.turnover_rate, tick_time=excluded.tick_time,
            crawl_time=excluded.crawl_time
    """, data)
    conn.commit()


if __name__ == "__main__":
    conn = sqlite3.connect(DB_PATH)
    init_db(conn)

    log("=" * 78)
    log("作业②:新浪股票沪深 A 股定向爬虫(requests + json 解析)")
    log("数据库:" + DB_PATH)
    log("=" * 78)

    total_count = get_stock_count()
    total_pages = math.ceil(total_count / PER_PAGE)
    log(f"接口返回的沪深 A 股总数:{total_count} 只,按每页 {PER_PAGE} 只共需 {total_pages} 页")

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

    for page in range(1, total_pages + 1):
        items = get_page(page)
        rows = [parse_item(x) for x in items]
        all_rows.extend(rows)
        log(f"  第 {page:>2}/{total_pages} 页:解析 {len(items):>3} 条,累计 {len(all_rows)} 条")
        time.sleep(0.25)          # 避免请求过于频繁

    save_items(conn, all_rows, crawl_time)

    # 只保留最新价大于 0 的股票做统计,排除停牌等无行情的记录
    valid = [x for x in all_rows if x[3] is not None and x[3] > 0]
    ups = [x for x in valid if (x[5] or 0) > 0]
    downs = [x for x in valid if (x[5] or 0) < 0]
    amount_sum = sum(x[11] for x in valid if x[11]) / 1e8

    log("\n" + "=" * 78)
    log(f"爬取完成:共写入 {len(all_rows)} 条记录,其中有报价 {len(valid)} 条,"
        f"停牌或无报价 {len(all_rows) - len(valid)} 条")
    log(f"上涨 {len(ups)} 只,下跌 {len(downs)} 只,平盘 {len(valid) - len(ups) - len(downs)} 只")
    log(f"沪深 A 股当日成交额合计约 {amount_sum:.2f} 亿元")
    log("=" * 78)

    # 按成交额排序,打印前 10 名,作为爬取结果展示
    log("\n成交额前 10 名:")
    log(pad("代码", 10) + pad("名称", 20) + pad("最新价", 10)
        + pad("涨跌幅%", 10) + "成交额(亿元)")
    log("-" * 62)
    top10 = sorted(valid, key=lambda x: x[11] or 0, reverse=True)[:10]
    for row in top10:
        log(pad(row[0], 10) + pad(row[2], 20) + pad(f"{row[3]:.2f}", 10)
            + pad(f"{row[5] or 0:.2f}", 10) + f"{(row[11] or 0) / 1e8:.2f}")

    # 数据库回查校验
    db_count = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0]
    db_sample = conn.execute(
        "SELECT code, name, price, change_pct FROM stocks "
        "ORDER BY amount DESC LIMIT 3").fetchall()
    log("\n数据库回查:stocks 表共 %d 条,成交额前 3 条为:" % db_count)
    for code, name, price, pct in db_sample:
        log(f"  {code}  {name}  最新价 {price}  涨跌幅 {pct}%")

    conn.close()
    OUT.close()

运行结果(共 5578 条记录)

  • 上涨 797 只,下跌 4686 只;
  • 沪深 A 股当日成交额合计约 7922.68 亿元;
  • 数据库 stock.db 的 stocks 表共 5578 条,成交额前 10 名如下:
代码 名称 最新价(元) 涨跌幅(%) 成交额(亿元)
300308 中际旭创 444.10 -9.98 112.64
300502 新易盛 170.61 -8.08 75.27
688825 长鑫科技 40.58 -4.58 69.93
002384 东山精密 80.23 -9.96 56.30
300750 宁德时代 260.50 -1.35 55.26
300274 阳光电源 84.14 -1.18 46.86
603986 兆易创新 243.01 -7.65 45.54
688256 寒武纪 799.96 -5.33 39.68
002371 北方华创 543.07 -3.20 34.94
600183 生益科技 70.94 -8.79 33.38
hw2_task2_result_1
hw2_task2_result_2

Gitee 文件夹链接:https://gitee.com/qgnb666/2026/tree/master/作业2

1.2 作业心得

作业①给我印象最深的是编码问题。天气页面返回的响应头没有写明字符集,requests 会按 ISO-8859-1 解码,结果中文全是乱码;显式设置 resp.encoding = "utf-8" 之后才恢复正常。这让我养成了“拿到响应先确认编码”的习惯。另一个细节是页面里的气温标签并不统一,大部分日期是 <span>最高</span>/<i>最低℃</i>,少数日期低温也写在 <span> 里,因此代码里做了兜底处理,避免某个城市出现 None。

作业②让我第一次完整体验了“F12 抓包—分析参数—构造请求”的流程。以前我总是直接爬网页 HTML,这次发现股票列表根本不是写在 HTML 里的,而是页面加载后由 XHR 请求 JSON 再渲染。抓包不仅找到了接口地址,还让我看懂了 page、num、node 这些参数的含义,只要改页码就能翻页。调试过程中还遇到接口偶尔返回 504 或读取超时,我给请求加了重试与退避,程序才能稳定跑完 56 页。这也让我理解了:真实网站的接口并不总是稳定的,爬虫必须考虑异常与重试。

二、作业③:软科中国大学 2021 主榜全量爬取

2.1 作业代码与运行结果

题目要求:爬取中国大学 2021 主榜( https://www.shanghairanking.cn/rankings/bcur/2021 )所有院校信息,存储在数据库中,并把浏览器 F12 调试分析的过程录制为 GIF 加入博客。

F12 发包分析

  1. 打开榜单页,按 F12 进入 Network 面板并刷新,榜单数据并不是直接写在 HTML 中,而是页面加载后异步请求接口渲染出来的;
  2. 在 XHR 请求中找到 api/pub/v1/bcur,请求参数为 bcur_type=11(主榜)和 year=2021;
  3. 在 Preview/Response 中可以看到 data.rankings 一次性返回全部 582 所院校,每条记录包含 rankings(排名)、univNameCn(中文校名)、province(省市)、univCategory(类型)、score(总分)、univTags(双一流/985/211 标签)等字段;
  4. 因此不需要像普通网页那样翻页,只要按这个接口请求一次,就能拿到整张榜单。

F12 调试录屏 GIF:录制步骤为“打开榜单页 → F12 → Network → 勾选 Fetch/XHR → 刷新 → 点击 bcur 请求 → 查看 Preview 中的 rankings 数组”,可用 ScreenToGif 或 LICEcap 录制 10~15 秒保存为 f12_bcur.gif,插入到本段末尾。
image
f12_bcur

接口返回的 JSON 数据

hw2_task3_api

实现思路

  1. 用 requests 请求 api/pub/v1/bcur,参数 bcur_type=11&year=2021;
  2. 用 json.loads() 解析,检查返回的 code == 200 后取出 data.rankings;
  3. 逐条映射为数据库字段,标签数组用“、”拼接成一个字符串;
  4. 写入 SQLite 表 university_ranking,以“年份 + 校名”为唯一键,重复运行覆盖旧数据。

完整代码(3/3.py)

# -*- coding: utf-8 -*-
"""
数据采集 第二次作业 —— 作业③
爬取软科中国大学 2021 主榜( https://www.shanghairanking.cn/rankings/bcur/2021 )
所有院校信息,并保存到 SQLite 数据库。

实现说明:
    1) 榜单页面的院校数据由前端异步请求接口后渲染,直接在浏览器 F12 的
       Network 面板中可以看到下面的接口:
       https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021
       参数 bcur_type=11 表示“主榜”,year=2021 表示 2021 年;
    2) 接口返回 JSON,data.rankings 中一次性包含全部院校(582 所),
       每条记录含排名、学校中英文名、省市、类型、总分、标签、校徽等字段;
    3) 用 requests 获取 JSON,解析后写入 SQLite 表 university_ranking。

学号:052404147   姓名:刘怀正
"""
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          # 11 表示 2021 中国大学排名主榜
YEAR = 2021

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

BASE_DIR = os.path.dirname(os.path.abspath(__file__))     # 当前脚本所在目录(作业2/3)
PARENT_DIR = os.path.dirname(BASE_DIR)                     # 作业2 根目录,用于放运行日志
DB_PATH = os.path.join(BASE_DIR, "university.db")
OUT_PATH = os.path.join(PARENT_DIR, "out_hw2_task3.txt")

OUT = open(OUT_PATH, "w", encoding="utf-8")


def log(*args):
    """同时输出到屏幕和 txt 文件,方便截图与写博客"""
    text = " ".join(str(a) for a in args)
    print(text, file=sys.stdout)
    print(text, file=OUT)


def pad(text, width):
    """按显示宽度补空格:中文按 2 列计算,保证控制台表格对齐"""
    real = sum(2 if ord(ch) > 127 else 1 for ch in str(text))
    return str(text) + " " * max(0, width - real)


def fetch_rankings():
    """请求榜单接口,返回 (院校列表, 接口原始结果)"""
    params = {"bcur_type": BCUR_TYPE, "year": YEAR}
    resp = requests.get(API_URL, params=params, headers=HEADERS, timeout=30)
    resp.raise_for_status()
    payload = json.loads(resp.text)
    if payload.get("code") != 200:
        raise RuntimeError("接口返回异常:%s" % payload.get("msg"))
    return payload["data"]["rankings"], payload


def parse_item(item):
    """把一条院校 JSON 记录转换成数据库需要的元组"""
    tags = item.get("univTags") or []
    return (
        int(item["ranking"]) if str(item.get("ranking", "")).isdigit() else None,
        str(item.get("rankOverall") or ""),      # 并列名次,如 "1"、"50-51"
        item.get("univNameCn"),                  # 中文校名
        item.get("univNameEn"),                  # 英文校名
        item.get("province"),                    # 省市
        item.get("univCategory"),                # 学校类型
        item.get("score"),                       # 总分
        "、".join(tags),                         # 双一流/985/211 等标签
        item.get("univLogo"),                    # 校徽图片相对地址
        item.get("univUp"),                      # 学校英文标识
    )


def init_db(conn):
    """创建大学排名表,以“年份 + 校名”唯一"""
    conn.execute("""
        CREATE TABLE IF NOT EXISTS university_ranking (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            year        INTEGER NOT NULL,
            rank_no     INTEGER,
            rank_overall TEXT,
            name_cn     TEXT NOT NULL,
            name_en     TEXT,
            province    TEXT,
            category    TEXT,
            score       REAL,
            tags        TEXT,
            logo        TEXT,
            univ_up     TEXT,
            crawl_time  TEXT,
            UNIQUE (year, name_cn)
        )
    """)


def save_items(conn, rows, crawl_time):
    """批量写入数据库,重复运行时覆盖旧数据"""
    data = [(YEAR,) + row + (crawl_time,) for row in rows]
    conn.executemany("""
        INSERT INTO university_ranking
            (year, rank_no, rank_overall, name_cn, name_en, province,
             category, score, tags, logo, univ_up, crawl_time)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        ON CONFLICT(year, name_cn) DO UPDATE SET
            rank_no=excluded.rank_no, rank_overall=excluded.rank_overall,
            name_en=excluded.name_en, province=excluded.province,
            category=excluded.category, score=excluded.score,
            tags=excluded.tags, logo=excluded.logo, univ_up=excluded.univ_up,
            crawl_time=excluded.crawl_time
    """, data)
    conn.commit()


if __name__ == "__main__":
    conn = sqlite3.connect(DB_PATH)
    init_db(conn)

    log("=" * 70)
    log("作业③:软科中国大学排名 2021 主榜全量爬取(requests + json)")
    log("榜单页面:https://www.shanghairanking.cn/rankings/bcur/2021")
    log("数据接口:%s?bcur_type=%d&year=%d" % (API_URL, BCUR_TYPE, YEAR))
    log("=" * 70)

    rankings, payload = fetch_rankings()
    log(f"接口返回 code={payload['code']} msg={payload['msg']},院校数:{len(rankings)}")

    rows = [parse_item(x) for x in rankings]
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    save_items(conn, rows, crawl_time)

    # 打印前 10 名,字段与题目要求的“排名/学校/省市/类型/总分”一致
    log("\n2021 主榜前 10 名:")
    log(pad("排名", 8) + pad("学校", 26) + pad("省市", 10)
        + pad("类型", 8) + "总分")
    log("-" * 60)
    for row in rows[:10]:
        log(pad(row[0], 8) + pad(row[2], 26) + pad(row[4], 10)
            + pad(row[5], 8) + f"{row[6]}")

    # 简单统计,验证数据完整性
    provinces = {}
    categories = {}
    for row in rows:
        provinces[row[4]] = provinces.get(row[4], 0) + 1
        categories[row[5]] = categories.get(row[5], 0) + 1

    log("\n覆盖省市 %d 个,学校类型 %d 类,平均总分 %.2f"
        % (len(provinces), len(categories),
           sum(x[6] for x in rows) / len(rows)))
    log("院校数量前 5 的省市:" + ",".join(
        f"{k} {v} 所" for k, v in sorted(provinces.items(), key=lambda kv: -kv[1])[:5]))
    log("类型分布:" + ",".join(f"{k} {v} 所" for k, v in categories.items()))

    # 数据库回查校验
    db_count = conn.execute(
        "SELECT COUNT(*) FROM university_ranking WHERE year=?", (YEAR,)).fetchone()[0]
    db_top = conn.execute(
        "SELECT rank_no, name_cn, province, category, score FROM university_ranking "
        "WHERE year=? ORDER BY rank_no LIMIT 3", (YEAR,)).fetchall()
    log("\n数据库回查:2021 年共 %d 条记录,前 3 名为:" % db_count)
    for r in db_top:
        log("  " + "  ".join(str(x) for x in r))

    log("\n" + "=" * 70)
    log(f"爬取完成:共写入 {len(rows)} 条院校信息到 {DB_PATH}")
    log("=" * 70)

    conn.close()
    OUT.close()

运行结果(2021 主榜共 582 所院校,平均总分 169.32)

排名 学校 省市 类型 总分
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

hw2_task3_result

数据库校验:程序最后从 university.db 回查,确认 2021 年共有 582 条记录,前 3 名依次是清华大学、北京大学、浙江大学,与接口返回完全一致。
Gitee 文件夹链接:https://gitee.com/qgnb666/2026/tree/master/作业2

2.2 作业心得

这道题让我理解了“网页 ≠ 数据源”。榜单页面打开时源码里只有骨架,真正的院校数据是前端请求 api/pub/v1/bcur 后渲染出来的;如果不看 F12 的 Network 面板,只会在 HTML 里找表格,最终一无所获。抓包之后我发现接口一次就返回了全部 582 所院校,连翻页都省了,这比解析 HTML 表格简单得多,也更稳定。

写代码时我也更注意字段映射和容错:接口的排名是字符串、总分是浮点数、标签是数组,入库前都要转换;用 UNIQUE(year, name_cn) 约束配合 ON CONFLICT,重复运行不会产生重复记录。通过这三道题,我把 requests 请求、正则/JSON 解析、异常重试和 SQLite 入库串成了一套完整的流程,也体会到“先抓包分析接口,再动手写爬虫”比直接写代码高效得多。