数据采集实践第二次作业
数据采集实践第二次作业
学号:102402120 姓名:陈睿涛
运行环境:Windows 11 + Python 3.13.2,主要依赖
requests 2.34.2、beautifulsoup4 4.12.3,数据库用 Python 标准库自带的sqlite3。Gitee 仓库:https://gitee.com/ACAct/26qiushujucaijiyuronghejishu
代码和数据库分别在
work2/作业1、work2/作业2、work2/作业3三个文件夹里
一、作业①②:从中国气象网和东方财富抓数据,落进两个数据库
1.1 作业代码与运行结果
① 中国气象网:5 个城市的 7 日天气预报
题目要求
给定 3–5 个城市,抓取中国气象网( http://www.weather.com.cn/ )的 7 日天气预报,保存到数据库。
我选了北京、上海、广州、深圳、杭州五个城市,正好覆盖南北方,温度对比也明显。
数据在哪里
中国气象网的城市天气页地址长这样:
https://www.weather.com.cn/weather/101280101.shtml
地址里 101280101. 这串数字就是城市编码,换个编码就换个城市。常用的几个编码我整理成了下面这张表,写在代码的 CITIES 字典里:
| 城市 | 城市编码 | 城市 | 城市编码 |
|---|---|---|---|
| 北京 | 101010100 | 深圳 | 101280601 |
| 上海 | 101020100 | 杭州 | 101210101 |
| 广州 | 101280101 |
页面本身是服务端渲染好的,7 天预报就在 HTML 里,所以这题用 requests + BeautifulSoup 最直接。在浏览器里按 F12,用元素选择工具点一下某一天的天气,就能看到结构:

- 7 日预报整个放在
id="7d"的div里; - 里面是
ul.t,每个li对应一天; li里的h1是日期文字(12日(今天));p.wea是天气现象,值放在title属性里;p.tem里span是最高温、i是最低温;p.win里span的title是风向,i里是风力等级。
这里有三个小坑,都是实际写的时候才发现的:
- 编码。页面写的是
<meta charset="utf-8">,但响应头里没有charset,requests会按 HTTP/1.1 的默认值 ISO-8859-1 去解码,抓下来全是乱码。手动resp.encoding = "utf-8"一下就好了。 #7d不能直接当 CSS 选择器。我一开始写soup.select_one("#7d ul.t"),soupsieve直接报Malformed id selector,因为 CSS 里 id 不能以数字开头。后来改成soup.find(id="7d")再往下找ul,就没问题了。- 风向不在文本里。风向我们肉眼看是「北风」两个字,其实是图标字体的
span,文字内容是空的,真正的值在title属性上。只调get_text()会得到空字符串,必须读属性。
怎么入库
建表 weather_forecast,字段是城市、城市编码、预报日期、页面上的日期文字、天气、最高温、最低温、风向、风力、入库时间。用 UNIQUE(city, forecast_date) 做联合唯一约束,插入时 ON CONFLICT ... DO UPDATE,这样同一城市同一天重复抓取只会覆盖旧值,不会越跑越多。
日期这一列没有直接用页面上的「12日(今天)」,因为那段文字不带年月。7 日列表的第一条固定是今天,所以我用 今天 + 偏移天数 算出 2026-11-12 这样的标准日期,跨月也不会算错。
"""
作业①:抓取中国气象网 5 个城市的 7 日天气预报,并保存到 SQLite 数据库
================================================================================
目标网站:http://www.weather.com.cn/
实现思路:requests 请求城市天气页 → BeautifulSoup 解析 7 日预报列表 → sqlite3 入库
输出文件:weather.db(与脚本同目录),表名 weather_forecast
运行方式:python 1.py
"""
from __future__ import annotations
import re
import sqlite3
import sys
from datetime import date, datetime, timedelta
from pathlib import Path
import requests
from bs4 import BeautifulSoup
# --------------------------------------------------------------------- 常量配置
# 5 个城市及对应的城市编码,编码就是天气页面地址里 101 开头的那串数字
CITIES = {
"北京": "101010100",
"上海": "101020100",
"广州": "101280101",
"深圳": "101280601",
"杭州": "101210101",
}
# 7 日天气预报页面的地址模板,{code} 处填城市编码
URL_TEMPLATE = "http://www.weather.com.cn/weather/{code}.shtml"
# 站点对没有浏览器 UA 的请求比较敏感,这里伪装成 Chrome
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "http://www.weather.com.cn/",
}
# 页面里写的是 utf-8,但响应头没带 charset,
# requests 会按 ISO-8859-1 猜,中文就全乱码了,所以手动指定
PAGE_ENCODING = "utf-8"
TIMEOUT = 15 # 单次请求超时时间(秒)
FORECAST_DAYS = 7 # 页面固定给出 7 天
# 数据库文件放在脚本所在目录,跟着代码一起提交
DB_PATH = Path(__file__).resolve().parent / "weather.db"
# 终端表格的列名与列宽(中文按 2 个字符宽度计算)
COLUMNS = (
("日期", 12),
("天气", 14),
("最高温", 8),
("最低温", 8),
("风向", 12),
("风力", 16),
)
def fetch_html(url: str) -> str:
"""用 requests 抓取天气页面,返回解码正确的 HTML 文本。
:param url: 城市天气页地址
:return: HTML 字符串
:raises requests.HTTPError: 服务器返回非 2xx 状态码时抛出
"""
resp = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
resp.raise_for_status() # 状态码异常时直接报错,避免解析空页面
resp.encoding = PAGE_ENCODING # 关键:覆盖 requests 对编码的错误猜测
return resp.text
def parse_temperature(text: str) -> tuple[int | None, int | None]:
"""把温度文本解析成(最高温, 最低温)。
7 日列表里今天的格子只给一个温度(最低温),
其余 6 天是 "15℃/3℃" 这种「最高温/最低温」的格式,两种都要处理。
:param text: 温度单元格的文本,例如 "15℃/3℃"、"1℃"
:return: (最高温 或 None, 最低温 或 None)
"""
numbers = [int(value) for value in re.findall(r"-?\d+", text)]
if not numbers:
return None, None
if len(numbers) == 1:
return None, numbers[0]
return numbers[0], numbers[1]
def parse_wind(cell) -> tuple[str, str]:
"""从风力单元格里取出风向和风力等级。
单元格结构是 <p class="win"><em><span class="N" title="北风"></span></em>
<i><3级</i></p>,风向放在 span 的 title 属性里,风力在 <i> 标签里。
白天和夜间风向不同时会出现两个 span,这里取第一个。
:param cell: <p class="win"> 节点
:return: (风向, 风力等级),取不到时返回空字符串
"""
if cell is None:
return "", ""
direction = ""
icon = cell.find("span")
if icon and icon.get("title"):
direction = icon["title"].strip()
else:
night = cell.find("em")
if night is not None:
direction = night.get_text(strip=True)
level_tag = cell.find("i")
level = level_tag.get_text(strip=True) if level_tag else ""
return direction, level
def parse_forecast(html: str, city: str, city_code: str) -> list[dict]:
"""用 BeautifulSoup 解析页面中的 7 日预报列表。
7 日预报的容器 id 是 "7d",里面 ul.t 下的 li 每个对应一天。
注意不能用 "#7d" 这种 CSS 选择器,id 以数字开头会被当成非法选择器,
用 find(id="7d") 更稳。
:param html: 页面 HTML 文本
:param city: 城市名称
:param city_code: 城市编码
:return: 每天一条记录的列表
:raises RuntimeError: 页面结构变化、找不到 7 日列表时抛出
"""
soup = BeautifulSoup(html, "html.parser")
box = soup.find(id="7d")
if box is None:
raise RuntimeError(f"{city} 页面中找不到 id=7d 的 7 日预报容器")
forecast_list = box.find("ul", class_="t")
if forecast_list is None:
raise RuntimeError(f"{city} 页面中找不到 ul.t 预报列表")
items = forecast_list.find_all("li")
today = date.today()
rows: list[dict] = []
for offset, item in enumerate(items):
date_tag = item.find("h1")
weather_tag = item.find("p", class_="wea")
temperature_tag = item.find("p", class_="tem")
# 列表第一条固定是「今天」,所以日期用今天往后推 offset 天
forecast_date = today + timedelta(days=offset)
high, low = parse_temperature(
temperature_tag.get_text(" ", strip=True) if temperature_tag else ""
)
direction, level = parse_wind(item.find("p", class_="win"))
rows.append({
"city": city,
"city_code": city_code,
"forecast_date": forecast_date.isoformat(),
"date_label": date_tag.get_text(strip=True) if date_tag else "",
"weather": weather_tag["title"].strip() if weather_tag else "",
"temp_high": high,
"temp_low": low,
"wind_direction": direction,
"wind_level": level,
})
return rows[:FORECAST_DAYS]
def init_db(db_path: Path) -> None:
"""创建数据库和天气预报表(重复执行不会报错)。
:param db_path: 数据库文件路径
"""
with sqlite3.connect(db_path) as conn:
conn.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, -- 预报日期(YYYY-MM-DD)
date_label TEXT, -- 页面上的日期文字,如「12日(今天)」
weather TEXT, -- 天气现象,如「多云转阴」
temp_high INTEGER, -- 最高气温(℃),今天可能为空
temp_low INTEGER, -- 最低气温(℃)
wind_direction TEXT, -- 风向
wind_level TEXT, -- 风力等级
crawl_time TEXT NOT NULL, -- 入库时间
UNIQUE (city, forecast_date)
)
""")
def save_forecasts(rows: list[dict], db_path: Path = DB_PATH) -> int:
"""把预报数据写入数据库;同一城市同一天重复抓取时覆盖旧数据。
:param rows: parse_forecast 返回的记录列表
:param db_path: 数据库文件路径
:return: 写入(或更新)的记录条数
"""
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = """
INSERT INTO weather_forecast (
city, city_code, forecast_date, date_label, weather,
temp_high, temp_low, wind_direction, wind_level, crawl_time
) VALUES (
:city, :city_code, :forecast_date, :date_label, :weather,
:temp_high, :temp_low, :wind_direction, :wind_level, :crawl_time
)
ON CONFLICT (city, forecast_date) DO UPDATE SET
city_code = excluded.city_code,
date_label = excluded.date_label,
weather = excluded.weather,
temp_high = excluded.temp_high,
temp_low = excluded.temp_low,
wind_direction = excluded.wind_direction,
wind_level = excluded.wind_level,
crawl_time = excluded.crawl_time
"""
records = [dict(row, crawl_time=crawl_time) for row in rows]
with sqlite3.connect(db_path) as conn:
conn.executemany(sql, records)
return len(records)
def display_width(text: str) -> int:
"""计算字符串在终端里占的显示宽度,中文算 2,其他算 1。
:param text: 待计算的文本
:return: 显示宽度
"""
return sum(2 if ord(char) > 127 else 1 for char in str(text))
def pad(text: str, width: int) -> str:
"""把文本左对齐并补空格到指定显示宽度。
:param text: 待补齐的文本
:param width: 目标显示宽度
:return: 补齐后的字符串
"""
text = str(text)
return text + " " * max(0, width - display_width(text))
def print_table(rows: list[dict]) -> None:
"""按城市分组,把预报数据以表格形式打印到屏幕。
:param rows: 预报记录列表
"""
line = "-" * sum(width for _, width in COLUMNS)
header = "".join(pad(name, width) for name, width in COLUMNS)
for city in CITIES:
city_rows = [row for row in rows if row["city"] == city]
if not city_rows:
continue
print(f"\n[{city}]")
print(line)
print(header)
print(line)
for row in city_rows:
values = (
row["forecast_date"],
row["weather"],
f"{row['temp_high']}℃" if row["temp_high"] is not None else "-",
f"{row['temp_low']}℃" if row["temp_low"] is not None else "-",
row["wind_direction"],
row["wind_level"],
)
print("".join(pad(value, width) for value, (_, width) in zip(values, COLUMNS)))
print(line)
def main() -> None:
"""程序入口:逐个城市抓取、解析、入库,最后打印结果。"""
init_db(DB_PATH)
all_rows: list[dict] = []
for city, city_code in CITIES.items():
url = URL_TEMPLATE.format(code=city_code)
try:
html = fetch_html(url)
rows = parse_forecast(html, city, city_code)
except (requests.RequestException, RuntimeError) as exc:
# 单个城市失败不影响其他城市
print(f"[{city}] 抓取失败:{exc}", file=sys.stderr)
continue
all_rows.extend(rows)
print(f"[{city}] 解析到 {len(rows)} 天预报")
if not all_rows:
print("没有抓取到任何数据,请检查网络或页面结构", file=sys.stderr)
sys.exit(1)
saved = save_forecasts(all_rows)
print_table(all_rows)
print(f"\n共 {len(all_rows)} 条记录,已写入数据库:{DB_PATH}(本次写入 {saved} 条)")
if __name__ == "__main__":
main()
运行结果
五个城市各解析到 7 天,一共 35 条记录:


从数据库里把北京这 7 天读出来渲染成表格,长这样:

五个城市的最低气温放在一张折线图里,南北方的差距一目了然:北京在 11 月 17 日已经掉到零下,广州、深圳还在 18–23℃:

② 新浪财经:沪深 A 股行情列表
题目要求
用 requests 和 json 解析方法定向爬取股票信息,存入数据库。候选网站里我选了新浪财经,下面是从找接口到入库的完整过程。
接口是怎么找到的
新浪财经的行情页同样是前端渲染的,表格里的股票由页面加载后的 XHR 请求取回来。打开 https://finance.sina.com.cn/stock/ ,按 F12 → Network → Fetch/XHR,再刷新页面,可以看到一条 Market_Center.getHQNodeData 的请求:
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 |
第几页 / 每页多少条 |
sort |
排序字段,changepercent 是涨跌幅 |
asc |
0 表示降序,配合 sort 就是涨幅榜 |
node |
板块,hs_a 是沪深 A 股 |
symbol |
股票代码过滤,留空表示不过滤 |
返回的是一个 JSON 数组(不是对象),数组里每一项就是一只股票,常用字段如下:
| 字段 | 含义 | 字段 | 含义 |
|---|---|---|---|
code |
股票代码 | name |
股票名称 |
trade |
最新价 | changepercent |
涨跌幅(%) |
pricechange |
涨跌额 | volume |
成交量(股) |
amount |
成交额(元) | turnoverratio |
换手率(%) |
per |
市盈率 | pb |
市净率 |
high |
最高价 | low |
最低价 |
open |
今开 | settlement |
昨收 |
mktcap |
总市值(万元) | nmc |
流通市值(万元) |
有两个单位要特别注意:volume 是「股」,mktcap/nmc 是「万元」,和平时说的「手」「元」分别差 100 倍和 10000 倍,代码里统一换算之后再入库。
分页与去重
接口用 page 翻页,num=100 一页,sort=changepercent&asc=0 拿到的是涨幅从高到低。抓 3 页理论上就是 300 只,但行情是实时变化的:相邻两次请求之间排名会动,第 2 页可能重复第 1 页末尾的股票。所以代码里用字典按 code 去重,翻到累计 300 只再停,这次实际翻了 4 页。
"""
作业②:用 requests + json 定向爬取新浪财经沪深 A 股行情,并保存到 SQLite 数据库
================================================================================
数据来源:新浪财经行情列表接口(浏览器 F12 → Network 抓到的 XHR 请求)
https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData
实现思路:requests 请求 JSON 接口 → json 解析 → 分页取出股票列表 → sqlite3 入库
输出文件:stock.db(与脚本同目录),表名 stock_spot
运行方式:python 2.py
"""
from __future__ import annotations
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
import requests
# --------------------------------------------------------------------- 常量配置
# 新浪财经行情列表接口,返回的是 JSON 数组
API_URL = (
"https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
"Market_Center.getHQNodeData"
)
# 请求头:带上新浪财经的 Referer,更接近浏览器的真实请求
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Referer": "https://finance.sina.com.cn/",
}
# node=hs_a 表示沪深 A 股;排序字段用 changepercent(涨跌幅)
STOCK_NODE = "hs_a"
SORT_FIELD = "changepercent"
PAGE_SIZE = 100 # 每页条数
MAX_PAGES = 5 # 最多翻 5 页;行情实时变化时相邻页可能有重复,翻到够数为止
TARGET_COUNT = 300 # 目标股票数(涨幅前 300)
TIMEOUT = 15 # 单次请求超时时间(秒)
DB_PATH = Path(__file__).resolve().parent / "stock.db"
# 终端表格的列名与列宽(中文按 2 个字符宽度计算)
COLUMNS = (
("代码", 8),
("名称", 12),
("最新价", 9),
("涨跌幅", 9),
("成交额(亿)", 12),
("换手率", 8),
)
def fetch_page(page: int, page_size: int = PAGE_SIZE) -> list[dict]:
"""抓取一页股票列表。
接口参数说明:
page 页码,从 1 开始
num 每页条数
sort 排序字段,changepercent 是涨跌幅
asc 0 = 降序(涨幅从高到低)
node hs_a 表示沪深 A 股
symbol 股票代码过滤,留空表示不过滤
:param page: 页码
:param page_size: 每页条数
:return: 股票字典组成的列表
:raises requests.RequestException: 网络或 HTTP 状态异常
:raises ValueError: 返回内容不是合法 JSON
:raises RuntimeError: 返回结构不是列表时抛出
"""
params = {
"page": page,
"num": page_size,
"sort": SORT_FIELD,
"asc": 0,
"node": STOCK_NODE,
"symbol": "",
"_s_r_a": "page",
}
resp = requests.get(API_URL, params=params, headers=HEADERS, timeout=TIMEOUT)
resp.raise_for_status()
payload = resp.json() or []
if not isinstance(payload, list):
raise RuntimeError(f"接口返回结构异常:{type(payload).__name__}")
return payload
def to_float(value) -> float | None:
"""把接口里的数字安全地转成 float。
接口里价格是 "2.200" 这样的字符串,个别字段还可能缺失,
统一兜底成 None,避免后面 float() 直接报错。
:param value: 接口返回的原始值
:return: float 或 None
"""
try:
return float(value)
except (TypeError, ValueError):
return None
def parse_stock(item: dict) -> dict:
"""把接口返回的一条股票数据映射成数据库字段。
新浪接口的单位和常用单位不一样,这里统一换算:
volume(成交量)单位是「股」,除以 100 换成「手」;
mktcap(总市值)、nmc(流通市值)单位是「万元」,乘 10000 换成「元」。
:param item: 接口返回列表中的一项
:return: 对应 stock_spot 表字段的字典
"""
volume = to_float(item.get("volume"))
total_mv = to_float(item.get("mktcap"))
circ_mv = to_float(item.get("nmc"))
return {
"code": str(item.get("code", "")).strip(), # 股票代码
"name": str(item.get("name", "")).strip(), # 股票名称
"price": to_float(item.get("trade")), # 最新价
"change_pct": to_float(item.get("changepercent")), # 涨跌幅(%)
"change_amount": to_float(item.get("pricechange")), # 涨跌额
"volume": volume / 100 if volume is not None else None,
"amount": to_float(item.get("amount")), # 成交额(元)
"turnover": to_float(item.get("turnoverratio")), # 换手率(%)
"pe": to_float(item.get("per")), # 市盈率
"pb": to_float(item.get("pb")), # 市净率
"high": to_float(item.get("high")), # 最高价
"low": to_float(item.get("low")), # 最低价
"open_price": to_float(item.get("open")), # 今开
"prev_close": to_float(item.get("settlement")), # 昨收
"total_mv": total_mv * 10000 if total_mv is not None else None,
"circ_mv": circ_mv * 10000 if circ_mv is not None else None,
}
def init_db(db_path: Path) -> None:
"""创建数据库和股票行情表(重复执行不会报错)。
:param db_path: 数据库文件路径
"""
with sqlite3.connect(db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS stock_spot (
code TEXT PRIMARY KEY, -- 股票代码
name TEXT NOT NULL, -- 股票名称
price REAL, -- 最新价
change_pct REAL, -- 涨跌幅(%)
change_amount REAL, -- 涨跌额
volume REAL, -- 成交量(手)
amount REAL, -- 成交额(元)
turnover REAL, -- 换手率(%)
pe REAL, -- 市盈率
pb REAL, -- 市净率
high REAL, -- 最高价
low REAL, -- 最低价
open_price REAL, -- 今开
prev_close REAL, -- 昨收
total_mv REAL, -- 总市值(元)
circ_mv REAL, -- 流通市值(元)
crawl_time TEXT NOT NULL -- 入库时间
)
""")
def save_stocks(rows: list[dict], db_path: Path = DB_PATH) -> int:
"""把股票数据写入数据库,代码相同的股票覆盖旧记录。
:param rows: parse_stock 返回的记录列表
:param db_path: 数据库文件路径
:return: 写入(或更新)的记录条数
"""
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = """
INSERT INTO stock_spot (
code, name, price, change_pct, change_amount, volume, amount,
turnover, pe, pb, high, low, open_price, prev_close,
total_mv, circ_mv, crawl_time
) VALUES (
:code, :name, :price, :change_pct, :change_amount, :volume, :amount,
:turnover, :pe, :pb, :high, :low, :open_price, :prev_close,
:total_mv, :circ_mv, :crawl_time
)
ON CONFLICT (code) DO UPDATE SET
name = excluded.name,
price = excluded.price,
change_pct = excluded.change_pct,
change_amount = excluded.change_amount,
volume = excluded.volume,
amount = excluded.amount,
turnover = excluded.turnover,
pe = excluded.pe,
pb = excluded.pb,
high = excluded.high,
low = excluded.low,
open_price = excluded.open_price,
prev_close = excluded.prev_close,
total_mv = excluded.total_mv,
circ_mv = excluded.circ_mv,
crawl_time = excluded.crawl_time
"""
records = [dict(row, crawl_time=crawl_time) for row in rows]
with sqlite3.connect(db_path) as conn:
conn.executemany(sql, records)
return len(records)
def display_width(text: str) -> int:
"""计算字符串在终端里占的显示宽度,中文算 2,其他算 1。
:param text: 待计算的文本
:return: 显示宽度
"""
return sum(2 if ord(char) > 127 else 1 for char in str(text))
def pad(text: str, width: int) -> str:
"""把文本左对齐并补空格到指定显示宽度。
:param text: 待补齐的文本
:param width: 目标显示宽度
:return: 补齐后的字符串
"""
text = str(text)
return text + " " * max(0, width - display_width(text))
def print_table(rows: list[dict], limit: int = 10) -> None:
"""打印涨幅靠前的若干只股票。
:param rows: 股票记录列表
:param limit: 最多打印多少行
"""
line = "-" * sum(width for _, width in COLUMNS)
print(line)
print("".join(pad(name, width) for name, width in COLUMNS))
print(line)
for row in rows[:limit]:
amount = row["amount"]
values = (
row["code"],
row["name"],
f"{row['price']:.2f}" if row["price"] is not None else "-",
f"{row['change_pct']:.2f}%" if row["change_pct"] is not None else "-",
f"{amount / 1e8:.2f}" if amount is not None else "-",
f"{row['turnover']:.2f}%" if row["turnover"] is not None else "-",
)
print("".join(pad(value, width) for value, (_, width) in zip(values, COLUMNS)))
print(line)
def main() -> None:
"""程序入口:分页抓取新浪股票列表 → 解析 → 入库 → 打印结果。"""
init_db(DB_PATH)
# 用字典按股票代码去重:行情是实时变化的,同一只股票可能在相邻两页各出现一次
rows_by_code: dict[str, dict] = {}
for page in range(1, MAX_PAGES + 1):
try:
items = fetch_page(page)
except (requests.RequestException, ValueError, RuntimeError) as exc:
print(f"第 {page} 页抓取失败:{exc}", file=sys.stderr)
break
if not items: # 空列表说明已经翻到最后一页
print(f"第 {page} 页没有数据,停止翻页")
break
page_rows = [parse_stock(item) for item in items if item.get("code")]
added = 0
for row in page_rows:
if row["code"] not in rows_by_code:
rows_by_code[row["code"]] = row
added += 1
print(f"第 {page} 页:拿到 {len(page_rows)} 条,新增 {added} 只"
f"(累计 {len(rows_by_code)} 只)")
if len(rows_by_code) >= TARGET_COUNT: # 凑够目标数量就停
break
if len(page_rows) < PAGE_SIZE: # 不足一页,说明已经是最后一页
break
all_rows = list(rows_by_code.values())[:TARGET_COUNT]
if not all_rows:
print("没有抓取到任何股票数据,请检查网络或接口参数", file=sys.stderr)
sys.exit(1)
saved = save_stocks(all_rows)
print(f"\n涨幅前 {min(len(all_rows), 10)} 的股票:")
print_table(all_rows)
print(f"\n共 {len(all_rows)} 条记录,已写入数据库:{DB_PATH}(本次写入 {saved} 条)")
# 顺便报告库里累计的股票数量
with sqlite3.connect(DB_PATH) as conn:
count = conn.execute("SELECT COUNT(*) FROM stock_spot").fetchone()[0]
print(f"数据库 stock_spot 表当前共 {count} 条记录")
if __name__ == "__main__":
main()
运行结果

把 stock_spot 表里的涨幅前 10 读出来:

涨幅画成柱状图,第一名长联科技 14.07%,第十名好当家 10.22%,前十的门槛正好在 10% 附近:

1.2 作业心得
这两道题放在一起写,是因为它们刚好代表了两种完全不同的取数方式:天气页面是服务端渲染,数据老老实实躺在 HTML 里,用 BeautifulSoup 顺着标签找就行;股票行情是前端渲染,HTML 里只有一个空壳,数据靠 XHR 拿回来。判断方法也很简单——在页面上 Ctrl+U 看源代码,或者直接搜一个你知道页面上一定存在的内容(比如天气页里的「北京」),搜得到就是服务端渲染,搜不到就得去 Network 里找接口。
天气这题踩的坑基本都跟「HTML 不老实」有关。最典型的是编码:页面明明是 utf-8,响应头偏偏不写 charset,requests 就按 ISO-8859-1 解码,中文全部变成乱码。这个问题不修,后面解析出来的天气、风向全是错的,而且程序不会报错,只是安静地给你一堆乱码。另一个是 id="7d",我下意识写了 #7d 选择器,结果 soupsieve 直接抛异常,查了才知道 CSS 的 id 不能以数字开头。还有风向藏在 title 属性里这件事——用肉眼在 Elements 面板里能看到「北风」,但代码里 get_text() 得到的是空字符串,因为那是图标字体的伪文本。这三个问题都说明一件事:不能只看页面上显示了什么,要看数据到底存在哪个属性里。
股票这题让我第一次认真读了接口的参数。新浪这个接口参数不多:page/num 控制翻页,sort=changepercent&asc=0 决定按涨幅降序,node=hs_a 限定沪深 A 股。一开始我只会把浏览器里复制出来的 URL 原样请求一遍,后来改 num、改 sort 才发现同一个接口换个参数就是另一张榜。返回的是 JSON 数组,字段名都是英文缩写,比如 trade 是最新价、settlement 是昨收、per 是市盈率,得对着返回结果一个个认,不能想当然。价格在 JSON 里还是 "2.200" 这样的字符串,所以统一用 to_float() 转成数字,缺失的值兜底成 None。
这题还踩了一个分页的坑。按理说 3 页 × 100 条就是 300 只,可数据写进库之后一查只有 276 条。原因是行情实时变化:请求第 2 页的时候排名已经动了,第 1 页末尾的股票又出现在第 2 页开头。后来改成用字典按股票代码去重,翻到累计 300 只再停,实际翻了 4 页,库里正好 300 条。这件事让我意识到,分页接口不能假设「每页的数据互不重复」,尤其按实时指标排序的时候。另外新浪的 volume 单位是「股」、mktcap 是「万元」,入库前统一换成「手」和「元」,否则后面画图的数量级会差得离谱。数据库这边用了 ON CONFLICT 做 upsert,重复跑不会产生重复记录,调试的时候改一次跑一次,不用担心库里堆脏数据。
二、作业③:软科 2021 中国大学排名,F12 分析加载方式,一次拿全 582 所
2.1 作业代码与运行结果
题目要求
爬取中国大学 2021 主榜( https://www.shanghairanking.cn/rankings/bcur/2021 )所有院校信息并存入数据库,同时把浏览器 F12 调试分析的过程放进博客。最终要能整理出这样一张表:
| 排名 | 学校 | 省市 | 类型 | 总分 |
|---|---|---|---|---|
| 1 | 清华大学 | 北京 | 综合 | 969.2 |
页面是怎么加载数据的
软科现在把排名页做成了 Nuxt 静态站点:2021 主榜页的 HTML 里已经渲染了前 30 行(清华、北大、浙大……),完整数据放在 _nuxt/static/.../rankings/bcur/2021/payload.js 里,582 所全在里面,翻页和排序都是前端完成的。
所以按「F12 → Network → 筛选 Fetch/XHR → 刷新」去看,是看不到任何数据接口的:页面根本不发 AJAX,Network 里只有文档、payload.js 和图片请求。我第一次就是卡在这里,刷新了几次都没有 bcur。
页面不发请求,不代表站点没有接口。站点仍然提供 bcur 接口:
https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021
它返回的就是同一份榜单数据,data.rankings 里正好 582 条,一次返回全部院校、不分页。直接请求接口比解析 HTML 或 payload.js 都方便,所以代码用的是这个接口。
参数也很好理解:
bcur_type=11表示「中国大学排名(主榜)」,bcur_type是榜单类型,主榜固定是 11;year=2021是年份,改成 2020 就是去年的榜;- 请求头里带了排名页的
Referer,让请求更像浏览器发出来的;实测这个接口不带也能返回,但很多站点会校验来源,先带上更保险。
F12 里怎么验证这个接口
因为这条请求不是页面自己发的,需要手动触发一下。截图时我是这么做的:
- 打开排名页,按 F12 → Network;
- 在地址栏打开上面的接口地址,回车;
- Network 里会出现一条
bcur?bcur_type=11&year=2021(类型是 document),点开 Response 就是 JSON; - 把地址栏、这条请求和 JSON 一起截下来。
也可以不离开页面,在 Console 里执行 fetch('/api/pub/v1/bcur?bcur_type=11&year=2021').then(r => r.json()),Network 里会出现一条 Fetch/XHR 类型的 bcur 请求,效果一样。
下面这张图就是我在浏览器 F12 里查看这个接口返回数据的过程,能看到请求地址和返回的 JSON:

入库设计
接口里每所学校有这么几个字段:ranking(排名)、univNameCn(中文校名)、univNameEn(英文校名)、province(省市)、univCategory(类型)、score(总分)、univTags(双一流/985/211 标签)。
建表 university_ranking 的时候,排名我用 TEXT 存:接口返回的 ranking 本身就是字符串,按原样保存,既不用担心名次里出现「1-2」这种写法,也保留了原始格式。唯一约束用的是 UNIQUE(year, name),这样明年再抓 2022 的榜单,数据会按年份区分开,不会把 2021 的记录覆盖掉。
"""
作业③:分析软科排名页面的数据接口,爬取 2021 中国大学排名(主榜)全部院校并入库
================================================================================
目标页面:https://www.shanghairanking.cn/rankings/bcur/2021
数据来源:站点 bcur 接口(页面是 Nuxt 静态导出,首屏 30 行在 HTML/payload.js 中,
接口可一次返回全部院校)
https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021
实现思路:requests 请求该接口拿 JSON → json 解析 rankings 列表 → sqlite3 入库
输出文件:ranking.db(与脚本同目录),表名 university_ranking
运行方式:python 3.py
"""
from __future__ import annotations
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
import requests
# --------------------------------------------------------------------- 常量配置
# 软科榜单接口:bcur_type=11 表示「中国大学排名(主榜)」,year 是年份
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
PARAMS = {"bcur_type": 11, "year": 2021}
YEAR = 2021
# 带上页面的 Referer,更接近浏览器的真实请求(部分站点会校验来源)
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.shanghairanking.cn/rankings/bcur/2021",
}
TIMEOUT = 30 # 接口返回 26 万字符左右,超时给宽松一点
DB_PATH = Path(__file__).resolve().parent / "ranking.db"
# 终端表格的列名与列宽(中文按 2 个字符宽度计算)
COLUMNS = (
("排名", 6),
("学校", 24),
("省市", 10),
("类型", 10),
("总分", 10),
)
def fetch_rankings() -> dict:
"""请求软科榜单接口,返回解析后的 JSON。
:return: 接口返回的字典
:raises RuntimeError: 接口返回错误码时抛出
"""
resp = requests.get(API_URL, params=PARAMS, headers=HEADERS, timeout=TIMEOUT)
resp.raise_for_status()
payload = resp.json()
if payload.get("code") != 200:
raise RuntimeError(f"接口返回错误:code={payload.get('code')},msg={payload.get('msg')}")
return payload
def parse_rankings(payload: dict) -> list[dict]:
"""把接口返回的 rankings 列表解析成数据库记录。
接口里每所学校的字段:
ranking 排名 univNameCn 中文校名
province 省市 univCategory 学校类型(综合/理工/…)
score 总分 univTags 标签(双一流/985/211)
univNameEn 英文校名
:param payload: 接口返回的 JSON
:return: 每所学校一条记录的列表
"""
rankings = payload.get("data", {}).get("rankings", [])
rows: list[dict] = []
for item in rankings:
name = str(item.get("univNameCn", "")).strip()
if not name: # 没有校名的脏数据直接跳过
continue
rows.append({
"year": YEAR,
"ranking": str(item.get("ranking", "")).strip(),
"name": name,
"name_en": str(item.get("univNameEn", "")).strip(),
"province": str(item.get("province", "")).strip(),
"category": str(item.get("univCategory", "")).strip(),
"score": item.get("score"),
"tags": "/".join(item.get("univTags") or []),
})
return rows
def init_db(db_path: Path) -> None:
"""创建数据库和排名表(重复执行不会报错)。
:param db_path: 数据库文件路径
"""
with sqlite3.connect(db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS university_ranking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL, -- 榜单年份
ranking TEXT NOT NULL, -- 排名,个别学校是并列名次,所以用文本
name TEXT NOT NULL, -- 学校中文名
name_en TEXT, -- 学校英文名
province TEXT, -- 省市
category TEXT, -- 学校类型
score REAL, -- 总分
tags TEXT, -- 标签,如「双一流/985/211」
crawl_time TEXT NOT NULL, -- 入库时间
UNIQUE (year, name)
)
""")
def save_rankings(rows: list[dict], db_path: Path = DB_PATH) -> int:
"""把排名数据写入数据库,同一学校重复抓取时覆盖旧记录。
:param rows: parse_rankings 返回的记录列表
:param db_path: 数据库文件路径
:return: 写入(或更新)的记录条数
"""
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = """
INSERT INTO university_ranking (
year, ranking, name, name_en, province, category, score, tags, crawl_time
) VALUES (
:year, :ranking, :name, :name_en, :province, :category, :score, :tags, :crawl_time
)
ON CONFLICT (year, name) DO UPDATE SET
ranking = excluded.ranking,
name_en = excluded.name_en,
province = excluded.province,
category = excluded.category,
score = excluded.score,
tags = excluded.tags,
crawl_time = excluded.crawl_time
"""
records = [dict(row, crawl_time=crawl_time) for row in rows]
with sqlite3.connect(db_path) as conn:
conn.executemany(sql, records)
return len(records)
def display_width(text: str) -> int:
"""计算字符串在终端里占的显示宽度,中文算 2,其他算 1。
:param text: 待计算的文本
:return: 显示宽度
"""
return sum(2 if ord(char) > 127 else 1 for char in str(text))
def pad(text: str, width: int) -> str:
"""把文本左对齐并补空格到指定显示宽度。
:param text: 待补齐的文本
:param width: 目标显示宽度
:return: 补齐后的字符串
"""
text = str(text)
return text + " " * max(0, width - display_width(text))
def print_table(rows: list[dict], limit: int = 10) -> None:
"""打印排名靠前的若干所学校。
:param rows: 排名记录列表
:param limit: 最多打印多少行
"""
line = "-" * sum(width for _, width in COLUMNS)
print(line)
print("".join(pad(name, width) for name, width in COLUMNS))
print(line)
for row in rows[:limit]:
values = (
row["ranking"],
row["name"],
row["province"],
row["category"],
f"{row['score']:g}" if row["score"] is not None else "-",
)
print("".join(pad(value, width) for value, (_, width) in zip(values, COLUMNS)))
print(line)
def main() -> None:
"""程序入口:请求接口 → 解析 → 入库 → 打印前 10 名和统计信息。"""
print(f"正在请求接口:{API_URL}?bcur_type={PARAMS['bcur_type']}&year={PARAMS['year']}")
try:
payload = fetch_rankings()
rows = parse_rankings(payload)
except (requests.RequestException, ValueError, RuntimeError) as exc:
print(f"抓取失败:{exc}", file=sys.stderr)
sys.exit(1)
if not rows:
print("接口返回的 rankings 为空,页面结构可能已变化", file=sys.stderr)
sys.exit(1)
# 接口默认就是按排名排好的,这里再按数字排一次,防止个别数据乱序
rows.sort(key=lambda row: int(row["ranking"]) if row["ranking"].isdigit() else 10 ** 6)
init_db(DB_PATH)
saved = save_rankings(rows)
print(f"\n2021 主榜共解析到 {len(rows)} 所院校,前 10 名:")
print_table(rows)
print(f"\n共 {len(rows)} 条记录,已写入数据库:{DB_PATH}(本次写入 {saved} 条)")
with sqlite3.connect(DB_PATH) as conn:
count = conn.execute(
"SELECT COUNT(*) FROM university_ranking WHERE year = ?", (YEAR,)
).fetchone()[0]
print(f"数据库 university_ranking 表中 {YEAR} 年的记录数:{count}")
if __name__ == "__main__":
main()
运行结果
接口一次返回 582 所院校,全部写入数据库。前 10 名如下:

数据库里的前 10 名:

顺便按省市统计了一下上榜数量,江苏 38 所排第一,山东 33 所、河南 31 所紧随其后:

2.2 作业心得
这道题最大的收获,是学会了「先判断页面怎么加载,再决定怎么取数」。
第一题和第二题都是拿到 URL 就开始写解析,第三题一开始我照搬了同样的思路:请求页面、找 table、遍历 tr。拿到 HTML 一看,前 30 行(清华、北大、浙大)确实在里面,但再往下就没有了——582 所里只渲染了 30 所,翻页是前端做的。继续按页解析 HTML 也能做,但要处理分页状态,很别扭。
真正让我改变思路的是 F12 的 Network 面板。打开页面、切到 Network、刷新,列表里只有文档、payload.js 和图片,没有任何 XHR——说明数据不是请求来的,而是打包在静态文件里。顺着这个思路找到 payload.js,582 所全在里面。页面不发请求,那站点有没有独立的接口可用?试了一下 bcur,返回的 JSON 正好是这 582 条,字段还比 HTML 规整,于是就用它了。找到之后剩下的工作反而简单:requests.get 带上 Referer,resp.json() 取出 data.rankings,一个循环解析完。
参数方面,bcur_type=11 是主榜,year=2021 是年份,改 year 就能拿别的年份的榜。我还照着 F12 里的 Headers 把 User-Agent 和 Referer 一起带上了——实测这个接口不带 Referer 也能正常返回,但别的网站经常校验来源,对照 Headers 一项项补齐是个好习惯。
「一次拿全」和「翻页」的差别也很明显。页面上每页 30 行,582 所将近 20 页,如果按页去爬,要发将近 20 次请求,中间任何一页失败都得重来,还容易漏。接口直接返回全量,一次请求解决,数据完整性反而更有保障。当然这也要看网站,不是所有接口都这么大方,遇到分页接口就得老老实实写循环、加延时。
入库这部分,我特意把唯一约束设计成 (year, name) 而不是只按排名。排名每年都会变,同一个学校在 2021 和 2022 的名次不一样,按年份加校名做唯一键,既能保存历史,又能用 upsert 更新当年的数据,比每次清空重建稳妥。标签字段 univTags 是数组,我用 / 拼成字符串存进去,查的时候也方便。
最后说一下 F12 的过程记录。作业要求是录 GIF,我这次在博客里放了截图的位置。因为接口不是页面自己发的,截的时候要先在地址栏打开接口地址(或者在 Console 里 fetch 一下),让 Network 里出现这条请求,再把地址栏、请求和 JSON 一起截进去。录屏的话用 ScreenToGif 之类的工具,从打开 F12 开始录到 JSON 展开为止,几秒钟就够了。
三个程序写下来,最大的感受是:爬虫的难点从来不是 requests.get 那一行,而是搞清楚数据在哪、以什么形式存在。天气页面的数据在 HTML 的 title 属性里,股票数据在 XHR 返回的 JSON 里,软科的数据则被打包进了静态 payload.js、接口要自己找——三种情况对应三种完全不同的取数方式,但最后都要落到「解析成结构化的行,再写进数据库」这一步。
仓库里的目录结构如下,每个文件夹里除了脚本,还附带了跑出来的数据库文件,可以直接用 DB Browser for SQLite 打开查看:
work2/
├── 作业1/
│ ├── 1.py
│ └── weather.db # weather_forecast 表,35 条
├── 作业2/
│ ├── 2.py
│ └── stock.db # stock_spot 表,300 条
├── 作业3/
│ ├── 3.py
│ └── ranking.db # university_ranking 表,582 条
├── blog_images/ # 博客配图
├── 要求.md
└── 博客.md
运行方式(在 Anaconda 的 real 环境里):
conda activate real
cd work2/作业1 && python 1.py
cd ../作业2 && python 2.py
cd ../作业3 && python 3.py
三个脚本都不需要改路径,数据库会生成在脚本自己所在的目录里。

浙公网安备 33010602011771号