数据采集实践第二次作业
第二次作业:基于 requests 的网络爬虫实践(天气 / 股票 / 大学排名)
本次作业共三个任务,统一使用 Python 3 + requests 发起请求,分别配合 BeautifulSoup 解析 HTML、json 解析接口数据,最终全部落地到 SQLite3 数据库。按课程要求,本次作业集中放在 作业二/ 目录下,内部按题目分为 1/、2/、3/ 三个子目录,每个子目录包含该题的 .py 源文件、数据库文件、预览 CSV 和图片目录。
仓库根目录结构:
data-collection/ ├── .gitignore └── 作业二/ ├── 1/1.py 中国气象网 7 日天气预报 ├── 2/2.py 新浪股票行情爬虫 ├── 3/3.py 软科中国大学排名 2021 主榜 ├── generate_images.py 博客配图生成脚本 ├── requirements.txt 依赖清单 ├── 博客.md 博客园发布用 Markdown └── README.md 运行说明
一、作业①:中国气象网 7 日天气预报
1.1 作业代码与运行结果
(1)数据来源分析
中国气象网的天气预报页面是服务端渲染的静态 HTML,不需要抓包找接口。打开任意城市页面后可以观察到 URL 规律:
http://www.weather.com.cn/weather/{城市代码}.shtml
城市代码可以在页面的搜索框中切换城市后从地址栏获得。本次选取 5 个覆盖东中西部、具有代表性的城市:
| 城市 | 城市代码 |
|---|---|
| 北京 | 101010100 |
| 上海 | 101020100 |
| 广州 | 101280101 |
| 成都 | 101270101 |
| 西安 | 101110101 |
页面的 7 天预报集中在 <ul class="t clearfix"> 下的 7 个 <li> 中,每天的结构为:
<h1>13日(今天)</h1>:日期标签,只有“日号”,没有年份;<p class="wea">晴转多云</p>:天气现象;<p class="tem"><span>16</span>/<i>3℃</i></p>:最高温 / 最低温;<p class="win"><em><span title="北风"></span></em><i><3级</i></p>:风向与风力。
(2)核心代码
请求与解析部分(完整代码见 作业二/1/1.py):
BASE_URL = "http://www.weather.com.cn/weather/{code}.shtml"
CITY_CODES = {
"北京": "101010100",
"上海": "101020100",
"广州": "101280101",
"成都": "101270101",
"西安": "101110101",
}
def fetch_weather_page(session: requests.Session, city_code: str) -> str:
"""下载指定城市的天气预报页面。"""
url = BASE_URL.format(code=city_code)
response = session.get(url, timeout=15)
response.raise_for_status()
# 中国气象网页面使用 UTF-8 编码,显式指定防止中文乱码
response.encoding = "utf-8"
return response.text
def parse_weather(html: str, city: str, city_code: str) -> list[dict]:
"""解析单个城市页面中的 7 日预报数据。"""
soup = BeautifulSoup(html, "html.parser")
items = soup.select("ul.t.clearfix > li")
today = date.today()
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
records = []
for index, item in enumerate(items):
label_node = item.find("h1")
if label_node is None:
continue
date_label = label_node.get_text(strip=True)
day_match = re.search(r"(\d+)日", date_label)
if day_match is None:
continue
day_number = int(day_match.group(1))
weather_node = item.find("p", class_="wea")
weather = weather_node.get_text(strip=True) if weather_node else ""
# 气温节点形如“16/3℃”,第一段最高温、第二段最低温
temp_node = item.find("p", class_="tem")
temp_high, temp_low = None, None
if temp_node:
temp_text = temp_node.get_text(strip=True).replace("℃", "")
parts = [part for part in temp_text.split("/") if part.strip()]
if len(parts) == 2:
temp_high = int(parts[0])
temp_low = int(parts[1])
# 风向:<em> 中可能有两个 <span>(白天、夜间),去重后拼接
wind_nodes = item.select("p.win em span")
directions = []
for node in wind_nodes:
direction = (node.get("title") or node.get_text(strip=True)).strip()
if direction and direction not in directions:
directions.append(direction)
wind_direction = "转".join(directions)
level_node = item.select_one("p.win i")
wind_level = level_node.get_text(strip=True) if level_node else ""
records.append({
"city": city,
"city_code": city_code,
"forecast_date": resolve_forecast_date(day_number, today, index),
"date_label": date_label,
"weather": weather,
"temp_high": temp_high,
"temp_low": temp_low,
"wind_direction": wind_direction,
"wind_level": wind_level,
"crawl_time": crawl_time,
})
return records
日期补全函数:页面只有“13日”,需要结合抓取当天补全年月,并处理跨月、跨年的情况:
def resolve_forecast_date(day_number: int, today: date, index: int) -> str:
"""
把页面上只有“几号”的日期补全为完整日期(YYYY-MM-DD)。
- 正常情况下取今天的年、月;
- 若日号比今天小很多(超过 15 天),说明跨月了,月份加 1;
- 若月份超过 12,则年份加 1、月份回到 1。
"""
year, month = today.year, today.month
if day_number < today.day - 15:
month += 1
if month > 12:
month = 1
year += 1
try:
return date(year, month, day_number).isoformat()
except ValueError:
# 处理 31 号落到 30 天月份等极端情况,退化为“今天 + 序号”
return (today + timedelta(days=index)).isoformat()
入库部分使用 INSERT OR REPLACE,以“城市代码 + 日期”作为唯一键,重复运行不会产生脏数据:
def init_database(db_path: Path) -> sqlite3.Connection:
connection = sqlite3.connect(db_path)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS weather_forecast (
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_direction TEXT,
wind_level TEXT,
crawl_time TEXT,
UNIQUE (city_code, forecast_date)
)
"""
)
connection.commit()
return connection
运行方式:
cd 作业二/1
python 1.py
(3)运行结果
终端成功输出 5 个城市、每个城市 7 天,共 35 条记录,并写入 weather.db、导出 weather_preview.csv:
[成功] 北京(101010100) 共解析 7 天预报
[成功] 上海(101020100) 共解析 7 天预报
[成功] 广州(101280101) 共解析 7 天预报
[成功] 成都(101270101) 共解析 7 天预报
[成功] 西安(101110101) 共解析 7 天预报
共写入 35 条记录,数据库:weather.db,CSV:weather_preview.csv

数据库中的字段与页面一一对应:
| 字段 | 类型 | 含义 |
|---|---|---|
| city | TEXT | 城市名称 |
| city_code | TEXT | 中国气象网城市代码 |
| forecast_date | TEXT | 补全后的完整日期 |
| date_label | TEXT | 页面原始日期标签 |
| weather | TEXT | 天气现象 |
| temp_high / temp_low | INTEGER | 最高温 / 最低温 |
| wind_direction | TEXT | 风向(白天转夜间) |
| wind_level | TEXT | 风力等级 |
| crawl_time | TEXT | 抓取时间 |
1.2 作业心得
这个作业看起来最简单,但真正动手后发现问题都藏在细节里。
第一个坑是日期不完整。页面上写的是“13日(今天)”“14日(明天)”,只有日号没有年月,直接存进数据库以后没法做时间比较。我的处理办法是以抓取当天为基准补全年月,并额外判断“日号比今天小 15 天以上”就认为跨月,这样在月末抓取时也不会把下个月的 2 日错算成本月的 2 日。
第二个坑是文本格式不统一。气温是 16/3℃ 这种把最高温和最低温用斜杠拼在一起的字符串,需要先去掉 ℃ 再按 / 切分;风向节点里白天和夜间各有一个 <span>,广州这类城市两个都是“无持续风向”,如果直接拼接会得到“无持续风向转无持续风向”,所以我在拼接前做了去重。
第三个体会是 CSS 选择器比正则更稳。一开始想用正则直接抠 HTML,但页面标签层级一变就容易失效;改用 soup.select("ul.t.clearfix > li") 之后,代码可读性和稳定性都好了很多。这也让我理解了“解析 HTML 优先用解析库,解析接口才用 json”的分工。
二、作业②:股票信息定向爬虫(requests + json)
2.1 作业代码与运行结果
(1)接口抓包与选型
按题目提示进入东方财富网,F12 → Network → XHR,刷新行情列表可以看到列表数据由 push2.eastmoney.com/api/qt/clist/get 返回,核心参数为:
| 参数 | 含义 |
|---|---|
| pn | 页码 |
| pz | 每页条数 |
| fid=f3 | 按涨跌幅排序 |
| fs | 市场范围,m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23 表示沪深 A 股 |
| fields | 需要返回的字段,如 f2 最新价、f3 涨跌幅、f12 代码、f14 名称 |
但实测发现,东方财富该接口在本机首次请求成功后,连续请求会被服务端直接断开连接(RemoteDisconnected),换用 82.push2、push2delay 等镜像域名同样失败。因此改用题目同样允许的新浪股票接口:
https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData
该接口返回标准 JSON 数组,完全满足“requests + json 解析”的要求。关键参数:
| 参数 | 取值 | 说明 |
|---|---|---|
| page | 1, 2, 3... | 页码 |
| num | 100 | 每页条数,实测最大 100 |
| sort | symbol | 按股票代码排序 |
| asc | 1 | 升序 |
| node | hs_a | 沪深 A 股(含北交所) |
| symbol | 空 | 不按代码过滤 |
| _s_r_a | page | 按页取数据 |
(2)核心代码
请求与 JSON 解析(完整代码见 作业二/2/2.py):
API_URL = (
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
"Market_Center.getHQNodeData"
)
PAGE_SIZE = 100 # 每页条数,接口实测最大支持 100
MAX_PAGES = 60 # 安全上限,防止接口异常时无限翻页
REQUEST_INTERVAL = 0.3 # 每页之间的间隔,避免给服务器造成压力
def fetch_stock_page(session: requests.Session, page: int) -> list[dict]:
"""抓取指定页的股票列表数据。"""
params = {
"page": page,
"num": PAGE_SIZE,
"sort": "symbol",
"asc": 1,
"node": "hs_a",
"symbol": "",
"_s_r_a": "page",
}
response = session.get(API_URL, params=params, timeout=15)
response.raise_for_status()
# 接口返回标准 JSON 数组,但编码声明为 GBK,需要手动指定
response.encoding = "gbk"
text = response.text.strip()
if not text or text in ("null", "[]"):
return []
try:
data = json.loads(text)
except json.JSONDecodeError:
# 个别情况下接口会返回带 BOM 或非标准内容,按 UTF-8 再尝试一次
response.encoding = "utf-8"
data = json.loads(response.text.strip())
return data if isinstance(data, list) else []
字段清洗:接口返回的数值都是字符串,停牌股票可能返回空字符串或 -,统一转换成 None 再入库:
def to_float(value) -> float | None:
"""把接口返回的字符串安全转换为浮点数。"""
if value is None or value == "" or value == "-":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def normalize_stock(raw: dict, crawl_time: str) -> dict:
"""把接口原始字段转换为数据库字段。"""
return {
"symbol": raw.get("symbol", ""),
"code": raw.get("code", ""),
"name": raw.get("name", ""),
"price": to_float(raw.get("trade")),
"change_amount": to_float(raw.get("pricechange")),
"change_percent": to_float(raw.get("changepercent")),
"open_price": to_float(raw.get("open")),
"high_price": to_float(raw.get("high")),
"low_price": to_float(raw.get("low")),
"prev_close": to_float(raw.get("settlement")),
"volume": to_int(raw.get("volume")),
"amount": to_float(raw.get("amount")),
"turnover_rate": to_float(raw.get("turnoverratio")),
"pe_ratio": to_float(raw.get("per")),
"pb_ratio": to_float(raw.get("pb")),
"total_market_cap": to_float(raw.get("mktcap")),
"circulating_market_cap": to_float(raw.get("nmc")),
"trade_time": raw.get("ticktime", ""),
"crawl_time": crawl_time,
}
分页主循环:以“当前页返回条数不足 PAGE_SIZE”作为最后一页的判断条件:
for page in range(1, MAX_PAGES + 1):
raw_list = fetch_stock_page(session, page)
if not raw_list:
print(f"[完成] 第 {page} 页返回空数据,共 {page - 1} 页有效数据")
break
records = [normalize_stock(raw, crawl_time) for raw in raw_list]
save_records(connection, records)
all_records.extend(records)
if len(raw_list) < PAGE_SIZE:
print("[完成] 当前页不足一页,已到达最后一页")
break
time.sleep(REQUEST_INTERVAL)
数据库表 stock_quote 共 19 个字段,以股票代码作为唯一键:
| 字段 | 含义 |
|---|---|
| symbol / code / name | 带市场前缀的代码 / 纯代码 / 名称 |
| price / change_amount / change_percent | 最新价 / 涨跌额 / 涨跌幅 |
| open_price / high_price / low_price / prev_close | 今开 / 最高 / 最低 / 昨收 |
| volume / amount | 成交量(股)/ 成交额(元) |
| turnover_rate / pe_ratio / pb_ratio | 换手率 / 市盈率 / 市净率 |
| total_market_cap / circulating_market_cap | 总市值 / 流通市值(万元) |
| trade_time / crawl_time | 行情时间 / 抓取时间 |
(3)运行结果
脚本自动翻页 56 页,共抓取 5578 只股票:
[进度] 第 1 页,获取 100 条,累计 100 条
[进度] 第 2 页,获取 100 条,累计 200 条
...
[进度] 第 56 页,获取 78 条,累计 5578 条
[完成] 当前页不足一页,已到达最后一页
共写入 5578 条股票记录,数据库:stock.db

2.2 作业心得
这次作业最大的收获是学会了用 F12 定位真正的数据接口。以前爬页面只会 requests.get 整个 HTML,这次在 Network 面板里按 XHR 过滤、观察请求的 Query String,才明白列表型数据基本都是前端异步请求 JSON 再渲染的。题目里提到“f1、f2 对应不同数值、可按需删减参数”,我在东方财富接口上验证了这一点:只保留需要的字段后,返回体明显变小。
第二个体会是接口选型要考虑可用性。东方财富接口文档看起来最完整,但实际连续请求会被断连,调试了三个镜像域名都不行。这时候换用题目允许的新浪接口反而是正确选择——它不是“退而求其次”,而是让程序真正能跑通的工程决策。这一点让我意识到,爬虫代码的健壮性比“用最有名的网站”更重要。
第三个细节是编码和脏数据。新浪接口响应头声明 GBK,如果不手动设置 response.encoding = "gbk",中文股票名会变成乱码;停牌股票的字段可能是空字符串或 -,如果直接 float() 会抛异常。所以我在入库前统一做了类型清洗,保证数据库里的数值列是真正的数值类型,后续可以直接用 SQL 做排序和统计(比如按成交额取前 20)。
三、作业③:软科中国大学排名 2021 主榜
3.1 作业代码与运行结果
(1)F12 抓包分析过程(GIF 说明)
软科排名页面是前端动态渲染的,直接请求 https://www.shanghairanking.cn/rankings/bcur/2021 得到的 HTML 里没有院校列表,必须找到它背后的数据接口。操作步骤如下(可按此过程用 ScreenToGif 录制为 GIF):
- Chrome 打开
https://www.shanghairanking.cn/rankings/bcur/2021; - 按
F12打开开发者工具,切到 Network(网络) 面板,勾选 XHR 过滤; - 按
F5刷新页面,在请求列表中找到bcur?bcur_type=11&year=2021&page=1&page_size=...; - 点击该请求,在 Headers 中确认请求 URL 与参数,在 Preview / Response 中可以看到
rankings数组; - 右键该请求 → Copy → Copy as cURL,即可在代码中复现这次请求。
录制建议:使用免费工具 ScreenToGif(或 QQ 录屏),只录制“打开 F12 → 筛选 XHR → 刷新 → 点开 bcur 请求 → 查看 Response”这一段,保存为
3/images/f12-debug.gif,再在博客中插入即可。
下面这张图是脚本真实请求该接口后保存的响应内容,可以看到 code=200、msg=success、rankings 中的清华、北大等数据:

(2)接口参数与核心代码
| 参数 | 含义 |
|---|---|
| bcur_type=11 | 中国大学排名(主榜) |
| year=2021 | 排名年份 |
| page | 页码,从 1 开始 |
| page_size | 每页条数 |
实测发现一个有意思的现象:无论 page_size 传多少,接口都会一次性返回全部 582 所院校(传 5 也会返回 582 条)。因此代码里做了兼容处理:按“学校名 + 排名”去重,如果某一页没有带来任何新数据,就判定已经到末页,避免死循环。
请求与解析代码(完整代码见 作业二/3/3.py):
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
RANKING_YEAR = 2021
BCUR_TYPE = "11" # 11 = 中国大学排名(主榜)
PAGE_SIZE = 600
MAX_PAGES = 10
REQUEST_INTERVAL = 0.5
def fetch_ranking_page(session: requests.Session, page: int) -> dict:
"""请求指定页的排名数据。"""
params = {
"bcur_type": BCUR_TYPE,
"year": str(RANKING_YEAR),
"page": page,
"page_size": PAGE_SIZE,
}
response = session.get(API_URL, params=params, timeout=20)
response.raise_for_status()
payload = response.json()
if payload.get("code") != 200:
raise ValueError(f"接口返回异常:code={payload.get('code')}, msg={payload.get('msg')}")
return payload.get("data") or {}
def normalize_ranking(raw: dict, crawl_time: str) -> dict:
"""把接口原始字段转换为数据库字段。"""
ranking = raw.get("ranking")
score = raw.get("score")
return {
"year": RANKING_YEAR,
"ranking": int(ranking) if str(ranking).isdigit() else None,
"ranking_text": str(ranking or ""),
"school_name": raw.get("univNameCn", ""),
"school_name_en": raw.get("univNameEn", ""),
"province": raw.get("province", ""),
"category": raw.get("univCategory", ""),
"score": float(score) if score not in (None, "") else None,
"tags": "、".join(raw.get("univTags") or []),
"crawl_time": crawl_time,
}
去重抓取逻辑:
def fetch_all_rankings(session: requests.Session) -> list[dict]:
"""
抓取全部院校排名数据。
该接口的 page_size 实际会被忽略、一次返回全量数据,因此这里做了兼容处理:
用“学校名 + 排名”去重;若某一页没有带来任何新数据,则判定已到末页。
"""
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
collected: list[dict] = []
seen: set[tuple[str, str]] = set()
for page in range(1, MAX_PAGES + 1):
data = fetch_ranking_page(session, page)
rankings = data.get("rankings") or []
if not rankings:
print(f"[完成] 第 {page} 页没有数据,抓取结束")
break
before = len(collected)
for raw in rankings:
key = (raw.get("univNameCn", ""), str(raw.get("ranking", "")))
if key in seen:
continue
seen.add(key)
item = normalize_ranking(raw, crawl_time)
if item["school_name"]:
collected.append(item)
added = len(collected) - before
print(f"[进度] 第 {page} 页返回 {len(rankings)} 条,新增 {added} 条,累计 {len(collected)} 条")
if added == 0:
print("[完成] 本页未产生新数据,抓取结束")
break
time.sleep(REQUEST_INTERVAL)
return collected
数据库表 university_rank 结构:
| 字段 | 类型 | 含义 |
|---|---|---|
| year | INTEGER | 排名年份 |
| ranking | INTEGER | 数字排名(用于排序) |
| ranking_text | TEXT | 原始排名文本(兼容并列名次) |
| school_name | TEXT | 学校中文名 |
| school_name_en | TEXT | 学校英文名 |
| province | TEXT | 省市 |
| category | TEXT | 学校类型(综合/理工/师范等) |
| score | REAL | 总分 |
| tags | TEXT | 双一流、985、211 等标签 |
(3)运行结果
[进度] 第 1 页返回 582 条,新增 582 条,累计 582 条
[进度] 第 2 页返回 582 条,新增 0 条,累计 582 条
[完成] 本页未产生新数据,抓取结束
共写入 582 所院校,数据库:rank.db,CSV:rank_preview.csv

数据库查询验证(前 5 名):
SELECT ranking_text, school_name, province, category, score
FROM university_rank
ORDER BY ranking
LIMIT 5;
| 排名 | 学校 | 省市 | 类型 | 总分 |
|---|---|---|---|---|
| 1 | 清华大学 | 北京 | 综合 | 969.2 |
| 2 | 北京大学 | 北京 | 综合 | 855.3 |
| 3 | 浙江大学 | 浙江 | 综合 | 768.7 |
| 4 | 上海交通大学 | 上海 | 综合 | 723.4 |
| 5 | 南京大学 | 江苏 | 综合 | 654.8 |
3.2 作业心得
作业③是三个任务里技术含量最高的一个,因为它迫使我从“爬 HTML”转向“爬接口”。
第一个体会是动态页面必须抓包。一开始我直接用 requests 请求排名页面,返回的 HTML 有几十 KB,但里面根本没有学校名字,只有 <div id="app"></div> 这样的挂载点和一堆 JS 文件。这说明页面是 Vue/React 之类的框架在浏览器里渲染的,requests 拿到的只是“空壳”。真正有用的是 XHR 请求 api/pub/v1/bcur,它返回的 JSON 里才有完整的 rankings 数组。这也让我理解了题目为什么特意要求录制 F12 调试的 GIF——找到接口这一步才是这个作业的核心。
第二个体会是接口行为和文档不一致时要写容错。软科的 page_size 参数形同虚设,传 5 也返回全部 582 条。如果按常规“每页 30 条、循环 20 页”来写,就会把同一批数据重复插入 20 次。我改成用“学校名 + 排名”做集合去重,并检测本页新增数量,新增为 0 就停止,这样无论接口返回全量还是分页,代码都能正常结束。
第三个细节是并列名次。数据库里可以看到有两所学校同时排第 95 名,如果把 ranking 设成唯一键就会丢数据。因此我保留了 ranking_text 原始文本,唯一键改为“年份 + 学校名”,这样既不会因为并列名次丢记录,重复运行也不会产生重复数据。
四、总结
三个作业虽然都是“爬数据 + 存数据库”,但分别对应了三种典型场景:
| 作业 | 页面类型 | 解析方式 | 数据量 | 主要难点 |
|---|---|---|---|---|
| ① 天气预报 | 服务端渲染 HTML | BeautifulSoup | 35 条 | 日期补全、文本切分 |
| ② 股票行情 | JSON 接口(分页) | json.loads | 5578 条 | 接口可用性、编码、脏数据 |
| ③ 大学排名 | JSON 接口(全量) | json.loads | 582 条 | 动态渲染、接口去重 |
整体收获可以归纳为三点:
- 先分析、再写代码。天气页面看 DOM 结构,股票和排名用 F12 找 XHR 接口,方向对了后面就很顺;
- 爬虫代码要能“活着”。网络异常、接口变更、编码错误、脏数据都是常态,必须加超时、重试/跳过、类型清洗和去重;
- 数据落地要规范。统一用 SQLite3 建表,主键 + 唯一约束保证重复运行不产生脏数据,同时导出 CSV 方便验收。
浙公网安备 33010602011771号