数据采集第二次作业
数据采集实践第二次作业
姓名:陈瑀涵 学号:102402107
码云文件夹链接:2026data-collection-practice
本次作业使用 Python 完成三个定向数据采集任务,分别采集城市天气、股票行情和 2021 年中国大学排名。网络请求主要使用 requests,网页解析使用 BeautifulSoup,JSON 接口数据直接使用 response.json() 处理,最后统一保存到 SQLite 数据库中。
一 任务过程与结果
1 作业代码和效果展示
1.1 中国气象网 7 日天气预报
Gitee代码链接:task1_weather.py
本任务选择北京、上海、福州、广州和成都五个城市。程序根据城市代码拼接中国气象网页面地址,再从 ul.t.clearfix 中找到每天的预报节点,提取日期、天气、最高温度、最低温度、风向和风力等级。
首先配置需要采集的城市及其在中国气象网中的城市代码。使用字典保存城市名和城市代码,后续可以通过循环依次完成五个城市的采集:
# 键是最终写入数据库的城市名称,值是中国气象网使用的城市代码。
CITIES = {
"北京": "101010100",
"上海": "101020100",
"福州": "101230101",
"广州": "101280101",
"成都": "101270101",
}
# 使用占位符保留城市代码的位置,便于重复拼接不同城市的 URL。
WEATHER_URL = "http://www.weather.com.cn/weather/{city_code}.shtml"
请求页面后,通过 CSS 选择器定位 7 日天气列表。解析过程中先判断节点是否存在,避免网页中某个字段暂时缺失时出现空对象错误:
# 创建带请求头和自动重试功能的 Session。
session = create_session()
response = session.get(
WEATHER_URL.format(city_code=city_code), timeout=15
)
# 如果服务器返回 4xx 或 5xx 状态码,立即抛出异常。
response.raise_for_status()
response.encoding = response.apparent_encoding or "utf-8"
soup = BeautifulSoup(response.text, "html.parser")
forecast_items = soup.select("ul.t.clearfix > li")
# 页面通常正好返回 7 个 li 节点,这里再用切片限制为前 7 天。
for item in forecast_items[:7]:
date_node = item.select_one("h1") # 日期
weather_node = item.select_one("p.wea") # 天气现象
high_node = item.select_one("p.tem span") # 最高温度
low_node = item.select_one("p.tem i") # 最低温度
wind_node = item.select_one("p.win") # 风向所在节点
level_node = item.select_one("p.win i") # 风力等级
# 风向保存在 span 的 title 属性中,去重后用“转”连接。
wind_directions = []
if wind_node:
for span in wind_node.select("span"):
direction = span.get("title")
if direction and direction not in wind_directions:
wind_directions.append(direction)
record = WeatherRecord(
city=city,
forecast_date=date_node.get_text(strip=True) if date_node else "",
weather=weather_node.get_text(strip=True) if weather_node else "",
high_temperature=high_node.get_text(strip=True) if high_node else "--",
low_temperature=low_node.get_text(strip=True) if low_node else "--",
wind="转".join(wind_directions) or "--",
wind_level=level_node.get_text(strip=True) if level_node else "--",
collected_at=collected_at,
)
数据表使用 (city, forecast_date) 作为唯一约束。再次运行程序时,ON CONFLICT 会更新同一城市同一天的预报,不会不断产生重复记录:
CREATE TABLE IF NOT EXISTS weather_forecast (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city TEXT NOT NULL,
forecast_date TEXT NOT NULL,
weather TEXT,
high_temperature TEXT,
low_temperature TEXT,
wind TEXT,
wind_level TEXT,
collected_at TEXT NOT NULL,
UNIQUE(city, forecast_date) -- 城市和日期共同确定一条天气记录
)
-- excluded 表示本次准备插入的新数据;发生唯一键冲突时更新天气字段。
INSERT INTO weather_forecast (
city, forecast_date, weather, high_temperature,
low_temperature, wind, wind_level, collected_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(city, forecast_date) DO UPDATE SET
weather=excluded.weather,
high_temperature=excluded.high_temperature,
low_temperature=excluded.low_temperature,
wind=excluded.wind,
wind_level=excluded.wind_level,
collected_at=excluded.collected_at
图 1 天气采集程序运行结果
图 2 天气采集程序运行结果
图 3 天气数据存储结果
1.2 东方财富股票行情
Gitee代码链接:task1_weather.py
进入东方财富行情页面后,打开浏览器 F12,在 Network 面板中筛选 Fetch/XHR 请求,可以找到返回股票列表的接口。接口返回 JSON,其中 data.diff 是股票记录列表。本程序采集股票代码、名称、最新价、涨跌幅、涨跌额、成交量、成交额、最高价、最低价、开盘价和昨收价等字段。
部分字段对应关系如下:
| 接口字段 | 含义 |
|---|---|
| f12 | 股票代码 |
| f14 | 股票名称 |
| f2 | 最新价 |
| f3 | 涨跌幅 |
| f5 | 成交量 |
| f6 | 成交额 |
接口参数决定请求哪一页、每页数量、市场范围和返回字段。为了便于读者理解,我在代码中写明了关键参数和字段的用途:
PARAMS = {
"pn": "1", # 页码:第 1 页
"pz": "100", # 每页返回 100 条股票记录
"po": "1", # 排序方向
"fid": "f3", # 按涨跌幅字段排序
# 沪深京 A 股市场范围
"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,f4,f5,f6,f15,f16,f17,f18",
}
使用 params 传参后,requests 会自动完成 URL 查询字符串的拼接和编码:
response = create_session().get(STOCK_API, params=PARAMS, timeout=20)
response.raise_for_status() # 检查 HTTP 请求是否成功
payload = response.json()
# data 可能为 None,因此先用 or {} 转成空字典,再读取 diff。
stock_items = (payload.get("data") or {}).get("diff") or []
接口中的无数据字段可能是 -、空字符串或 null。下面的函数先统一进行清洗,再转换为浮点数;无法转换的值保存为 Python 的 None,写入 SQLite 后对应 NULL:
def number_or_none(value: object) -> float | None:
"""把有效数字转换为 float,把短横线、空值转换为 None。"""
if value in (None, "", "-"):
return None
try:
return float(value)
except (TypeError, ValueError):
# 单个异常字段不会导致整批股票数据采集失败。
return None
解析列表时使用接口字段构造 StockRecord。数据类让每条记录的字段名称和类型更明确:
records = [
StockRecord(
stock_code=str(item.get("f12", "")),
stock_name=str(item.get("f14", "")),
latest_price=number_or_none(item.get("f2")),
change_percent=number_or_none(item.get("f3")),
change_amount=number_or_none(item.get("f4")),
volume=number_or_none(item.get("f5")),
turnover=number_or_none(item.get("f6")),
highest=number_or_none(item.get("f15")),
lowest=number_or_none(item.get("f16")),
opening=number_or_none(item.get("f17")),
previous_close=number_or_none(item.get("f18")),
collected_at=collected_at,
)
for item in stock_items
]
股票行情会随时间变化,因此表中使用“股票代码 + 采集时间”作为唯一条件,既避免同一时刻的重复记录,也能保留不同时间的行情:
CREATE TABLE IF NOT EXISTS stock_quotes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL,
latest_price REAL,
change_percent REAL,
change_amount REAL,
volume REAL,
turnover REAL,
highest REAL,
lowest REAL,
opening REAL,
previous_close REAL,
collected_at TEXT NOT NULL,
UNIQUE(stock_code, collected_at)
)
图 4 东方财富股票接口分析
图 5 股票采集程序运行结果
图 6 股票行情数据存储结果
1.3 软科中国大学排名 2021 主榜
Gitee代码链接:task1_weather.py
进入 2021 中国大学排名页面后打开 F12,通过 Network 面板分析排名数据接口。进入2021中国大学排名页面后打开F12,通过Network面板查看排名数据接口。查看请求的Headers、Preview和Response,可以确认接口参数以及返回的院校排名数据。接口返回 JSON,院校列表位于返回数据中的 rankings 字段。程序提取排名、学校、省市、类型和总分,并把完整主榜写入数据库。
接口地址中的 year 参数指定榜单年份,bcur_type=11 对应中国大学排名主榜:
RANKING_API = "https://www.shanghairanking.cn/api/pub/v1/bcur"
PARAMS = {"bcur_type": "11", "year": "2021"}
# 通过 params 发送查询参数,并将服务器返回内容直接解析为 JSON。
response = create_session().get(RANKING_API, params=PARAMS, timeout=20)
response.raise_for_status()
payload = response.json()
data = payload.get("data") or {}
# rankings 是当前接口的榜单字段,results 用于兼容可能的字段调整。
ranking_items = data.get("rankings") or data.get("results") or []
考虑到接口字段名可能调整,编写 first_value() 依次检查几个候选字段名,返回第一个存在且非空的值:
def first_value(item: dict, *keys: str) -> str:
"""按顺序查找候选字段,返回第一个非空值。"""
for key in keys:
value = item.get(key)
if value not in (None, ""):
return str(value)
return ""
遍历所有院校时,不对列表进行切片,保证将 2021 主榜的全部院校写入数据库。每一项被整理为字段清晰的 UniversityRecord:
records: list[UniversityRecord] = []
for item in ranking_items:
records.append(
UniversityRecord(
ranking=first_value(item, "ranking", "rank", "rk"),
university=first_value(item, "univNameCn", "name", "univName"),
province=first_value(item, "province", "provinceName"),
university_type=first_value(item, "univCategory", "category", "type"),
total_score=first_value(item, "score", "totalScore"),
year=2021,
collected_at=collected_at,
)
)
写入时以“年份 + 学校名称”为唯一约束。同一年度同一学校再次采集时更新名次、类型和分数,便于反复测试程序:
INSERT INTO university_rankings (
ranking, university, province, university_type,
total_score, year, collected_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(year, university) DO UPDATE SET
ranking=excluded.ranking,
province=excluded.province,
university_type=excluded.university_type,
total_score=excluded.total_score,
collected_at=excluded.collected_at
浏览器 F12 分析过程录制如下。录制中展示了接口请求参数及返回的排名数据:
图 7 软科中国大学排名 2021 主榜接口分析过程
程序输出前 20 条记录用于快速核对,全部记录均写入数据库。
图 8 大学排名采集程序运行结果
图 9 大学排名数据存储结果
2 实现思路和问题处理
三个任务采用相似的处理流程:先发送请求,再检查响应状态,随后解析 HTML 或 JSON,整理成结构统一的记录,最后批量写入 SQLite。公共的请求重试、请求头和数据库连接代码被放在 common.py 中,避免在三个文件中反复编写。
公共请求函数通过 Session 复用连接,并对常见的临时错误状态码进行最多 3 次重试。backoff_factor 会让每次重试之间保留一定间隔,避免短时间内连续请求服务器:
def create_session() -> requests.Session:
"""创建带浏览器请求头和自动重试功能的会话。"""
session = requests.Session()
retry = Retry(
total=3, # 整个请求最多重试 3 次
connect=3, # 连接失败时重试
read=3, # 读取响应失败时重试
backoff_factor=0.8, # 重试等待时间按比例递增
status_forcelist=(429, 500, 502, 503, 504),
allowed_methods=("GET",),
)
session.mount("http://", HTTPAdapter(max_retries=retry))
session.mount("https://", HTTPAdapter(max_retries=retry))
session.headers.update({
"User-Agent": "Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9",
})
return session
数据库工具统一把数据库文件放在项目的 data 目录中,并使用 executemany() 批量写入,减少重复的数据库操作:
def connect_database(filename: str) -> sqlite3.Connection:
"""连接 data 目录中的 SQLite 数据库。"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(DATA_DIR / filename)
connection.execute("PRAGMA journal_mode=WAL")
return connection
def save_rows(connection, sql, rows) -> int:
"""批量执行 SQL,提交事务并返回写入数量。"""
materialized_rows = list(rows)
connection.executemany(sql, materialized_rows)
connection.commit()
return len(materialized_rows)
网络请求不一定每次都成功,因此程序设置了超时和自动重试,并使用 raise_for_status() 及时发现错误。
接口可能出现空值或短横线。股票程序使用 number_or_none() 统一转换数值,遇到无效内容时写入数据库的 NULL,避免强制转换导致程序中断。
数据库写入也进行了重复数据处理。天气记录以“城市和预报日期”为唯一条件,大学排名以“年份和学校”为唯一条件,重复执行时更新原记录;股票行情具有时间变化,使用“股票代码和采集时间”区分不同时刻的数据。
二 作业总结与体会
1 作业心得
完成这次作业后,我对网页中“看到的数据”和程序实际获取的数据之间的关系有了更清楚的认识。天气页面的数据直接包含在 HTML 中,适合使用 BeautifulSoup 按标签解析;股票和大学排名数据可以通过接口获取,更适合先用 F12 分析请求地址和参数,再使用 JSON 解析。选择正确的采集方式,比直接对整个网页使用正则表达式更稳定,也更容易理解。
我也体会到,把数据采集下来并不代表任务已经结束。采集结果还需要进行字段整理、空值处理、重复数据控制和数据库存储。加入唯一约束以后,多次运行程序不会产生大量无意义的重复记录;加入超时和重试以后,程序在网络短暂波动时也更稳定。这些处理让代码从“能够运行”变成“比较方便重复使用”。
2 不足与改进方向
目前程序采集的是运行时刻的页面和接口数据。如果网站以后调整网页标签、接口地址或字段名称,解析代码仍需要跟着修改。另外,股票程序当前一次采集 100 条行情,主要用于展示 JSON 接口采集和数据库存储过程,还没有实现自动翻页与定时采集。
后续可以增加日志文件,把每次采集的成功数量和失败原因保存下来;还可以把城市、股票数量和数据库路径放进配置文件,减少修改源代码的次数。如果需要长期运行,也可以配合定时任务每天自动采集,并对历史数据进行趋势分析和可视化。
浙公网安备 33010602011771号