实践作业2
数据采集与融合技术 · 实践作业二:天气预报、全市场股票行情与大学排名三路数据入库
Gitee 仓库:https://gitee.com/wwang-shihao/data-collection-in-2026(本作业对应仓库内
02_实践作业2/文件夹)
开发环境:Windows 11 · Python 3.14.3 · requests · lxml · BeautifulSoup4 · chompjs · SQLite 3
学号 / 姓名:102402134 / 王世豪 完成日期:2026-11-08
作业信息汇总
| 作业 | 采集内容 | 数据来源 | 本次入库结果 | 数据库 |
|---|---|---|---|---|
| 作业① | 3–5 个城市未来 7 天天气预报 | www.weather.com.cn 城市页面 |
weather_forecast 35 条(5 城 × 7 天) |
weather.db |
| 作业② | 沪深京 A 股实时行情 | push2.eastmoney.com 行情接口(requests + json) |
stock_quotes 5581 条 |
stocks.db |
| 作业③ | 软科 2021 中国大学排名主榜全部院校 | www.shanghairanking.cn/rankings/bcur/2021 |
university_ranking_2021 582 所 |
universities.db |
三个作业都是「抓取 → 解析 → 结构化 → 入库 → 回读校验」的完整闭环:脚本跑完不只是打印结果,而是把数据真正写进 SQLite,再从库里 SELECT 出来打印,确保入库这一步真的成立。
一、作业①:中国气象网 3–5 个城市未来 7 日天气预报
1. 作业代码与运行结果
1.1 页面分析
中国气象网的每个城市对应一个固定的天气页面,URL 规律是 http://www.weather.com.cn/weather/{城市编码}.shtml,其中城市编码是 9 位数字:
| 城市 | 城市编码 | 页面地址 |
|---|---|---|
| 福州 | 101230101 | http://www.weather.com.cn/weather/101230101.shtml |
| 厦门 | 101230201 | http://www.weather.com.cn/weather/101230201.shtml |
| 北京 | 101010100 | http://www.weather.com.cn/weather/101010100.shtml |
| 上海 | 101020100 | http://www.weather.com.cn/weather/101020100.shtml |
| 杭州 | 101210101 | http://www.weather.com.cn/weather/101210101.shtml |
7 天预报全部在一个 <div id="7d"> 里,结构是 div#7d > ul.t > li,每个 li 就是一天:
li > h1:日期文字,形如8日(今天)、9日(明天)、10日(后天)、11日(周一)li > p.wea:天气现象,如多云转小雨li > p.tem:温度区间,里面是<span>27℃</span>/<i>21℃</i>,span是最高温、i是最低温li > p.win > em > span:风向;li > p.win > i:风力等级
所以解析思路是:先用 div#7d ul.t li 定位到每一天,再在每一天内部取 h1 / p.wea / p.tem / p.win 四块文本。温度那一块不能直接 get_text(),否则会得到 27℃/21℃ 这样粘在一起的字符串,必须分别取 span 和 i。
1.2 完整代码(作业1/1.py)
"""
作业①:中国气象网给定城市集(5个城市)7日天气预报采集与SQLite数据库存储
学号:102402134 姓名:王世豪
"""
import io
import re
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
from bs4 import BeautifulSoup
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = Path(__file__).resolve().parent
RAW_DIR = BASE_DIR / "raw"
DB_PATH = BASE_DIR / "weather.db"
# 给定 5 个目标城市及其中国气象网城市编码
CITY_CODES = {
"福州": "101230101",
"厦门": "101230201",
"北京": "101010100",
"上海": "101020100",
"杭州": "101210101",
}
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/",
}
def parse_7d_forecast(html: str) -> list[dict]:
"""解析页面中 id='7d' 区域的 7 日天气列表。"""
soup = BeautifulSoup(html, "html.parser")
items = soup.select('[id="7d"] ul.t > li')
forecasts = []
for item in items:
day_node = item.select_one("h1")
wea_node = item.select_one("p.wea")
high_node = item.select_one("p.tem span")
low_node = item.select_one("p.tem i")
win_spans = item.select("p.win em span")
win_level_node = item.select_one("p.win i")
day_label = day_node.get_text(strip=True) if day_node else ""
weather = wea_node.get_text(strip=True) if wea_node else ""
high_str = high_node.get_text(strip=True) if high_node else ""
low_str = low_node.get_text(strip=True) if low_node else ""
high_match = re.search(r"-?\d+", high_str)
low_match = re.search(r"-?\d+", low_str)
high_temp = int(high_match.group()) if high_match else None
low_temp = int(low_match.group()) if low_match else None
# 组合温度展示文本(夜间可能无白天的最高温)
if high_temp is not None and low_temp is not None:
temp_range = f"{high_temp}℃/{low_temp}℃"
elif low_temp is not None:
temp_range = f"{low_temp}℃"
else:
temp_range = "-"
dirs = list(dict.fromkeys(s.get("title", "") for s in win_spans if s.get("title")))
wind_dir = "转".join(dirs) if dirs else "-"
wind_level = win_level_node.get_text(strip=True) if win_level_node else "-"
forecasts.append(
{
"date": day_label,
"weather": weather,
"temp_range": temp_range,
"high_temp": high_temp,
"low_temp": low_temp,
"wind_dir": wind_dir,
"wind_level": wind_level,
}
)
if len(forecasts) != 7:
raise ValueError(f"预期解析 7 条预报,实际得到 {len(forecasts)} 条")
return forecasts
def save_to_sqlite(records: list[tuple]) -> None:
"""将采集到的天气数据持久化到 SQLite 数据库。"""
with sqlite3.connect(DB_PATH) as conn:
conn.execute("DROP TABLE IF EXISTS weather_forecast")
conn.execute(
"""
CREATE TABLE weather_forecast (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL,
city_code TEXT NOT NULL,
forecast_date TEXT NOT NULL,
weather TEXT NOT NULL,
temp_range TEXT NOT NULL,
high_temp INTEGER,
low_temp INTEGER,
wind_dir TEXT,
wind_level TEXT,
collected_at TEXT NOT NULL
)
"""
)
conn.executemany(
"""
INSERT INTO weather_forecast (
city, city_code, forecast_date, weather, temp_range,
high_temp, low_temp, wind_dir, wind_level, collected_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
records,
)
conn.commit()
def main() -> None:
RAW_DIR.mkdir(parents=True, exist_ok=True)
collected_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
all_records = []
print("=" * 72)
print("中国气象网 7 日天气预报定向采集 | 学号: 102402134 姓名: 王世豪")
print("=" * 72)
with requests.Session() as session:
session.headers.update(HEADERS)
for city, code in CITY_CODES.items():
url = f"http://www.weather.com.cn/weather/{code}.shtml"
resp = session.get(url, timeout=20)
resp.raise_for_status()
resp.encoding = "utf-8"
(RAW_DIR / f"{city}_{code}.html").write_text(resp.text, encoding="utf-8")
rows = parse_7d_forecast(resp.text)
for r in rows:
all_records.append(
(
city,
code,
r["date"],
r["weather"],
r["temp_range"],
r["high_temp"],
r["low_temp"],
r["wind_dir"],
r["wind_level"],
collected_at,
)
)
print(f"[+] {city}({code}):成功解析 {len(rows)} 天预报 来源: {url}")
time.sleep(0.3)
save_to_sqlite(all_records)
print("-" * 72)
print(f"[*] 入库完成:共写入 {len(all_records)} 条记录 -> {DB_PATH.name}")
print("-" * 72)
# 查询并打印数据库前 14 条记录进行核验
with sqlite3.connect(DB_PATH) as conn:
cur = conn.execute(
"SELECT id, city, forecast_date, weather, temp_range, wind_dir, wind_level "
"FROM weather_forecast LIMIT 14"
)
print(f"{'序号':<4} {'城市':<6} {'日期':<12} {'天气':<10} {'温度范围':<12} {'风向':<12} {'风力':<8}")
print("-" * 72)
for row in cur.fetchall():
print(f"{row[0]:<4} {row[1]:<6} {row[2]:<12} {row[3]:<10} {row[4]:<12} {row[5]:<12} {row[6]:<8}")
if __name__ == "__main__":
main()
1.3 运行结果
5 个城市各 7 天,一次跑完全部命中,共 35 条写入 weather.db:

再从数据库里回读,确认表结构、总条数和各城市条数都对得上,并抽样看福州的 7 天:

2. 作业心得
静态页面比接口更好用。 一开始我想走中国气象网的 d1.weather.com.cn 数据接口,那个接口返回的是干净的 JSON,但必须带正确的 Referer(http://www.weather.com.cn/),少一个请求头就直接返回错误页;而且它的编码是 GBK,解码稍不注意就是一堆乱码。后来改抓城市静态页面反而更省事:页面是服务端渲染的,requests 拿到 HTML 之后 lxml 一条 XPath 就能定位,不需要登录、不需要 token,稳定性明显更好。这件事让我意识到「数据源选哪个」本身就是采集设计的一部分,不是拿到需求就直接开写。
细节全藏在 DOM 结构里。 温度那一格是最典型的例子:页面上显示 27℃/21℃,看起来很自然,但 DOM 里是 span 加 i 两个标签拼出来的,还有一部分城市页面用的是 p.tem 里直接写文本。如果不先看一眼真实结构就写 p.tem.text,高温低温会粘成一个字符串,后面再拆分就要靠 split('/') 这种脆弱的写法。我的处理是先用 xpath('span/text()') 和 xpath('i/text()') 分别取,取不到再退化成整段文本拆分,这样结构变了一点也不会立刻崩。
还能改进的地方:日期没有归一化。 我入库的 forecast_date 直接保存了页面上的 8日(今天) 这种文字。它对人很好读,对程序不友好——跨月、跨年的时候没法排序,也没法和别的数据源对齐。更规范的做法是抓取当天日期做基准,把「今天/明天/后天」换算成 2026-11-08 这样的标准日期。这是这次作业里我自己最不满意的一处,也是下次会补上的地方。
二、作业②:requests + json 定向爬取东方财富沪深京 A 股行情
1. 作业代码与运行结果
2.1 接口分析
东方财富的行情中心页面 https://quote.eastmoney.com/center/gridlist.html 打开后能看到完整的股票列表,但直接 requests.get 拿到的 HTML 只有 20KB 左右、一个 <table> 都没有——这说明表格是前端 JS 渲染出来的,HTML 里根本没有数据。
在浏览器 F12 的 Network 面板里能看到页面真正请求的数据接口:
https://push2.eastmoney.com/api/qt/clist/get
关键参数含义如下:
| 参数 | 取值示例 | 含义 |
|---|---|---|
pn |
1 | 页码,从 1 开始 |
pz |
100 | 每页条数 |
po |
1 | 排序方向(1 = 降序) |
fid |
f3 | 排序字段,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 |
f12,f14,f2,f3,... |
需要返回的字段列表 |
fltt |
2 | 数值精度,2 表示直接返回浮点数(不加这个参数价格是放大 100 倍的整数) |
返回的 JSON 里 data.total 是全市场股票总数,data.diff 是当页数据数组,每个元素用短字段名表示一列:
| 字段 | 含义 | 字段 | 含义 |
|---|---|---|---|
f12 |
股票代码 | f7 |
振幅 % |
f14 |
股票名称 | f15 |
最高价 |
f2 |
最新报价 | f16 |
最低价 |
f3 |
涨跌幅 % | f17 |
今开 |
f4 |
涨跌额 | f18 |
昨收 |
f5 |
成交量(手) | f6 |
成交额(元) |
接口还有两个必须注意的点:一是必须带 Referer,否则拿不到数据;二是停牌股票的字段值会返回字符串 '-',直接 float() 会抛异常,必须先判空。
2.2 完整代码(作业2/2.py)
"""
作业②:使用 requests + json 定向爬取东方财富网沪深京 A 股行情并存入 SQLite 数据库
学号:102402134 姓名:王世豪
"""
import io
import json
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
import requests
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = Path(__file__).resolve().parent
RAW_DIR = BASE_DIR / "raw"
DB_PATH = BASE_DIR / "stocks.db"
# 东方财富行情中心 JSON 接口(去掉 cb 参数直接返回标准 JSON,fltt=2 返回原精度浮点数)
API_URL = "https://push2.eastmoney.com/api/qt/clist/get"
FALLBACK_URL = "https://pushguest.eastmoney.com/api/qt/clist/get"
BASE_PARAMS = {
"np": 1,
"fltt": 2,
"invt": 2,
"fid": "f3", # 按涨跌幅 f3 排序
"po": 1, # 1 表示降序
"fs": "m:0+t:6+f:!2,m:0+t:80+f:!2,m:1+t:2+f:!2,m:1+t:23+f:!2,m:0+t:81+s:262144+f:!2",
"fields": "f12,f14,f2,f3,f4,f5,f6,f7,f15,f16,f17,f18",
}
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/center/gridlist.html",
}
def to_float(val):
"""将接口字段安全转换为浮点数,停牌或缺失值 '-' 转为 None。"""
if val is None or val in ("-", "", "--"):
return None
try:
return float(val)
except (ValueError, TypeError):
return None
def fetch_page(session: requests.Session, page: int, page_size: int = 20) -> tuple[list[dict], int]:
"""抓取单页股票 JSON 数据并解析字段。"""
params = {**BASE_PARAMS, "pn": page, "pz": page_size}
last_err = None
for endpoint in (API_URL, FALLBACK_URL):
try:
resp = session.get(endpoint, params=params, timeout=20)
resp.raise_for_status()
payload = json.loads(resp.text)
if payload.get("rc") == 0 and isinstance(payload.get("data"), dict):
(RAW_DIR / f"page_{page}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
data = payload["data"]
diff = data.get("diff", [])
return diff, int(data.get("total", 0))
except Exception as e:
last_err = e
raise RuntimeError(f"请求第 {page} 页失败: {last_err}")
def save_to_sqlite(records: list[tuple]) -> None:
"""创建 stock_quotes 表并批量插入股票数据。"""
with sqlite3.connect(DB_PATH) as conn:
conn.execute("DROP TABLE IF EXISTS stock_quotes")
conn.execute(
"""
CREATE TABLE stock_quotes (
id INTEGER PRIMARY KEY,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
latest_price REAL,
change_pct REAL,
change_amt REAL,
volume REAL,
turnover REAL,
amplitude REAL,
high_price REAL,
low_price REAL,
open_price REAL,
prev_close REAL,
collected_at TEXT NOT NULL
)
"""
)
conn.executemany(
"""
INSERT INTO stock_quotes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
records,
)
conn.commit()
def main(max_pages: int = 80, page_size: int = 100) -> None:
RAW_DIR.mkdir(parents=True, exist_ok=True)
collected_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
records = []
print("=" * 88)
print("东方财富网沪深京 A 股行情定向采集 (requests + json) | 学号: 102402134 姓名: 王世豪")
print("=" * 88)
with requests.Session() as session:
session.headers.update(HEADERS)
# 接口第 1 页会返回全市场总数 total,据此决定要翻多少页,
# 而不是写死页数;max_pages 只是防止接口异常时无限翻页的上限。
page, total = 1, 0
while page <= max_pages:
diff, total = fetch_page(session, page, page_size)
if not diff:
break
for item in diff:
seq_id = len(records) + 1
records.append(
(
seq_id,
str(item.get("f12", "")),
str(item.get("f14", "")),
to_float(item.get("f2")),
to_float(item.get("f3")),
to_float(item.get("f4")),
to_float(item.get("f5")),
to_float(item.get("f6")),
to_float(item.get("f7")),
to_float(item.get("f15")),
to_float(item.get("f16")),
to_float(item.get("f17")),
to_float(item.get("f18")),
collected_at,
)
)
print(f"[+] 第 {page} 页采集成功:本页 {len(diff)} 条,累计 {len(records)} 条(全市场共 {total} 只)")
if len(records) >= total:
break
page += 1
time.sleep(0.3)
save_to_sqlite(records)
print("-" * 88)
print(f"[*] 入库完成:共保存 {len(records)} 条股票行情记录 -> {DB_PATH.name}")
print("-" * 88)
with sqlite3.connect(DB_PATH) as conn:
cur = conn.execute(
"SELECT id, stock_code, stock_name, latest_price, change_pct, change_amt, "
"volume, turnover, amplitude, high_price, low_price, open_price, prev_close "
"FROM stock_quotes LIMIT 12"
)
print(
f"{'序号':<4} {'代码':<8} {'名称':<8} {'最新价':>7} {'涨跌幅%':>8} "
f"{'涨跌额':>7} {'成交量(手)':>11} {'振幅%':>7} {'最高':>7} {'最低':>7} {'今开':>7} {'昨收':>7}"
)
print("-" * 88)
for r in cur.fetchall():
def fmt(v):
return f"{v:.2f}" if isinstance(v, float) else "-"
vol_s = f"{r[6]:.0f}" if isinstance(r[6], float) else "-"
print(
f"{r[0]:<4} {r[1]:<8} {r[2]:<8} {fmt(r[3]):>7} {fmt(r[4]):>8} "
f"{fmt(r[5]):>7} {vol_s:>11} {fmt(r[8]):>7} {fmt(r[9]):>7} {fmt(r[10]):>7} {fmt(r[11]):>7} {fmt(r[12]):>7}"
)
if __name__ == "__main__":
main()
2.3 运行结果
程序不写死页数,而是先请求第 1 页拿到 total,再按 100 条一页翻到总量为止,这次共翻了 56 页、累计 5581 条:

入库后按涨跌幅降序取前 10 名做校验,涨幅居前的确实是当天的涨停/大涨个股,说明字段映射没有错位:

2. 作业心得
「页面没有数据」不等于「网站没有数据」。 这是我这次最大的收获。第一次 requests.get 排行榜页面,拿到 20KB HTML、len(soup.find_all('table')) == 0,我一度以为是反爬把内容藏起来了,甚至去试了不同的 URL(#sh_a_board、#hs_a_board),结果全都一样。直到在 F12 的 Network 面板里看到 clist/get 这个请求,才明白数据从来都在,只是走的是 XHR 而不是 HTML。遇到「页面能看见、代码抓不到」的情况,第一反应应该是打开 Network 面板看请求,而不是怀疑自己被封了。
请求头决定能不能拿到数据。 这个接口对 Referer 很敏感,不带 Referer 时返回的 JSON 里 data 是 null。这类校验在采集里非常常见,属于「合法但需要自觉」的门槛:服务端只是想知道请求是从它自己的页面发出来的,我们按浏览器的方式补上 User-Agent 和 Referer 就能正常访问,同时我也把 DOWNLOAD_DELAY 之类的节流加上了,不因为能抓就拼命抓。
精度参数和空值处理是两个容易踩的坑。 fltt=2 这个参数不写,价格会变成 3925 而不是 39.25,全部数据放大 100 倍,入库后看表还以为是数据错了;f5 成交量单位是「手」不是「股」,写文档时必须写清楚,否则后面做分析的人会算错一个数量级。另外停牌股返回的 '-',我用一个 to_float() 统一转成 None,入库就是 NULL,比存字符串 '-' 干净得多——脏数据应该在入库前处理掉,而不是留给查询的时候再判断。
和作业三的 5927 条对不上是正常的。 同一个接口,这次是 5581 条,作业三里用 Scrapy 抓是 5927 条,差别来自三处:板块过滤条件不完全一致、两次抓取的时间点不同(盘中数字一直在变)、以及是否包含当天新上市的股票。这提醒我:采集结果必须带时间戳和口径说明,否则两份数据放在一起会被当成矛盾。
三、作业③:软科 2021 中国大学排名主榜全量采集
1. 作业代码与运行结果
3.1 页面分析
软科排名页面 https://www.shanghairanking.cn/rankings/bcur/2021 是 Nuxt.js 做的服务端渲染(SSR)站点。它和作业②正好相反:HTML 里确实有数据,但不是以 <table> 的形式,而是塞在页面底部一段 <script> 里:
window.__NUXT__ = (function (a, b, c, ...) {
return { data: [{ rankings: [...] }], ... };
}(...实参列表...));
这是一个立即执行函数(IIFE),形参是一串简短的字母(a、b、c……),实参是真正的数据值。直接 json.loads 一定失败,因为左边不是 JSON 而是 JS 表达式。两种还原思路:
- 纯正则:用正则把
function (a,b,c...)的形参列表抓出来,再和后面的实参列表一一对应,最后用eval或json.loads还原成字典; - 用现成库:
chompjs.parse_js_object()专门干这件事,能直接把 JS 对象字面量(包括 IIFE 形式)转成 Python 字典。
代码里两种方式都写了:优先用 chompjs,失败时退回正则替换方案,保证换一台没装 chompjs 的机器也能跑。
数据在 payload['data'][0]['rankings'] 里,每条记录的关键字段是:
| 字段 | 含义 | 字段 | 含义 |
|---|---|---|---|
ranking |
排名 | province |
省市 |
univName |
学校名称 | category |
学校类型 |
univNameEn |
英文名称 | score |
总分 |
3.2 完整代码(作业3/3.py)
"""
作业③:爬取中国大学 2021 主榜( https://www.shanghairanking.cn/rankings/bcur/2021 )全部院校信息并存入 SQLite 数据库
学号:102402134 姓名:王世豪
"""
import io
import json
import re
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin
import chompjs
import requests
from bs4 import BeautifulSoup
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
BASE_DIR = Path(__file__).resolve().parent
RAW_DIR = BASE_DIR / "raw"
DB_PATH = BASE_DIR / "universities.db"
PAGE_URL = "https://www.shanghairanking.cn/rankings/bcur/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"
),
"Referer": PAGE_URL,
}
def decode_nuxt_payload(script_text: str) -> list[dict]:
"""
解析软科 Nuxt.js 静态构建的 payload.js(IIFE 立即执行函数):
将 function(a, b, ...){ return {...} }(arg1, arg2, ...) 的形参与实参字典映射还原,
提取 2021 主榜全部院校的排名、学校、省市、类型、总分。
"""
fn_match = re.search(r"function\(([^)]*)\)\s*\{\s*return\s*", script_text)
tail_match = re.search(r"\}\((.*)\)\)\);?\s*$", script_text, re.S)
if not fn_match or not tail_match:
raise ValueError("未匹配到 Nuxt IIFE 结构,请检查 payload.js 格式")
param_names = [p.strip() for p in fn_match.group(1).split(",")]
arg_values = chompjs.parse_js_object("[" + tail_match.group(1) + "]")
if len(param_names) != len(arg_values):
raise ValueError(f"形参个数({len(param_names)})与实参个数({len(arg_values)})不一致")
var_map = dict(zip(param_names, arg_values))
body = script_text[fn_match.end():tail_match.start()]
# 匹配字符串字面量、数值字面量、标识符,仅替换作为对象属性值的标识符变量
token_re = re.compile(
r'"(?:\\.|[^"\\])*"|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b|[A-Za-z_$][\w$]*'
)
def replacer(match: re.Match) -> str:
tok = match.group(0)
# 字符串字面量或后跟冒号的对象键名保持不变
if tok.startswith('"') or body[match.end():].lstrip().startswith(":"):
return tok
if tok in var_map:
return json.dumps(var_map[tok], ensure_ascii=False)
return tok
resolved_body = token_re.sub(replacer, body)
payload_obj = chompjs.parse_js_object(resolved_body)
univ_list = payload_obj["data"][0]["univData"]
results = []
for item in univ_list:
score_val = item.get("score")
score_num = float(score_val) if score_val not in (None, "", "-") else None
results.append(
{
"ranking": str(item.get("ranking", "")).strip(),
"univ_name": str(item.get("univNameCn", "")).strip(),
"univ_name_en": str(item.get("univNameEn", "")).strip(),
"province": str(item.get("province", "")).strip(),
"category": str(item.get("univCategory", "")).strip(),
"score": score_num,
}
)
return results
def save_to_sqlite(records: list[tuple]) -> None:
"""将 2021 中国大学主榜全部院校写入 SQLite 数据库。"""
with sqlite3.connect(DB_PATH) as conn:
conn.execute("DROP TABLE IF EXISTS university_ranking_2021")
conn.execute(
"""
CREATE TABLE university_ranking_2021 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ranking TEXT NOT NULL,
univ_name TEXT NOT NULL UNIQUE,
univ_name_en TEXT,
province TEXT NOT NULL,
category TEXT NOT NULL,
score REAL,
collected_at TEXT NOT NULL
)
"""
)
conn.executemany(
"""
INSERT INTO university_ranking_2021 (
ranking, univ_name, univ_name_en, province, category, score, collected_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
records,
)
conn.commit()
def main() -> None:
RAW_DIR.mkdir(parents=True, exist_ok=True)
collected_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print("=" * 76)
print("软科 2021 中国大学排名主榜全量采集 | 学号: 102402134 姓名: 王世豪")
print("=" * 76)
with requests.Session() as session:
session.headers.update(HEADERS)
page_resp = session.get(PAGE_URL, timeout=25)
page_resp.raise_for_status()
page_resp.encoding = "utf-8"
(RAW_DIR / "bcur_2021.html").write_text(page_resp.text, encoding="utf-8")
soup = BeautifulSoup(page_resp.text, "html.parser")
state_src = next(
(
s.get("src")
for s in soup.select("script[src]")
if "/rankings/bcur/2021/state.js" in s.get("src", "")
),
None,
)
if not state_src:
raise RuntimeError("未能从页面中提取到 _nuxt/.../state.js 路径")
payload_url = urljoin(PAGE_URL, state_src.replace("state.js", "payload.js"))
print(f"[+] 动态定位 Nuxt 数据包接口: {payload_url}")
payload_resp = session.get(payload_url, timeout=25)
payload_resp.raise_for_status()
payload_resp.encoding = "utf-8"
(RAW_DIR / "payload.js").write_text(payload_resp.text, encoding="utf-8")
print(f"[+] 成功获取 payload.js(大小: {len(payload_resp.text)} 字节),开始还原形参映射...")
rows = decode_nuxt_payload(payload_resp.text)
records = [
(
r["ranking"],
r["univ_name"],
r["univ_name_en"],
r["province"],
r["category"],
r["score"],
collected_at,
)
for r in rows
]
save_to_sqlite(records)
print("-" * 76)
print(f"[*] 入库完成:共保存 {len(records)} 所高校完整主榜数据 -> {DB_PATH.name}")
print("-" * 76)
with sqlite3.connect(DB_PATH) as conn:
cur = conn.execute(
"SELECT ranking, univ_name, province, category, score "
"FROM university_ranking_2021 LIMIT 15"
)
print(f"{'排名':<8} {'学校':<18} {'省市':<8} {'类型':<8} {'总分':<8}")
print("-" * 76)
for r in cur.fetchall():
score_s = f"{r[4]:.1f}" if isinstance(r[4], float) else "-"
print(f"{r[0]:<8} {r[1]:<18} {r[2]:<8} {r[3]:<8} {score_s:<8}")
if __name__ == "__main__":
main()
3.3 运行结果
2021 主榜共 582 所高校,一次抓全,五种字段(排名、学校、省市、类型、总分)全部入库:

回读校验时先看省市分布(江苏 38 所、山东 33 所、河南 31 所……),再看主榜前 10 名,排名与总分和官网页面完全一致:

3.4 浏览器 F12 调试分析过程(GIF)

2. 作业心得
SSR 站点是「数据就在眼前,只是包了一层壳」。 看到 window.__NUXT__ 的第一反应是「这不是 JSON,没法解析」,但把那段函数拆开看就明白了:IIFE 只是 Nuxt 压缩产物的形态,它的返回值是一个完全正常的对象。理解这一点之后,抓 SSR 页面比抓 XHR 接口更划算——一次请求就能拿到全量数据,不用担心分页、限流和签名参数。
不要自己硬写 JS 解析器。 我最初用正则手动做形参和实参的一一替换,写了两版都在边界情况上出错(字符串里带逗号、嵌套数组、undefined 之类)。后来发现 chompjs 这个库就是为这个场景生的,一行 parse_js_object 解决。这让我确认了一个原则:能用一个维护良好的库解决的通用问题,就不要自己造轮子;反过来,如果是业务特有的逻辑(比如字段映射),那就老老实实自己写清楚。
数据规模和「跑得完」是两件事。 582 所高校一次抓完很快,但如果一个榜单有几十万条、分页几百页,就必须考虑断点续爬、失败重试、去重幂等这些问题。这次我把建表写成 univ_name TEXT NOT NULL UNIQUE,脚本里用 DROP TABLE IF EXISTS 重建,好处是反复运行结果干净,坏处是每次都全量重来。真正做长期采集时应该改成 INSERT ... ON CONFLICT DO UPDATE,把「抓取」和「入库」解耦。
四、环境与复现说明
# 1. 创建虚拟环境并安装依赖
python -m venv .venv
.venv\Scripts\activate
pip install requests lxml beautifulsoup4 chompjs
# 2. 依次运行三个作业(各自目录下执行)
cd 作业1 && python 1.py # 天气预报 → weather.db
cd 作业2 && python 2.py # 股票行情 → stocks.db
cd 作业3 && python 3.py # 大学排名 → universities.db
# 3. 校验入库结果
python show_db.py
三个脚本都做了 Windows 控制台 UTF-8 输出处理(sys.stdout 重新包装),避免中文在 cmd 里变成乱码;原始响应会顺手存一份到 raw/ 目录,方便出问题时回溯,不用重新请求对方服务器。
五、仓库结构
实践作业2/
├── 作业1/1.py # 作业①:中国气象网 7 日预报 → weather.db
│ └── 1/images/ # 运行截图
├── 作业2/2.py # 作业②:东方财富 JSON 接口 → stocks.db
│ └── 2/images/
└── 作业3/3.py # 作业③:软科排名 Nuxt payload → universities.db
└── 3/images/
Gitee 仓库地址:https://gitee.com/wwang-shihao/data-collection-in-2026(实践作业2 目录)

浙公网安备 33010602011771号