学号: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 日天气预报,并保存在数据库中。
实现思路
- 中国气象网的每个城市都有一个城市代码,7 日预报页面的地址是
http://www.weather.com.cn/weather/{城市代码}.shtml,例如北京是 101010100;本次选取北京、上海、广州、成都、武汉 5 个城市。 - 页面中
<ul class="t clearfix">里固定有 7 个<li>,依次对应今天、明天、后天等 7 天,每个<li>内包含日期、天气现象、最高/最低气温、风向和风力。 - 用正则表达式按
<li class="sky skyid...">切分每个预报日,再在块内分别提取p.wea的天气、p.tem的气温、p.win的风向与风力。 - 页面是 UTF-8 编码,但响应头没有声明字符集,因此必须显式设置
resp.encoding = "utf-8",否则中文会乱码。 - 数据写入 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><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 条记录)


数据库校验:运行结束后程序会自动回查,输出“本次写入 35 条,数据库现有 35 条,覆盖 5 个城市”,说明每个城市的 7 天数据都已正确入库。
Gitee 文件夹链接:https://gitee.com/qgnb666/2026/tree/master/作业2
1.1.2 作业②:新浪股票行情定向爬虫
题目要求:用 requests 和 json 解析方法定向爬取股票相关信息,并存储在数据库中,候选网站为东方财富网或新浪股票。
F12 抓包过程
- 打开新浪股票行情页
https://finance.sina.com.cn/stock/,按 F12 打开开发者工具,切到 Network(网络) 面板; - 刷新页面,在筛选框中选 Fetch/XHR,可以看到页面通过下面的接口加载沪深 A 股列表:
https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData - 观察请求参数:
page是页码,num是每页条数,sort=symbol&asc=1表示按代码升序,node=hs_a表示沪深 A 股; - 查看 Response,返回的是标准 JSON 数组,每一条包含
code、name、trade、pricechange、changepercent、settlement、open、high、low、volume、amount、per、pb、mktcap、nmc、turnoverratio等字段; - 另有一个
Market_Center.getHQNodeStockCount?node=hs_a接口返回股票总数(5578 只),用它计算总页数,避免写死页码。
接口返回的 JSON 数据(浏览器直接打开接口地址)

实现思路
- 用
requests.get()携带 UA 与新浪的 Referer,按页请求接口; - 用
json.loads()把响应解析成列表,再逐条取出需要的字段,"-"、""等无效值统一转换成None; - 按股票代码去重入库(
code作为主键),遇到 504、超时等临时错误自动重试; - 最后按成交额排序输出前 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 |
![]() |
||||
![]() |
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 发包分析
- 打开榜单页,按 F12 进入 Network 面板并刷新,榜单数据并不是直接写在 HTML 中,而是页面加载后异步请求接口渲染出来的;
- 在 XHR 请求中找到
api/pub/v1/bcur,请求参数为bcur_type=11(主榜)和year=2021; - 在 Preview/Response 中可以看到
data.rankings一次性返回全部 582 所院校,每条记录包含rankings(排名)、univNameCn(中文校名)、province(省市)、univCategory(类型)、score(总分)、univTags(双一流/985/211 标签)等字段; - 因此不需要像普通网页那样翻页,只要按这个接口请求一次,就能拿到整张榜单。
F12 调试录屏 GIF:录制步骤为“打开榜单页 → F12 → Network → 勾选 Fetch/XHR → 刷新 → 点击 bcur 请求 → 查看 Preview 中的 rankings 数组”,可用 ScreenToGif 或 LICEcap 录制 10~15 秒保存为
f12_bcur.gif,插入到本段末尾。
接口返回的 JSON 数据

实现思路
- 用 requests 请求
api/pub/v1/bcur,参数bcur_type=11&year=2021; - 用
json.loads()解析,检查返回的code == 200后取出data.rankings; - 逐条映射为数据库字段,标签数组用“、”拼接成一个字符串;
- 写入 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 |

数据库校验:程序最后从 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 入库串成了一套完整的流程,也体会到“先抓包分析接口,再动手写爬虫”比直接写代码高效得多。



