大数据采集与融合技术实验2
实验2:用 requests 和 json 解析方法爬取股票信息
这次实验的核心是不解析 HTML,直接抓接口的 JSON:股票、天气、院校排名这三类数据,
页面上的表格都是 JavaScript 异步渲染出来的,用 requests 拿到的那份 HTML 里根本没有数据。
所以整个实验的关键动作是同一个——按 F12 打开开发者工具,在 Network 面板里刷新页面,
找到真正返回数据的那条请求,把它的 URL 和参数抄下来,然后用 requests 请求它、用 json 解析它。
实验环境:Python 3.12、requests 2.32、BeautifulSoup 4.13、SQLite(Python 内置 sqlite3)。
源码都放在 Gitee 上,按小题分了文件夹,每个脚本可以独立运行。
作业2/
├── 2.py 实验2 东方财富行情,打印到屏幕
├── 2.1/2.1.py 实验2.1 中国气象网 7 日预报 → weather.db
├── 2.2/2.2.py 实验2.2 股票行情 → stock.db
├── 2.3/2.3.py 实验2.3 软科 2021 主榜全部院校 → ranking.db
└── 查看数据库.py 把三个库的表结构和数据打印出来
一、东方财富行情接口的抓包与 json 解析
(1)抓包定位接口,json 解析实现
打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board,这个页面右键"查看网页源代码"是看不到股票数据的,
表格全部由 JavaScript 请求接口后填充。F12 → 网络(Network) → 勾选 Fetch/XHR → 刷新页面,
列表里就会出现 push2.eastmoney.com/api/qt/clist/get 这条请求,响应体是一段纯 JSON。
把这条请求的 URL 拆开看,参数各有用处:
| 参数 | 作用 |
|---|---|
fs |
市场板块,m:0+t:6 是深市主板、m:1+t:2 是沪市主板,拼起来是全部 A 股 |
fields |
要返回的字段,原始 URL 里有三十多个,删到只留 8 个 |
pn / pz |
第几页、每页多少条 |
fid + po |
排序字段和方向,fid=f3&po=1 就是按涨跌幅降序 |
ut |
前端硬编码的固定 token,抄下来即可 |
np |
设成 1 返回纯 JSON,否则会套一层 JSONP 回调 |
字段名都是 f 加数字,含义要靠对照:f2 最新价、f3 涨跌幅、f4 涨跌额、f5 成交量(手)、
f6 成交额(元)、f8 换手率、f12 代码、f14 名称。所以代码里只要一次 response.json() 就能拿到全部数据,
不用再写一堆 find/select。
# -*- coding: utf-8 -*-
"""实验2:用 requests + json 解析方法爬取东方财富网股票行情信息。
数据来源:东方财富网行情中心 A 股列表
( https://quote.eastmoney.com/center/gridlist.html#hs_a_board )
该页面的表格由 JavaScript 异步渲染,直接请求页面拿不到数据;
按课件提示用浏览器 F12 抓包,可以看到真正的数据接口是
https://push2.eastmoney.com/api/qt/clist/get ,它返回的是 JSON,
因此这里用 requests 请求接口 + json 解析,不再需要解析 HTML。
"""
import sys
import unicodedata
import requests
# 抓包得到的行情列表接口(东方财富有多个镜像域名,主域名被限流时可换下一个)
API_TEMPLATE = "https://{host}/api/qt/clist/get"
API_HOSTS = ("push2.eastmoney.com", "33.push2.eastmoney.com", "82.push2.eastmoney.com")
# 接口要求携带的固定 token,直接照抄抓包 URL 里的 ut 参数
UT = "fa5fd1943c7b386f172d6893dbfba10b"
# 市场筛选条件:沪深主板、创业板、科创板、北交所的 A 股
MARKET_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"
)
# 只保留需要的字段(抓包 URL 里有三十多个字段,多余的可以删掉):
# f12 代码、f14 名称、f2 最新价、f3 涨跌幅、f4 涨跌额、
# f5 成交量(手)、f6 成交额(元)、f8 换手率
FIELDS = "f2,f3,f4,f5,f6,f8,f12,f14"
# 请求头:带上 Referer 和 UA,否则接口可能返回空数据
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/",
}
# 单次请求的超时时间(秒)
TIMEOUT = 15
# 打印的表头
TITLES = ("序号", "代码", "名称", "最新价", "涨跌幅", "涨跌额", "成交量", "成交额", "换手率")
def fetch_stocks(page: int = 1, page_size: int = 20) -> dict:
"""请求一页股票行情数据。
:param page: 页码,从 1 开始
:param page_size: 每页条数
:return: 接口返回的 data 部分,含 total(总数)和 diff(股票列表)
"""
params = {
"pn": page, # 页码
"pz": page_size, # 每页条数
"po": 1, # 1 表示降序
"np": 1, # 返回格式:1 为纯 JSON(不要 JSONP 回调)
"fltt": 2, # 价格保留 2 位小数
"invt": 2,
"fid": "f3", # 按涨跌幅排序
"ut": UT,
"fs": MARKET_FS,
"fields": FIELDS,
}
last_error = None
for host in API_HOSTS:
try:
response = requests.get(
API_TEMPLATE.format(host=host), params=params, headers=HEADERS, timeout=TIMEOUT
)
response.raise_for_status()
payload = response.json() # 接口本身就是 JSON,直接解析
if payload.get("rc") == 0:
return payload.get("data") or {}
last_error = RuntimeError(f"接口返回异常:{payload.get('msg')}")
except (requests.RequestException, ValueError) as exc:
last_error = exc
raise RuntimeError(f"行情接口不可用:{last_error}")
def parse_stocks(data: dict) -> list:
"""把接口返回的字段映射成可读的记录。
接口字段名是 f+数字,含义由东方财富前端约定:
f2 最新价 / f3 涨跌幅 / f4 涨跌额 / f5 成交量 / f6 成交额
/ f8 换手率 / f12 代码 / f14 名称。停牌股的数值可能返回 "-"。
:param data: fetch_stocks() 返回的 data
:return: 每只股票一个 dict 的列表
"""
rows = []
for item in data.get("diff") or []:
rows.append({
"code": item.get("f12", "-"),
"name": item.get("f14", "-"),
"price": item.get("f2"),
"change_pct": item.get("f3"),
"change_amt": item.get("f4"),
"volume": item.get("f5"),
"amount": item.get("f6"),
"turnover": item.get("f8"),
})
return rows
def fmt_num(value, suffix: str = "", digits: int = 2) -> str:
"""把数值格式化成字符串,停牌等缺失值统一显示为“-”。
:param value: 原始数值
:param suffix: 单位后缀,如 “%”
:param digits: 保留的小数位数
:return: 格式化后的字符串
"""
if not isinstance(value, (int, float)):
return "-"
return f"{value:.{digits}f}{suffix}"
def fmt_amount(value) -> str:
"""把成交额(元)换算成“万 / 亿”单位,方便阅读。
:param value: 成交额,单位元
:return: 带单位的字符串
"""
if not isinstance(value, (int, float)):
return "-"
if value >= 1e8:
return f"{value / 1e8:.2f}亿"
if value >= 1e4:
return f"{value / 1e4:.1f}万"
return f"{value:.0f}"
def display_width(text: str) -> int:
"""计算字符串在终端中占用的列数(中文等全角字符占 2 列)。
:param text: 待计算的字符串
:return: 显示宽度
"""
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in text)
def pad(text: str, width: int) -> str:
"""按显示宽度右侧补空格,让中英文混排的表格对齐。
:param text: 单元格内容
:param width: 该列宽度
:return: 补齐后的字符串
"""
return text + " " * max(0, width - display_width(text))
def print_table(rows: list) -> None:
"""按表格形式打印股票行情。
:param rows: parse_stocks() 返回的记录列表
"""
table = []
for index, row in enumerate(rows, 1):
table.append((
str(index),
row["code"],
row["name"],
fmt_num(row["price"]),
fmt_num(row["change_pct"], "%"),
fmt_num(row["change_amt"]),
fmt_num(row["volume"], digits=0),
fmt_amount(row["amount"]),
fmt_num(row["turnover"], "%"),
))
widths = [display_width(t) for t in TITLES]
for row in table: # 列宽取表头与数据行的最大值
for i, cell in enumerate(row):
widths[i] = max(widths[i], display_width(cell))
print(" ".join(pad(t, widths[i]) for i, t in enumerate(TITLES)))
print("-" * (sum(widths) + 2 * (len(widths) - 1)))
for row in table:
print(" ".join(pad(cell, widths[i]) for i, cell in enumerate(row)))
def main() -> None:
"""程序入口:请求接口 → 解析 JSON → 打印行情。"""
print("正在请求东方财富行情接口……")
try:
data = fetch_stocks(page=1, page_size=20)
except (requests.RequestException, ValueError, RuntimeError) as exc:
message = str(exc)
if "ProxyError" in message or "proxy" in message.lower():
# 本机开了 *** 之类的代理软件时,push2.eastmoney.com 常被规则拦掉
print("数据获取失败:东方财富行情接口 push2.eastmoney.com 被本机代理拦截。")
print("处理办法:在代理软件里把 eastmoney.com 设为直连(DIRECT),")
print("或者临时把代理切到“直连模式”,然后重新运行本脚本。")
else:
print(f"数据获取失败:{message}")
sys.exit(1)
rows = parse_stocks(data)
if not rows:
print("接口没有返回股票数据,可能参数已失效")
sys.exit(1)
print(f"沪深京 A 股共 {data.get('total')} 只,下面按涨跌幅排序显示第 1 页:\n")
print_table(rows)
print(f"\n本页 {len(rows)} 条,数据来源:东方财富网行情中心")
if __name__ == "__main__":
main()
运行结果,抓到的总数是 5580 只,这里按涨跌幅降序显示第 1 页:

(2)心得体会
这次最直接的感受是:抓包比解析 HTML 省事太多。之前爬网页要一层层找标签、处理空格和换行,
数据一改版就全废;接口返回的是结构化 JSON,字段稳定、类型清楚,代码量少一半。
过程中踩了两个坑。第一个是停牌股:f2 这些数值字段在停牌时返回的是字符串 "-",直接参与格式化会报错,
所以写了个 fmt_num 先判断类型再格式化。
另外中英文混排的表格对齐也折腾了一会儿。f-string 的 :<10 是按字符数补空格的,
而一个汉字在终端里占两列,所以"学校名称"和"北京"这种列会错位。后来用 unicodedata.east_asian_width
先算出每个字符串的显示宽度,再按宽度补齐空格,表格才真正对齐。
二、把抓到的数据写进 SQLite 数据库
(1)气象预报与股票行情的入库实现
这一部分要做两件事:气象网的 7 日预报、股票行情,都要落到数据库里。数据库用的是 Python 自带的 sqlite3,
不用装 MySQL,建库就是建一个 .db 文件,交作业时直接跟着代码一起提交。
实验 2.1 中国气象网 7 日天气预报。 城市代码可以从气象网的城市搜索里拿到,福州是 101230101,
把代码填进 http://www.weather.com.cn/weather/{code}.shtml 就得到对应城市的页面。
这个页面的数据是服务端直接渲染的,用 BeautifulSoup 解析 ul.t.clearfix 下的每个 li:
h1 是日期、p.wea 是天气现象、p.tem 里的 span 和 i 分别是最高温和最低温、
p.win 里 span 的 title 是风向、i 是风力等级。
# -*- coding: utf-8 -*-
"""实验2.1:爬取中国气象网给定城市的 7 日天气预报,并保存到 SQLite 数据库。
数据来源:中国气象网( http://www.weather.com.cn )
页面地址规则:http://www.weather.com.cn/weather/{城市代码}.shtml
城市代码可以在中国气象网的城市搜索里找到,如福州 101230101。
"""
import os
import sqlite3
import sys
from datetime import datetime
import requests
from bs4 import BeautifulSoup
# 数据库文件:放在脚本同目录下,随脚本一起提交
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weather.db")
# 页面地址模板
URL_TEMPLATE = "http://www.weather.com.cn/weather/{code}.shtml"
# 给定城市集合:城市名 -> 中国气象网城市代码
CITY_CODES = {
"福州": "101230101",
"厦门": "101230201",
"北京": "101010100",
"上海": "101020100",
"广州": "101280101",
}
# 请求头,模拟浏览器
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"
)
}
TIMEOUT = 15
# 建表语句:同一城市同一天只保留一条记录,方便重复运行
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS weather (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL, -- 城市名
city_code TEXT NOT NULL, -- 气象网城市代码
date TEXT NOT NULL, -- 日期,如“31日(今天)”
weather TEXT, -- 天气现象,如“多云转晴”
temp_high TEXT, -- 最高气温(℃)
temp_low TEXT, -- 最低气温(℃)
wind TEXT, -- 风向
wind_level TEXT, -- 风力
crawl_time TEXT, -- 抓取时间
UNIQUE (city, date)
)
"""
def fetch_html(url: str) -> str:
"""下载天气预报页面。
:param url: 页面地址
:return: 页面 HTML 文本
"""
response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
response.raise_for_status()
response.encoding = "utf-8" # 气象网页面是 utf-8,自动识别容易出错
return response.text
def parse_forecast(html: str) -> list:
"""解析 7 日天气预报列表。
页面结构:<ul class="t clearfix"> 下每个 <li> 是一天,
<h1> 日期、<p class="wea"> 天气、<p class="tem"> 高低温、
<p class="win"> 里 <em> 中的 <span title> 是风向、<i> 是风力。
:param html: 页面 HTML 文本
:return: 每天一条记录的列表
"""
soup = BeautifulSoup(html, "lxml")
ul = soup.find("ul", class_="t")
if ul is None:
raise RuntimeError("页面结构异常,没有找到 7 日预报列表")
days = []
for li in ul.find_all("li", recursive=False):
date_tag = li.find("h1")
wea_tag = li.find("p", class_="wea")
tem_tag = li.find("p", class_="tem")
if not (date_tag and wea_tag and tem_tag):
continue
# 高低温:<span>27</span>/<i>20℃</i>
high_tag = tem_tag.find("span")
low_tag = tem_tag.find("i")
temp_high = high_tag.get_text(strip=True) if high_tag else ""
temp_low = low_tag.get_text(strip=True).replace("℃", "") if low_tag else ""
# 风向取 <em> 中两个 <span title>,风力取 <i>
wind_tag = li.find("p", class_="win")
winds, wind_level = [], ""
if wind_tag:
winds = [s.get("title", "") for s in wind_tag.find_all("span") if s.get("title")]
level_tag = wind_tag.find("i")
wind_level = level_tag.get_text(strip=True) if level_tag else ""
days.append({
"date": date_tag.get_text(strip=True),
"weather": wea_tag.get_text(strip=True),
"temp_high": temp_high,
"temp_low": temp_low,
"wind": "转".join(winds) if winds else "",
"wind_level": wind_level,
})
return days
def save_to_db(city: str, city_code: str, days: list) -> None:
"""把某城市 7 日预报写入数据库。
:param city: 城市名
:param city_code: 城市代码
:param days: parse_forecast() 返回的记录列表
"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with sqlite3.connect(DB_PATH) as conn:
conn.execute(CREATE_SQL)
conn.executemany(
"""INSERT OR REPLACE INTO weather
(city, city_code, date, weather, temp_high, temp_low, wind, wind_level, crawl_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(city, city_code, d["date"], d["weather"], d["temp_high"],
d["temp_low"], d["wind"], d["wind_level"], now)
for d in days
],
)
def print_forecast(city: str, days: list) -> None:
"""在屏幕上打印某城市的 7 日预报。
:param city: 城市名
:param days: 预报记录列表
"""
print(f"\n【{city}】7 日天气预报")
for d in days:
temp = f"{d['temp_high']}~{d['temp_low']}℃"
print(f" {d['date']:<10}{d['weather']:<8}{temp:<10}{d['wind']} {d['wind_level']}")
def main() -> None:
"""程序入口:遍历城市集合,抓取并入库。"""
print(f"开始抓取 {len(CITY_CODES)} 个城市的 7 日天气预报……")
total = 0
for city, code in CITY_CODES.items():
url = URL_TEMPLATE.format(code=code)
try:
days = parse_forecast(fetch_html(url))
except (requests.RequestException, RuntimeError) as exc:
print(f"[{city}] 抓取失败:{exc}")
continue
save_to_db(city, code, days)
print_forecast(city, days)
total += len(days)
with sqlite3.connect(DB_PATH) as conn:
count = conn.execute("SELECT COUNT(*) FROM weather").fetchone()[0]
print(f"\n本次写入 {total} 条记录,数据库 weather.db 中共 {count} 条记录")
if __name__ == "__main__":
main()
五个城市各 7 天,一共 35 条记录写进 weather.db:

实验 2.2 股票行情入库。 这里换成了新浪财经的行情接口
(Market_Center.getHQNodeData),它返回一个 JSON 数组,参数 node=hs_a 表示沪深 A 股、
num=100 是每页条数、page 翻页,翻 5 页就是 500 条。
# -*- coding: utf-8 -*-
"""实验2.2:用 requests + json 定向爬取股票行情信息,并保存到 SQLite 数据库。
候选网站里选择新浪股票( http://finance.sina.com.cn/stock/ )。
按课件提示用 F12 抓包,可以看到行情列表页的数据由这个接口异步返回:
https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/
Market_Center.getHQNodeData?page=1&num=100&sort=changepercent&asc=0&node=hs_a
参数含义:
page / num 分页,num 每页条数(最大 100)
node hs_a 表示沪深 A 股全部股票
sort 排序字段,changepercent 为涨跌幅
asc 0 表示降序
接口直接返回 JSON 数组,用 json 解析即可,不用再解析 HTML。
"""
import json
import os
import sqlite3
import sys
import time
from datetime import datetime
import requests
# 新浪行情列表接口
API_URL = (
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
"Market_Center.getHQNodeData"
)
# 每页条数(接口上限 100)与最多抓取页数
PAGE_SIZE = 100
MAX_PAGES = 5
# 数据库文件:与脚本同目录
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stock.db")
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://finance.sina.com.cn/",
}
TIMEOUT = 15
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS stock_quote (
code TEXT PRIMARY KEY, -- 股票代码(6 位)
name TEXT NOT NULL, -- 股票名称
price REAL, -- 最新价
change_pct REAL, -- 涨跌幅(%)
change_amt REAL, -- 涨跌额
volume INTEGER, -- 成交量(股)
amount REAL, -- 成交额(元)
turnover REAL, -- 换手率(%)
crawl_time TEXT -- 抓取时间
)
"""
def fetch_page(page: int, page_size: int = PAGE_SIZE) -> list:
"""抓取一页行情数据。
:param page: 页码,从 1 开始
:param page_size: 每页条数
:return: 该页的股票记录列表(接口原始 JSON 数组)
"""
params = {
"page": page,
"num": page_size,
"sort": "symbol", # 按代码排序,这样每页取到的是连续区间的股票
"asc": 1, # 1 表示升序
"node": "hs_a", # 沪深 A 股
"symbol": "",
"_s_r_a": "page",
}
response = requests.get(API_URL, params=params, headers=HEADERS, timeout=TIMEOUT)
response.raise_for_status()
response.encoding = "utf-8"
data = json.loads(response.text) # 接口返回的就是 JSON 数组
return data if isinstance(data, list) else []
def to_float(value) -> float:
"""把接口里的字符串数值转成 float,无法转换时返回 None。
:param value: 原始值,如 "5.570"
:return: 浮点数或 None
"""
try:
return float(value)
except (TypeError, ValueError):
return None
def parse_page(items: list) -> list:
"""把一页原始数据整理成入库记录。
新浪字段:code 代码、name 名称、trade 最新价、changepercent 涨跌幅、
pricechange 涨跌额、volume 成交量(股)、amount 成交额(元)、
turnoverratio 换手率。
:param items: fetch_page() 返回的原始列表
:return: 每只股票一个 dict 的列表
"""
rows = []
for item in items:
code = (item.get("code") or "").strip()
if not code:
continue
rows.append({
"code": code,
"name": (item.get("name") or "").strip(),
"price": to_float(item.get("trade")),
"change_pct": to_float(item.get("changepercent")),
"change_amt": to_float(item.get("pricechange")),
"volume": int(to_float(item.get("volume")) or 0),
"amount": to_float(item.get("amount")),
"turnover": to_float(item.get("turnoverratio")),
})
return rows
def save_to_db(rows: list) -> int:
"""把股票记录写入数据库(代码相同则覆盖)。
:param rows: parse_page() 返回的记录列表
:return: 本次写入条数
"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with sqlite3.connect(DB_PATH) as conn:
conn.execute(CREATE_SQL)
conn.executemany(
"""INSERT OR REPLACE INTO stock_quote
(code, name, price, change_pct, change_amt, volume, amount, turnover, crawl_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(r["code"], r["name"], r["price"], r["change_pct"], r["change_amt"],
r["volume"], r["amount"], r["turnover"], now)
for r in rows
],
)
return len(rows)
def print_summary(rows: list) -> None:
"""打印本批数据中涨幅前 5 名与涨跌家数统计。
:param rows: 股票记录列表
"""
valid = [r for r in rows if r["change_pct"] is not None]
up = [r for r in valid if r["change_pct"] > 0]
down = [r for r in valid if r["change_pct"] < 0]
flat = len(valid) - len(up) - len(down)
print(f"\n本批涨幅前 5 名:")
print(f"{'代码':<10}{'名称':<12}{'最新价':<10}{'涨跌幅':<10}{'成交额':<12}换手率")
print("-" * 70)
for row in sorted(valid, key=lambda r: r["change_pct"], reverse=True)[:5]:
amount = row["amount"] or 0
amount_text = f"{amount / 1e8:.2f}亿" if amount >= 1e8 else f"{amount / 1e4:.1f}万"
print(f"{row['code']:<10}{row['name']:<12}{row['price']:<10.2f}"
f"{row['change_pct']:<10.2f}{amount_text:<12}{row['turnover']:.2f}%")
print(f"\n统计:上涨 {len(up)} 只,下跌 {len(down)} 只,平盘 {flat} 只")
def main() -> None:
"""程序入口:逐页抓取 → 入库 → 打印摘要。"""
print(f"开始抓取新浪财经 A 股行情,共 {MAX_PAGES} 页 × {PAGE_SIZE} 条……")
all_rows = []
for page in range(1, MAX_PAGES + 1):
try:
items = fetch_page(page)
except (requests.RequestException, ValueError) as exc:
print(f"[第 {page} 页] 抓取失败:{exc}")
break
if not items:
print(f"[第 {page} 页] 没有数据,已抓完全部股票")
break
rows = parse_page(items)
save_to_db(rows)
all_rows.extend(rows)
print(f" 第 {page} 页:{len(rows)} 条,已写入数据库")
time.sleep(1) # 适当停顿,避免请求过快
if not all_rows:
print("没有抓到任何数据,请检查网络或接口参数")
sys.exit(1)
print_summary(all_rows)
with sqlite3.connect(DB_PATH) as conn:
total = conn.execute("SELECT COUNT(*) FROM stock_quote").fetchone()[0]
print(f"数据库 stock.db 中共 {total} 条股票记录")
if __name__ == "__main__":
main()
500 条数据入库,涨 80 只、跌 411 只、平盘 9 只:

两个库建好之后可以用 python 查看数据库.py 一次把三个库的表结构和数据都打出来核对:
# -*- coding: utf-8 -*-
"""辅助脚本:一次性打印实验2 三个数据库的表结构和数据,方便截图写博客。
用法:在“作业2”目录下执行 python 查看数据库.py
(只是查库,不联网、不修改数据)
"""
import os
import sqlite3
# 脚本所在目录(作业2),三个 db 都在它的子目录里
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# (数据库相对路径, 表名, 打印几行, 需要显示的列)
TARGETS = [
("2.1/weather.db", "weather", 7,
("city", "date", "weather", "temp_high", "temp_low", "wind", "wind_level")),
("2.2/stock.db", "stock_quote", 6,
("code", "name", "price", "change_pct", "volume", "amount", "turnover")),
("2.3/ranking.db", "university_rank", 6,
("ranking", "name_cn", "province", "category", "score", "tags")),
]
def format_field_names(fields: list, width: int = 64) -> list:
"""把字段名拼成不超过指定宽度的若干行,避免一行太长不好截图。
:param fields: PRAGMA table_info 的结果
:param width: 每行最多多少字符
:return: 折行后的字符串列表
"""
names = [f[1] for f in fields if f[1] != "id"]
lines, current = [], ""
for name in names:
piece = name if not current else ", " + name
if len(current) + len(piece) > width:
lines.append(current)
current = name
else:
current += piece
if current:
lines.append(current)
return lines
def show_db(rel_path: str, table: str, limit: int, columns: tuple) -> None:
"""打印一个数据库的表结构、总记录数和前若干行数据。
:param rel_path: 相对“作业2”目录的数据库路径
:param table: 表名
:param limit: 打印多少行
:param columns: 需要显示的列
"""
db_path = os.path.join(BASE_DIR, rel_path)
print("=" * 72)
if not os.path.exists(db_path):
print(f"{rel_path} 不存在,请先运行对应的爬虫脚本")
return
with sqlite3.connect(db_path) as conn:
total = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
print(f"{rel_path} → 表 {table} 共 {total} 条记录")
# 表结构:字段名(类型见源码建表语句)
fields = conn.execute(f"PRAGMA table_info({table})").fetchall()
lines = format_field_names(fields)
print("表结构:" + lines[0])
for line in lines[1:]:
print(" " + line)
# 数据预览
sql = f"SELECT {', '.join(columns)} FROM {table} LIMIT {limit}"
print("-" * 72)
print(" | ".join(columns))
for row in conn.execute(sql):
print(" | ".join("" if v is None else str(v) for v in row))
print()
def main() -> None:
"""依次打印三个数据库。"""
for rel_path, table, limit, columns in TARGETS:
show_db(rel_path, table, limit, columns)
if __name__ == "__main__":
main()

(2)心得体会
建表的时候我特意加了唯一约束:气象表用 UNIQUE(city, date),股票表用 code 作主键。
配合 INSERT OR REPLACE,脚本重复运行多少次都不会产生重复记录——这一点在做实验的时候很关键,
因为调代码难免要反复跑,如果每次都往里追加,最后统计出来的条数就完全不可信了。"可重复执行"应该是写爬虫脚本的默认要求。
另一个教训是关于排序参数的。2.2 最初我用 sort=changepercent&asc=0(按涨跌幅降序)抓前 5 页,
结果统计出来"上涨 500 只,下跌 0 只"——因为我抓的本来就是涨幅榜的前 500 名,这个统计毫无意义。
改成 sort=symbol&asc=1 按代码排序,抓的才是连续区间里的股票,涨跌分布才有代表性。
数据的统计口径要和取数方式对得上,不然容易得出荒谬的结论。
还有个小细节:东财的成交量单位是"手"(1 手 = 100 股),新浪的 volume 是"股"。
两边混用的话数值会差 100 倍,入库前必须统一,我在表结构注释里写清楚了单位。
三、软科 2021 中国大学排名全量抓取
(1)从 30 条到 582 条:接口分析
实验 2.3 要求爬 2021 主榜的所有院校。直接请求 https://www.shanghairanking.cn/rankings/bcur/2021
看起来是成功的,但用 BeautifulSoup 数一下 table 里的 tr,只有 31 行——表头 1 行加 30 所学校,
和页面上"共 582 所学校"对不上。也就是说这个页面的 HTML 只带了第一页的数据。
按 F12 抓包(这一步的过程我录了 Gif,见文末),刷新页面后在网络面板里能看到两种情况:
一条是 rankings/bcur/2021/payload.js(238 KB,页面预渲染用的数据包),
另一条是我最终采用的接口 api/pub/v1/bcur?bcur_type=11&year=2021。后者的参数很直白:
bcur_type=11 表示中国大学排名主榜,year=2021 是年份,返回的 data.rankings 数组里就是全部 582 条。
每条记录的字段:univNameCn 中文名、univNameEn 英文名、province 省市、univCategory 学校类型、
score 总分、ranking 排名、univTags 标签(双一流/985/211),另外 indData 里是各项细分指标的分数。
入库时我把标签用 / 拼成一个字符串,并列排名形如 "10-12" 的取前面的整数。
# -*- coding: utf-8 -*-
"""实验2.3:爬取软科 2021 中国大学排名(主榜)全部院校信息并存入数据库。
思路:排名页面 https://www.shanghairanking.cn/rankings/bcur/2021 的表格由
前端异步渲染,直接请求 HTML 只能拿到第一页 30 条。按课件提示用 F12 抓包,
可以发现页面真正请求的数据接口是
https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021 ,
它一次返回主榜全部院校的 JSON 数据,因此直接请求该接口即可。
"""
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 表示中国大学排名主榜,year=2021 表示 2021 年
API_PARAMS = {"bcur_type": 11, "year": 2021}
# 榜单年份,入库时一并保存
YEAR = 2021
# 数据库文件:与脚本同目录
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ranking.db")
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"
)
}
TIMEOUT = 30
CREATE_SQL = """
CREATE TABLE IF NOT EXISTS university_rank (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL, -- 榜单年份
ranking INTEGER, -- 排名
name_cn TEXT NOT NULL, -- 学校中文名
name_en TEXT, -- 学校英文名
province TEXT, -- 省市
category TEXT, -- 学校类型(综合/理工/师范……)
score REAL, -- 总分
tags TEXT, -- 标签,如“双一流/985/211”
crawl_time TEXT, -- 抓取时间
UNIQUE (year, name_cn)
)
"""
def fetch_rankings() -> list:
"""请求接口并返回全部院校记录。
:return: 接口 data.rankings 中的院校列表
"""
response = requests.get(API_URL, params=API_PARAMS, headers=HEADERS, timeout=TIMEOUT)
response.raise_for_status()
payload = response.json() # 接口直接返回 JSON,用 json 解析即可
if payload.get("code") != 200:
raise RuntimeError(f"接口返回异常:{payload.get('msg')}")
rankings = (payload.get("data") or {}).get("rankings") or []
if not rankings:
raise RuntimeError("接口没有返回院校数据,参数可能已失效")
return rankings
def parse_universities(rankings: list) -> list:
"""把接口数据整理成入库用的记录。
:param rankings: fetch_rankings() 返回的院校列表
:return: 每所院校一个 dict 的列表
"""
rows = []
for item in rankings:
ranking = item.get("ranking")
try:
ranking = int(str(ranking).split("-")[0]) # 并列排名可能形如“10-12”
except (TypeError, ValueError):
ranking = None
rows.append({
"ranking": ranking,
"name_cn": (item.get("univNameCn") or "").strip(),
"name_en": (item.get("univNameEn") or "").strip(),
"province": item.get("province") or "",
"category": item.get("univCategory") or "",
"score": item.get("score"),
"tags": "/".join(item.get("univTags") or []),
})
return [row for row in rows if row["name_cn"]]
def save_to_db(rows: list) -> int:
"""把院校记录写入数据库。
:param rows: parse_universities() 返回的记录列表
:return: 本次写入的条数
"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with sqlite3.connect(DB_PATH) as conn:
conn.execute(CREATE_SQL)
conn.executemany(
"""INSERT OR REPLACE INTO university_rank
(year, ranking, name_cn, name_en, province, category, score, tags, crawl_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(YEAR, r["ranking"], r["name_cn"], r["name_en"], r["province"],
r["category"], r["score"], r["tags"], now)
for r in rows
],
)
return len(rows)
def print_summary(rows: list) -> None:
"""打印前 10 名和省份、类型分布,方便核对爬取结果。
:param rows: 院校记录列表
"""
print(f"\n{'排名':<6}{'学校名称':<14}{'省市':<8}{'类型':<6}{'总分':<8}标签")
print("-" * 76)
for row in sorted(rows, key=lambda r: r["ranking"] or 9999)[:10]:
print(f"{row['ranking']:<8}{row['name_cn']:<14}{row['province']:<8}"
f"{row['category']:<6}{row['score']:<8}{row['tags']}")
provinces = {}
categories = {}
for row in rows:
provinces[row["province"]] = provinces.get(row["province"], 0) + 1
categories[row["category"]] = categories.get(row["category"], 0) + 1
top_province = sorted(provinces.items(), key=lambda kv: kv[1], reverse=True)[:5]
print("\n院校数量前 5 的省市:" + "、".join(f"{k}({v})" for k, v in top_province))
print("按学校类型统计:" + "、".join(f"{k}({v})" for k, v in
sorted(categories.items(), key=lambda kv: kv[1], reverse=True)))
def main() -> None:
"""程序入口:请求接口 → 解析 → 入库 → 打印摘要。"""
print(f"正在请求软科 {YEAR} 中国大学排名(主榜)接口……")
try:
rankings = fetch_rankings()
except (requests.RequestException, ValueError, RuntimeError) as exc:
print(f"数据获取失败:{exc}")
sys.exit(1)
rows = parse_universities(rankings)
count = save_to_db(rows)
print(f"接口返回 {len(rankings)} 条,成功写入数据库 {count} 条")
print_summary(rows)
with sqlite3.connect(DB_PATH) as conn:
total = conn.execute("SELECT COUNT(*) FROM university_rank WHERE year = ?", (YEAR,)).fetchone()[0]
print(f"\n数据库 ranking.db 中 {YEAR} 年主榜共 {total} 条记录")
if __name__ == "__main__":
main()
入库 582 条,前 10 名和省份分布如下:

(2)心得体会
这个实验让我搞清楚了"页面能打开"和"数据在页面里"是两回事。上一步的东财是纯前端渲染,
连表格都没有;软科这里是服务端渲染了第一页,看起来有数据,但只有 30 条,很容易误以为已经爬全了。
如果我没有去核对"共 582 所"这个数字,就会交一个只有 30 条的结果上去。爬完之后一定要用页面上的总数量核对一遍。
软科这个站还有个有意思的地方:页面实际加载的是 Nuxt 预渲染生成的 payload.js,而公开的 API 是另一条路。
两条路拿到的数据是一致的(都是 582 条),但接口返回的是干净的 JSON,字段名也更友好,所以我选了接口。
写论文式的爬虫时,能拿到官方 JSON 接口就不要去解析 HTML,这是最省事也最稳定的做法。
F12 抓包的过程我录成了 Gif,附在这里:

附:源码仓库
全部代码、数据库文件和运行截图都在 Gitee 上(https://gitee.com/zzq-1419/shujucaiji),
按小题分了目录,每个脚本可以单独运行:
作业2/
├── 2.py 实验2 东方财富行情
├── 2.1/2.1.py 实验2.1 气象预报入库
├── 2.2/2.2.py 实验2.2 股票行情入库
├── 2.3/2.3.py 实验2.3 软科排名入库
├── 查看数据库.py 数据库内容预览
└── screenshots/ 运行截图与抓包 Gif
依赖只有 requests、beautifulsoup4、lxml,数据库用 Python 内置的 sqlite3,不需要额外安装。
浙公网安备 33010602011771号