数据采集作业三
环境:Windows 11 + Python 3.13 + requests + beautifulsoup4 + SQLite
仓库结构:作业1/1.py、作业2/2.py、作业3/3.py,每个文件夹内附脚本运行后生成的.db数据库文件
Gitee 文件夹链接:作业14总目录
一、实验过程与代码实现
(一)实验环境、技术选型与数据库设计
1. 技术选型
| 环节 | 选型 | 原因 |
|---|---|---|
| 网络请求 | requests |
相比 urllib 更简洁,支持 params 字典自动拼接 URL,支持自定义请求头,方便应对 Referer 校验 |
| HTML 解析 | BeautifulSoup + html.parser |
作业① 的气象网页面是服务端渲染的静态 HTML,DOM 结构清晰,用 CSS 选择器比正则更稳 |
| 数据解析 | json(json.loads) |
作业二、三的目标都是返回 JSON 的接口,直接解析字段比解析 HTML 可靠得多 |
| 数据存储 | sqlite3(Python 内置) |
免安装、免配置、单文件,随作业文件夹一起提交即可复现;SQL 语法与 MySQL 一致,换成 pymysql 只需改变连接方式 |
三份代码都遵循同一套工程结构:
常量配置区 → 网络请求函数 → 解析函数 → 建表/入库函数 → 展示函数 → main()
所有函数都写了 docstring,关键行有行内注释;脚本末尾统一用 if __name__ == "__main__": main() 作为入口。
2. 数据库设计
三份作业各自使用一个独立的数据库文件,与脚本放在同一目录,避免互相干扰。
weather.db — weather_forecast(7 日天气预报)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 自增主键 |
| city_name / city_code | TEXT | 城市名 / 气象网城市编码 |
| forecast_date | TEXT | 预报日期,如 9日(今天) |
| weather | TEXT | 天气现象 |
| temp_high / temp_low | INTEGER | 最高温 / 最低温(℃),今天无最低温时为 NULL |
| wind_dir / wind_level | TEXT | 风向 / 风力 |
| crawl_time | TEXT | 抓取时间 |
唯一索引建在 (city_code, forecast_date) 上,配合 INSERT OR REPLACE,重复运行只是刷新当天的预报,不会产生重复行。
stock.db — stock_quote(股票行情快照)
以 code(股票代码)为主键,共 19 个业务字段:名称、市场、最新价、涨跌幅、涨跌额、成交量、成交额、振幅、换手率、量比、市盈率、市净率、最高/最低/今开/昨收、总市值/流通市值、抓取时间。
ranking.db — university_rank(大学排名)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 自增主键 |
| rank_no | INTEGER | 排名(不唯一,存在并列名次) |
| univ_name | TEXT UNIQUE | 学校名称,作为唯一键 |
| province / univ_category | TEXT | 省市 / 类型 |
| total_score | REAL | 总分 |
| crawl_time | TEXT | 抓取时间 |
这里有一个踩坑点,详见作业三。
(二)三个爬虫的实现过程与运行结果
作业一 中国气象网:5 个城市的 7 日天气预报
(1)目标与 URL 规律
中国气象网的天气页面地址形如:
http://www.weather.com.cn/weather/101010100.shtml
其中 101010100 是城市编码(北京)。在首页搜索框输入城市名跳转后,地址栏里就能看到对应编码,因此"爬多个城市"本质上就是"替换 URL 中的这一段数字"。本次选取 5 个城市:
| 城市 | 编码 | 城市 | 编码 |
|---|---|---|---|
| 北京 | 101010100 | 成都 | 101270101 |
| 上海 | 101020100 | 西安 | 101110101 |
| 广州 | 101280101 |
(2)页面结构分析(F12 Elements)
用 F12 打开 Elements 面板,定位到未来 7 天的那一排卡片,可以发现 7 天的数据全部在同一个 <ul class="t clearfix"> 里,7 个 <li> 就是 7 天:
<ul class="t clearfix">
<li>
<h1>9日(今天)</h1>
<p title="晴" class="wea">晴</p>
<p class="tem"><span>4℃</span>/<i>-3℃</i></p>
<p class="win"><em><span title="西南风"></span></em><i><3级</i></p>
</li>
...
</ul>
两个细节需要注意:
- 今天这一天的温度只有一个
<i>(只有最高温),其它日期才是最高/最低两段,所以温度字段必须按/切分并允许最低温为空; - 风向不在文本里,而在
<span>的title属性上,得用tag.get("title")取。
**(3)实现思路
爬取中国气象网指定城市(5 个)的 7 日天气预报,并保存到 SQLite 数据库。
- 中国气象网的城市天气页 URL 形如 http://www.weather.com.cn/weather/101010100.shtml ,其中 101010100 是城市编码(北京),不同城市只需替换该编码。
- 页面中 7 日预报位于
<ul class="t clearfix">下的 7 个标签<li>内,使用 requests 获取源码、BeautifulSoup 解析 DOM,即可得到日期/天气/温度/风向/风力。 - 解析结果写入 SQLite 表 weather_forecast,(city_code, forecast_date) 建唯一索引,重复运行同一城市同一天的数据会被覆盖,便于反复调试。
运行环境:Python 3.8+,依赖 requests、beautifulsoup4
pip install requests beautifulsoup4
(4)代码
# -*- coding: utf-8 -*-
import re
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import requests
from bs4 import BeautifulSoup
# --------------------------------------------------------------------------- #
# 全局配置
# --------------------------------------------------------------------------- #
# 城市名 -> 中国气象网城市编码(编码可在页面搜索框中输入城市后从 URL 中获得)
CITY_CODES = {
"北京": "101010100",
"上海": "101020100",
"广州": "101280101",
"成都": "101270101",
"西安": "101110101",
}
# 天气页地址模板,%s 处填入城市编码
WEATHER_URL = "http://www.weather.com.cn/weather/{}.shtml"
# 伪装成浏览器,避免被服务端直接拒绝
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 = Path(__file__).with_name("weather.db")
# 每个城市请求之间的间隔(秒),控制访问频率,避免给对方服务器造成压力
REQUEST_INTERVAL = 1
# --------------------------------------------------------------------------- #
# 网络请求与解析
# --------------------------------------------------------------------------- #
def fetch_html(city_code: str) -> str:
"""请求指定城市编码的天气页面,返回解码后的 HTML 文本。
:param city_code: 中国气象网城市编码,例如 "101010100"
:return: 页面 HTML 源码
"""
url = WEATHER_URL.format(city_code)
# 气象网页面为 UTF-8 编码,requests 有时会按 ISO-8859-1 猜测,这里显式指定
response = requests.get(url, headers=HEADERS, timeout=15)
response.raise_for_status()
response.encoding = "utf-8"
return response.text
def _to_int(text: str) -> int | None:
"""从 "26℃"、" -3℃ " 这类字符串中提取整数温度,提取失败返回 None。
:param text: 含温度数字的字符串
:return: 整型温度值或 None
"""
if not text:
return None
matched = re.search(r"-?\d+", text)
return int(matched.group()) if matched else None
def parse_forecast(html: str, city_name: str, city_code: str) -> list[dict]:
"""解析天气页面,抽取未来 7 天的预报信息。
页面结构(简化):
<ul class="t clearfix">
<li>
<h1>9日(今天)</h1>
<p class="wea">晴</p>
<p class="tem"><span>4℃</span>/<i>-3℃</i></p>
<p class="win"><em><span title="西南风"></span></em><i><3级</i></p>
</li>
...
</ul>
:param html: 页面 HTML 源码
:param city_name: 城市中文名,用于入库
:param city_code: 城市编码,用于入库
:return: 每个元素为一条 7 日预报记录的字典列表
"""
soup = BeautifulSoup(html, "html.parser")
week_list = soup.find("ul", class_="t clearfix")
if week_list is None:
# 页面结构变化或城市编码错误时,返回空列表由调用方处理
return []
records = []
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for item in week_list.find_all("li", recursive=False):
# 日期,例如 "9日(今天)"
date_tag = item.find("h1")
date_text = date_tag.get_text(strip=True) if date_tag else ""
# 天气现象,例如 "晴" / "多云" / "小雨"
weather_tag = item.find("p", class_="wea")
weather = weather_tag.get_text(strip=True) if weather_tag else ""
# 温度,今天只有最高温(单个 i),其余日期为 "最高温/最低温"
tem_tag = item.find("p", class_="tem")
tem_text = tem_tag.get_text(strip=True) if tem_tag else ""
high_text, _, low_text = tem_text.partition("/")
temp_high = _to_int(high_text)
temp_low = _to_int(low_text) if low_text else None
# 风向与风力:风向在 span 的 title 属性里,风力在 <i> 文本中
wind_dir_tag = item.select_one("p.win em span")
wind_dir = wind_dir_tag.get("title", "") if wind_dir_tag else ""
wind_level_tag = item.select_one("p.win i")
wind_level = wind_level_tag.get_text(strip=True) if wind_level_tag else ""
records.append(
{
"city_name": city_name,
"city_code": city_code,
"forecast_date": date_text,
"weather": weather,
"temp_high": temp_high,
"temp_low": temp_low,
"wind_dir": wind_dir,
"wind_level": wind_level,
"crawl_time": crawl_time,
}
)
return records
# --------------------------------------------------------------------------- #
# 数据库操作
# --------------------------------------------------------------------------- #
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
"""创建数据库连接并建表(表已存在时不重复创建)。
:param db_path: 数据库文件路径
:return: sqlite3 连接对象
"""
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS weather_forecast (
id INTEGER PRIMARY KEY AUTOINCREMENT,
city_name TEXT NOT NULL,
city_code TEXT NOT NULL,
forecast_date TEXT NOT NULL,
weather TEXT,
temp_high INTEGER,
temp_low INTEGER,
wind_dir TEXT,
wind_level TEXT,
crawl_time TEXT,
UNIQUE (city_code, forecast_date)
)
"""
)
conn.commit()
return conn
def save_records(conn: sqlite3.Connection, records: list[dict]) -> int:
"""把预报记录写入数据库,同一城市同一天的数据做覆盖更新。
:param conn: 数据库连接
:param records: 解析得到的记录列表
:return: 实际写入的记录条数
"""
if not records:
return 0
sql = """
INSERT OR REPLACE INTO weather_forecast
(city_name, city_code, forecast_date, weather,
temp_high, temp_low, wind_dir, wind_level, crawl_time)
VALUES
(:city_name, :city_code, :forecast_date, :weather,
:temp_high, :temp_low, :wind_dir, :wind_level, :crawl_time)
"""
conn.executemany(sql, records)
conn.commit()
return len(records)
# --------------------------------------------------------------------------- #
# 结果展示
# --------------------------------------------------------------------------- #
def _display_width(text: str) -> int:
"""计算字符串在终端的显示宽度(中文占两列),用于对齐表格。"""
width = 0
for char in str(text):
width += 2 if ord(char) > 127 else 1
return width
def print_table(headers: list[str], rows: list[list]) -> None:
"""以等宽表格形式打印结果,方便在控制台直观查看。
:param headers: 表头
:param rows: 数据行
"""
str_rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
widths = [max(_display_width(h), *(_display_width(r[i]) for r in str_rows)) if str_rows else _display_width(h)
for i, h in enumerate(headers)]
def format_row(cells: list[str]) -> str:
padded = [cell + " " * (widths[i] - _display_width(cell)) for i, cell in enumerate(cells)]
return "| " + " | ".join(padded) + " |"
separator = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
print(separator)
print(format_row(headers))
print(separator)
for row in str_rows:
print(format_row(row))
print(separator)
# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #
def main() -> None:
"""遍历城市集合,抓取 7 日天气预报并写入数据库。"""
conn = init_db()
total = 0
for city_name, city_code in CITY_CODES.items():
print(f"[抓取] {city_name}({city_code})...", end=" ")
try:
html = fetch_html(city_code)
records = parse_forecast(html, city_name, city_code)
except requests.RequestException as error:
print(f"失败:{error}")
continue
saved = save_records(conn, records)
total += saved
print(f"解析到 {len(records)} 天预报,已入库 {saved} 条")
time.sleep(REQUEST_INTERVAL)
# 从数据库中读回数据做整体展示,验证确实写入了数据库
rows = conn.execute(
"""
SELECT city_name, forecast_date, weather, temp_high, temp_low, wind_dir, wind_level
FROM weather_forecast
ORDER BY city_name, id
"""
).fetchall()
conn.close()
print(f"\n本次共写入 {total} 条记录,数据库当前共有 {len(rows)} 条记录:\n")
print_table(
["城市", "日期", "天气", "最高温", "最低温", "风向", "风力"],
[list(row) for row in rows],
)
print(f"\n数据文件:{DB_PATH}")
if __name__ == "__main__":
main()
(5)运行结果截图


5 个城市 × 7 天 = 35 条记录全部入库。脚本最后并不是直接打印内存中的列表,而是重新从数据库 SELECT 出来再打印,这样表格本身就是"数据确实落库了"的证据。
- 本小题代码与数据库:Gitee · 作业14/作业1
作业二 东方财富网:股票行情的定向爬取
(1)F12 抓包定位数据接口
打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board(沪深 A 股列表页),按 F12 切到 Network → Fetch/XHR,再在页面上点"下一页"或切换排序字段(例如点"涨跌幅"表头),就能看到一条反复出现的请求:
https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=20&po=1&np=1
&ut=bd1d9ddb04089700cf9c27f6f7426281&fltt=2&invt=2
&fid=f6&fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23
&fields=f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23
也就是说:整页 HTML 里根本没有股票数据,列表是前端用这个 JSON 接口渲染出来的。这一步关键——找到接口,就不用再去解析那堆满屏的 <div>。
(2)参数分析
| 参数 | 含义 | 说明 |
|---|---|---|
pn / pz |
页码 / 每页条数 | 翻页靠它,pz 最大可调到几百 |
po |
排序方向 | 1 降序,0 升序 |
fid |
排序字段 | f3 按涨跌幅,f6 按成交额,f12 按代码 |
fs |
市场过滤 | m:0+t:6 深主板、m:0+t:80 创业板、m:1+t:2 沪主板、m:1+t:23 科创板 |
fields |
返回字段 | 可自由增删,只要返回体需要的 f 编号,响应体积能小一个数量级 |
ut |
客户端标识 | 抓包所得,缺了会被拒绝 |
(3)字段编号对照(data.diff 中的 f 键)
| 编号 | 含义 | 编号 | 含义 | 编号 | 含义 |
|---|---|---|---|---|---|
| f2 | 最新价 | f8 | 换手率 | f16 | 最低价 |
| f3 | 涨跌幅 | f9 | 市盈率(动) | f17 | 今开 |
| f4 | 涨跌额 | f10 | 量比 | f18 | 昨收 |
| f5 | 成交量(手) | f12 | 股票代码 | f20 | 总市值 |
| f6 | 成交额 | f13 | 市场编号 | f21 | 流通市值 |
| f7 | 振幅 | f14 | 股票名称 | f23 | 市净率 |
f13 的 0/1 分别代表深市、沪市,落库时转换成了中文。
(4)实现思路
"""
作业:使用 requests + json 解析爬取东方财富网股票行情数据,并保存到 SQLite 数据库。
抓包分析(Chrome F12 -> Network -> XHR):
打开 https://quote.eastmoney.com/center/gridlist.html#hs_a_board (沪深 A 股列表),
页面滚动/翻页时会不断请求下面这个接口:
https://push2.eastmoney.com/api/qt/clist/get
?pn=1 # 页码
&pz=20 # 每页条数
&po=1 # 排序方向,1 为降序
&fid=f3 # 排序字段,f3 为涨跌幅
&fs=m:0+t:6,... # 市场/板块过滤器,沪深 A 股
&fields=f1,f2,... # 需要返回的字段,可增删
返回值是 JSON:data.total 为总条数,data.diff 为当期页的股票数组,
数组元素中的键 f1~f152 就是字段编号,对照关系见 FIELD_MAP。
请求参数可裁剪:只保留需要的 fields 能让响应体积大幅减小,
这也是“定向爬取”的关键——不是抓整个页面,而是直接向数据接口要数据。
"""
(5)代码
# -*- coding: utf-8 -*-
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import requests
# --------------------------------------------------------------------------- #
# 全局配置
# --------------------------------------------------------------------------- #
# 行情列表接口(东方财富行情中心的公开 JSON 接口)
API_URL = "https://push2.eastmoney.com/api/qt/clist/get"
# 抓包得到的固定参数:ut 为客户端标识,fs 为市场过滤条件(沪深 A 股)
BASE_PARAMS = {
"po": 1, # 排序方向:1 降序
"np": 1, # 返回格式标记
"ut": "bd1d9ddb04089700cf9c27f6f7426281",
"fltt": 2, # 价格保留 2 位小数
"invt": 2,
"fid": "f6", # 排序字段:f6 成交额(改成 f3 即按涨跌幅排序,可自由切换)
# m:0+t:6 深市主板, m:0+t:80 创业板, m:1+t:2 沪市主板, m:1+t:23 科创板
"fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23",
# 只请求需要的字段,f12 代码、f14 名称是必需项
"fields": "f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23",
}
# 每页条数与抓取页数(以我学号的最后三位147定义,14 条/页 * 7 页 = 98 条;)
PAGE_SIZE = 14
MAX_PAGES = 7
# 请求头:东方财富接口会校验 Referer,缺失时可能返回空数据
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/",
}
# 字段编号 -> (数据库列名, 中文含义),字段编号含义来自对接口返回值的分析
FIELD_MAP = {
"f2": ("price", "最新价"),
"f3": ("change_pct", "涨跌幅(%)"),
"f4": ("change_amt", "涨跌额"),
"f5": ("volume", "成交量(手)"),
"f6": ("amount", "成交额(元)"),
"f7": ("amplitude", "振幅(%)"),
"f8": ("turnover", "换手率(%)"),
"f9": ("pe", "市盈率(动)"),
"f10": ("volume_ratio", "量比"),
"f12": ("code", "股票代码"),
"f13": ("market_id", "市场编号"),
"f14": ("name", "股票名称"),
"f15": ("high", "最高价"),
"f16": ("low", "最低价"),
"f17": ("open", "今开"),
"f18": ("pre_close", "昨收"),
"f20": ("total_mv", "总市值"),
"f21": ("float_mv", "流通市值"),
"f23": ("pb", "市净率"),
}
# 市场编号 -> 中文市场名
MARKET_NAMES = {0: "深市", 1: "沪市"}
# 数据库文件与本脚本同目录
DB_PATH = Path(__file__).with_name("stock.db")
# 翻页间隔(秒),控制请求频率
REQUEST_INTERVAL = 0.5
# --------------------------------------------------------------------------- #
# 网络请求与解析
# --------------------------------------------------------------------------- #
def fetch_page(page: int) -> dict:
"""请求接口的指定页,返回解析后的 JSON 字典。
:param page: 页码,从 1 开始
:return: 接口返回的 JSON 数据(已转为 Python 字典)
"""
params = dict(BASE_PARAMS, pn=page, pz=PAGE_SIZE)
response = requests.get(API_URL, params=params, headers=HEADERS, timeout=15)
response.raise_for_status()
# 接口返回的 Content-Type 是 text/plain,因此用 json.loads 手动解析文本
return json.loads(response.text)
def parse_stock(item: dict, crawl_time: str) -> dict:
"""把接口返回的一条股票数据(f 字段字典)转换成数据库记录。
:param item: data.diff 中的单个元素,形如 {"f2": 26.21, "f12": "301010", ...}
:param crawl_time: 本次抓取时间
:return: 字段名与数据库列名一一对应的字典
"""
record = {}
for field, (column, _) in FIELD_MAP.items():
record[column] = item.get(field)
# 接口用 "-" 表示停牌等无数据情况,统一转为 None 便于入库
for column, value in record.items():
if value == "-":
record[column] = None
record["market"] = MARKET_NAMES.get(record.pop("market_id"), "未知")
record["crawl_time"] = crawl_time
return record
def crawl_stocks(max_pages: int = MAX_PAGES) -> list[dict]:
"""按页循环抓取股票行情,直到达到页数上限或数据取完。
:param max_pages: 最多抓取的页数
:return: 股票记录列表
"""
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
records = []
for page in range(1, max_pages + 1):
data = fetch_page(page)
payload = data.get("data") or {}
items = payload.get("diff") or []
if not items:
print(f"[第 {page} 页] 无数据,结束抓取")
break
total = payload.get("total", 0)
records.extend(parse_stock(item, crawl_time) for item in items)
print(f"[第 {page} 页] 获取 {len(items)} 条,累计 {len(records)} 条(全市场共 {total} 只)")
# 已经取完全部数据时提前退出
if len(records) >= total:
break
time.sleep(REQUEST_INTERVAL)
return records
# --------------------------------------------------------------------------- #
# 数据库操作
# --------------------------------------------------------------------------- #
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
"""创建数据库连接并建表。
:param db_path: 数据库文件路径
:return: sqlite3 连接对象
"""
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS stock_quote (
code TEXT PRIMARY KEY, -- 股票代码,唯一
name TEXT NOT NULL, -- 股票名称
market TEXT, -- 沪市 / 深市
price REAL, -- 最新价
change_pct REAL, -- 涨跌幅(%)
change_amt REAL, -- 涨跌额
volume REAL, -- 成交量(手)
amount REAL, -- 成交额(元)
amplitude REAL, -- 振幅(%)
turnover REAL, -- 换手率(%)
volume_ratio REAL, -- 量比
pe REAL, -- 市盈率(动)
pb REAL, -- 市净率
high REAL, -- 最高价
low REAL, -- 最低价
open REAL, -- 今开
pre_close REAL, -- 昨收
total_mv REAL, -- 总市值
float_mv REAL, -- 流通市值
crawl_time TEXT -- 抓取时间
)
"""
)
conn.commit()
return conn
def save_records(conn: sqlite3.Connection, records: list[dict]) -> int:
"""写入股票行情数据,同一代码重复抓取时覆盖为最新快照。
:param conn: 数据库连接
:param records: 股票记录列表
:return: 写入条数
"""
if not records:
return 0
columns = list(records[0].keys())
placeholders = ", ".join(f":{column}" for column in columns)
sql = (
f"INSERT OR REPLACE INTO stock_quote ({', '.join(columns)}) "
f"VALUES ({placeholders})"
)
conn.executemany(sql, records)
conn.commit()
return len(records)
# --------------------------------------------------------------------------- #
# 结果展示
# --------------------------------------------------------------------------- #
def _display_width(text: str) -> int:
"""计算字符串的终端显示宽度(中文占两列)。"""
return sum(2 if ord(char) > 127 else 1 for char in str(text))
def print_table(headers: list[str], rows: list[list]) -> None:
"""打印等宽表格,便于控制台核对数据。
:param headers: 表头
:param rows: 数据行
"""
str_rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
widths = [
max([_display_width(headers[i])] + [_display_width(row[i]) for row in str_rows])
for i in range(len(headers))
]
def format_row(cells: list[str]) -> str:
padded = [cell + " " * (widths[i] - _display_width(cell)) for i, cell in enumerate(cells)]
return "| " + " | ".join(padded) + " |"
separator = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
print(separator)
print(format_row(headers))
print(separator)
for row in str_rows:
print(format_row(row))
print(separator)
def report(conn: sqlite3.Connection) -> None:
"""统计并打印入库结果:总数、涨跌分布、平均涨幅、成交额榜前 10。
:param conn: 数据库连接
"""
total = conn.execute("SELECT COUNT(*) FROM stock_quote").fetchone()[0]
up = conn.execute("SELECT COUNT(*) FROM stock_quote WHERE change_pct > 0").fetchone()[0]
down = conn.execute("SELECT COUNT(*) FROM stock_quote WHERE change_pct < 0").fetchone()[0]
flat = total - up - down
avg_pct, sum_amount = conn.execute(
"SELECT ROUND(AVG(change_pct), 2), ROUND(SUM(amount) / 1e8, 2) FROM stock_quote"
).fetchone()
print(f"\n入库合计 {total} 条:上涨 {up} 只,下跌 {down} 只,平盘 {flat} 只")
print(f"平均涨跌幅 {avg_pct}% ,百强成交额合计 {sum_amount} 亿元\n")
# 成交额榜前 10(抓取时已按 f6 成交额降序排列)
rows = conn.execute(
"""
SELECT code, name, market, price, change_pct, turnover, amount
FROM stock_quote
ORDER BY amount DESC
LIMIT 10
"""
).fetchall()
print("成交额榜前 10:")
print_table(
["代码", "名称", "市场", "最新价", "涨跌幅%", "换手率%", "成交额(亿)"],
[[r[0], r[1], r[2], r[3], r[4], r[5], round((r[6] or 0) / 1e8, 2)] for r in rows],
)
# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #
def main() -> None:
"""抓取股票行情 -> 解析 -> 入库 -> 打印统计。"""
records = crawl_stocks()
if not records:
print("未抓取到数据,请检查网络或接口参数。")
return
conn = init_db()
saved = save_records(conn, records)
print(f"\n已写入数据库 {saved} 条记录。")
report(conn)
conn.close()
print(f"\n数据文件:{DB_PATH}")
if __name__ == "__main__":
main()
(5)运行结果

入库数据


说明:
fid一开始设的是f3(涨跌幅降序),结果抓回来的 100 只全是涨停或大涨的票,涨跌分布统计出来"上涨 100、下跌 0",毫无信息量。改成f6(成交额降序)后抓的是资金最活跃的 100 只,涨跌分布才有参考意义。这也说明排序参数的选择会直接影响数据分布。
- 本小题代码与数据库:Gitee · 作业14/作业2
作业三 软科中国大学排名 2021 主榜(全部院校)
(1)实现思路
爬取软科中国大学排名 2021 主榜的全部院校信息(排名、学校、省市、类型、总分),并保存到 SQLite 数据库。
* **数据来源**:站点公开的 JSON 接口 `https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021`,其中 `bcur_type=11` 表示主榜、`year` 表示年份。该接口一次返回全部 582 所院校,没有分页,因此不需要写翻页循环;把 `year` 改成 2026 会返回 590 所,可直观验证参数含义。
* **请求与解析**:用 `requests.get(API_URL, params=API_PARAMS, headers=HEADERS)` 发起请求,请求头带浏览器 `User-Agent` 和 `Referer`(接口有校验,缺失可能返回 403);用 `json.loads(response.text)` 解析文本,取 `payload["data"]["rankings"]` 得到院校列表。`fetch_rankings()` 会检查 `code == 200`,并在网络异常或字段缺失时自动重试 3 次、每次间隔 2 秒。
* **字段清洗**:`parse_rankings()` 只保留作业要求的 5 个字段——`ranking` 排名、`univNameCn` 学校、`province` 省市、`univCategory` 类型、`score` 总分;`_to_number()` 把 `"-"`、空字符串等无数据情况统一转成 `None`,排名缺失时退化为列表顺序,避免脏数据写进数据库。
* **入库设计**:写入与脚本同目录的 `ranking.db`,表 `university_rank` 用自增 `id` 作主键、`univ_name` 设 `UNIQUE`,配合 `INSERT OR REPLACE`,重复运行只刷新数据、不产生重复行。**​不能拿 `rank_no` 当主键**:榜单存在并列名次,会覆盖掉 72 所院校,详见(5)踩坑。
* **结果验证**:`report()` 不是打印内存里的列表,而是重新从数据库 `SELECT` 出前 20 名、上榜数量前 10 的省市和各类型院校分布,表格本身就是"数据确实落库"的证据。
整体按「常量配置 → 请求 → 解析 → 建表/入库 → 展示 → `main()`」分层,所有函数都写了 docstring,接口地址、参数、请求头、重试次数等常量集中在文件顶部,换年份只需改 `API_PARAMS` 一处。
(2)代码
# -*- coding: utf-8 -*-
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import requests
# --------------------------------------------------------------------------- #
# 全局配置
# --------------------------------------------------------------------------- #
# 排行榜数据接口(由 F12 抓包得到)
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
# 请求参数:bcur_type=11 表示中国大学排名主榜
API_PARAMS = {"bcur_type": 11, "year": 2021}
# 接口对 Referer 有校验,缺省时可能返回 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": "https://www.shanghairanking.cn/rankings/bcur/2021",
"Accept": "application/json, text/plain, */*",
}
# 数据库文件与本脚本同目录
DB_PATH = Path(__file__).with_name("ranking.db")
# 失败重试次数与重试间隔(秒)
MAX_RETRY = 3
RETRY_INTERVAL = 2
# --------------------------------------------------------------------------- #
# 网络请求与解析
# --------------------------------------------------------------------------- #
def fetch_rankings(params: dict = API_PARAMS) -> list[dict]:
"""请求排行榜接口,返回原始院校列表;失败时自动重试。
:param params: 请求参数,默认为 2021 主榜
:return: data.rankings 对应的院校字典列表
"""
for attempt in range(1, MAX_RETRY + 1):
try:
response = requests.get(API_URL, params=params, headers=HEADERS, timeout=20)
response.raise_for_status()
payload = json.loads(response.text) # 接口返回 JSON 文本,用 json 模块解析
if payload.get("code") != 200:
raise ValueError(f"接口返回异常:{payload.get('msg')}")
return payload["data"]["rankings"]
except (requests.RequestException, ValueError, KeyError) as error:
print(f" 第 {attempt} 次请求失败:{error}")
if attempt == MAX_RETRY:
raise
time.sleep(RETRY_INTERVAL)
return []
def _to_number(value, cast=float):
"""把接口中的数值字段转换为目标类型,缺失或非法值返回 None。
:param value: 原始值,可能是数字、数字字符串或 "-"
:param cast: 目标类型,int 或 float
:return: 转换后的数值或 None
"""
if value in (None, "", "-"):
return None
try:
return cast(float(value))
except (TypeError, ValueError):
return None
def parse_rankings(raw_list: list[dict], crawl_time: str) -> list[dict]:
"""把接口返回的原始数据整理成数据库记录(排名/学校/省市/类型/总分)。
:param raw_list: 接口返回的原始院校列表
:param crawl_time: 本次抓取时间
:return: 数据库记录列表
"""
records = []
for index, item in enumerate(raw_list, start=1):
rank = _to_number(item.get("ranking"), int)
records.append(
{
# 若接口未给出排名,则退化为列表顺序
"rank_no": rank if rank is not None else index,
"univ_name": (item.get("univNameCn") or "").strip(),
"province": (item.get("province") or "").strip(),
"univ_category": (item.get("univCategory") or "").strip(),
"total_score": _to_number(item.get("score"), float),
"crawl_time": crawl_time,
}
)
return records
# --------------------------------------------------------------------------- #
# 数据库操作
# --------------------------------------------------------------------------- #
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
"""创建数据库连接并建表。
:param db_path: 数据库文件路径
:return: sqlite3 连接对象
"""
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS university_rank (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rank_no INTEGER NOT NULL, -- 排名(存在并列名次,故不唯一)
univ_name TEXT NOT NULL UNIQUE, -- 学校名称,唯一键,重复抓取时覆盖
province TEXT, -- 省市
univ_category TEXT, -- 类型(综合/理工/师范...)
total_score REAL, -- 总分
crawl_time TEXT -- 抓取时间
)
"""
)
conn.commit()
return conn
def save_records(conn: sqlite3.Connection, records: list[dict]) -> int:
"""写入院校排名数据,重复运行时按排名覆盖旧数据。
:param conn: 数据库连接
:param records: 院校记录列表
:return: 写入条数
"""
if not records:
return 0
sql = """
INSERT OR REPLACE INTO university_rank
(rank_no, univ_name, province, univ_category, total_score, crawl_time)
VALUES
(:rank_no, :univ_name, :province, :univ_category, :total_score, :crawl_time)
"""
conn.executemany(sql, records)
conn.commit()
return len(records)
# --------------------------------------------------------------------------- #
# 结果展示
# --------------------------------------------------------------------------- #
def _display_width(text: str) -> int:
"""计算字符串的终端显示宽度(中文占两列)。"""
return sum(2 if ord(char) > 127 else 1 for char in str(text))
def print_table(headers: list[str], rows: list[list], limit: int | None = None) -> None:
"""打印等宽表格,limit 用于只展示前 N 行。
:param headers: 表头
:param rows: 数据行
:param limit: 最多展示的行数,None 表示全部
"""
if limit is not None:
rows = rows[:limit]
str_rows = [[("" if cell is None else str(cell)) for cell in row] for row in rows]
widths = [
max([_display_width(headers[i])] + [_display_width(row[i]) for row in str_rows])
for i in range(len(headers))
]
def format_row(cells: list[str]) -> str:
padded = [cell + " " * (widths[i] - _display_width(cell)) for i, cell in enumerate(cells)]
return "| " + " | ".join(padded) + " |"
separator = "+" + "+".join("-" * (width + 2) for width in widths) + "+"
print(separator)
print(format_row(headers))
print(separator)
for row in str_rows:
print(format_row(row))
print(separator)
def report(conn: sqlite3.Connection) -> None:
"""打印入库统计:总数、前 20 名、各省上榜数量、各类型占比。
:param conn: 数据库连接
"""
total = conn.execute("SELECT COUNT(*) FROM university_rank").fetchone()[0]
print(f"\n数据库中共 {total} 所院校。\n")
print("主榜前 20 名:")
top20 = conn.execute(
"""
SELECT rank_no, univ_name, province, univ_category, total_score
FROM university_rank ORDER BY rank_no LIMIT 20
"""
).fetchall()
print_table(["排名", "学校", "省市", "类型", "总分"], [list(r) for r in top20])
print("\n上榜数量前 10 的省市:")
provinces = conn.execute(
"""
SELECT province, COUNT(*) AS cnt
FROM university_rank GROUP BY province ORDER BY cnt DESC LIMIT 10
"""
).fetchall()
print_table(["省市", "上榜院校数"], [list(r) for r in provinces])
print("\n各类型院校分布:")
categories = conn.execute(
"""
SELECT univ_category, COUNT(*) AS cnt
FROM university_rank GROUP BY univ_category ORDER BY cnt DESC
"""
).fetchall()
print_table(["类型", "数量"], [list(r) for r in categories])
# --------------------------------------------------------------------------- #
# 主流程
# --------------------------------------------------------------------------- #
def main() -> None:
"""抓取 2021 软科中国大学排名主榜全部院校 -> 解析 -> 入库 -> 打印统计。"""
print("[抓取] 软科中国大学排名 2021 主榜 ...")
crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
raw_list = fetch_rankings()
print(f" 接口返回 {len(raw_list)} 条院校数据")
records = parse_rankings(raw_list, crawl_time)
conn = init_db()
saved = save_records(conn, records)
print(f" 已写入数据库 {saved} 条")
report(conn)
conn.close()
print(f"\n数据文件:{DB_PATH}")
if __name__ == "__main__":
main()
(3)运行结果


入库数据


(4)踩坑:主键选错导致 72 所院校凭空消失
第一版把 rank_no 设成了主键,结果接口明明返回 582 条、executemany 也执行了 582 次,SELECT COUNT(*) 却是 510。原因是软科榜单存在并列名次(比如两所学校同为第 100 名),INSERT OR REPLACE 遇到主键冲突后把前一条覆盖掉了,582 - 510 = 72 条数据就这样"蒸发"。
修正方法:主键改成自增 id,把 univ_name 设为唯一键——学校名才是天然不重复的标识,而排名不是。改完之后数字与接口完全对上。
- 本小题代码与数据库:Gitee · 作业14/作业3
二、实验总结与心得
(一)技术要点与踩坑复盘
| # | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 1 | requests 拿到中文乱码 |
响应头缺 charset,requests 按 ISO-8859-1 猜测编码 |
手动 response.encoding = "utf-8" 后再取 .text |
| 2 | 气象网"今天"一列没有最低温 | 页面结构如此,今天只有 <i>最高温</i> |
用 partition("/") 切分,最低温允许为 None |
| 3 | 东方财富接口返回空 data |
缺少 Referer / ut 参数 |
请求头带上 Referer: https://quote.eastmoney.com/,参数保留抓包得到的 ut |
| 4 | 同样请求浏览器能开、代码报 403 | 没带 User-Agent,被识别为脚本 |
统一设置浏览器 UA |
| 5 | 软科 582 条只入库 510 条 | 并列排名导致主键冲突被覆盖 | 主键换自增 id,univ_name 建唯一索引 |
| 6 | 反复调试时数据库越跑越脏 | 每次运行都 INSERT |
INSERT OR REPLACE + 业务唯一键,跑多少次结果都一致 |
几个可以复用的经验:
- 先抓包,再写代码。 三份作业里真正花时间的不是写 Python,而是判断"数据到底从哪个 URL 来"。气象网是服务端渲染(数据在 HTML 里),东方财富和软科是前后端分离(数据在 XHR 返回的 JSON 里)。判断错了,后面全是白费功夫。
- 能用接口就不要解析 HTML。 作业二三直接拿 JSON,字段明确、结构稳定,比在几百个
<div>里找文本可靠得多;fields参数还能裁剪字段。 - 请求参数是有语义的,删改前要想清楚。
fid换个字段,抓回来的数据分布完全不同;pz调大能少翻几页,但响应体也会变大。 - 控制访问频率。 三份代码里都加了
time.sleep(),每次请求间隔 0.5~1 秒。爬虫是客,别把人家服务器打挂。 - 入库后要"读回来"验证。 打印内存里的列表说明"解析对了",只有
SELECT出来再打印才能说明"存对了"——作业三那 72 条数据丢失,正是靠SELECT COUNT(*)才发现的。
(二)心得体会
这次作业最大的感受是:爬虫的难点在"分析",不在"编码"。
写第一个作业(气象网)时,我还在用"先 requests 拿全文,再到处找正则"的老办法,代码又长又脆。到作业② 才开始真正用 F12 的 Network 面板:点一下"涨跌幅"表头,看哪条请求被重新发出,参数里哪个字段变了,很快就能锁定 fid 是排序字段、 fields 是返回字段。这种"观察请求—改参数—对比响应"的循环,比读十篇教程都管用。作业三更进一步:页面源码里一个学校名都搜不到,但 Preview 里 JSON 明明整整齐齐,这一刻才真正理解"前端渲染"是什么意思。
第二个感受是数据结构设计不能想当然。软科那 72 条数据丢失,报错信息一条都没有,程序还打印了"已写入 582 条",看起来完美无缺。如果不是顺手做了个 COUNT(*),这个 bug 会一直带到交作业。这让我意识到:写爬虫不能只看"有没有报错",要看"数量对不对、字段对不对"——数据质量校验应该成为流程的一部分,而不是事后补救。
第三个感受是规范带来的好处。把常量(城市编码、字段映射、请求头)统一提到文件顶部,把请求、解析、入库拆成独立函数后,改需求变得非常轻松:要多加一个城市,只改 CITY_CODES 字典;要改抓取页数,只改 MAX_PAGES;想看看换个排序字段效果如何,只改 fid 一行。如果全写在一个 main 里,每次改动都得重新读一遍自己的代码。
最后,这次也踩到了"反爬"的边:User-Agent、Referer、请求间隔、重试机制,这些在教科书上只是几个名词,真正遇到 403 和空响应时才知道为什么要加。当然,爬虫要有边界感——控制频率、只取公开数据、不绕过登录和验证码,这是基本的分寸。

浙公网安备 33010602011771号