数据采集作业二
数据采集作业二
码云仓库地址: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><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><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 条记录全部入库。


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 条。

作业③:软科中国大学排名 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 条,全部入库,排名与总分与网页一致。

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 秒,完全不影响体验。

浙公网安备 33010602011771号