Python 数据采集实践:五城七日天气、股票 JSON 与 2021 大学主榜入库
一、天气与股票数据采集实践
1.1 作业代码、实现过程与结果
本文代码和文字由 AI 辅助生成,程序已在本机实际运行。
本次实验使用 Python、requests、BeautifulSoup、json 和 SQLite。SQLite 无须额外搭建服务,生成的 .db 文件就是实际数据库,可用 DB Browser for SQLite 或 Python 查询。
安装与运行:
python -m pip install -r requirements.txt
python 作业1/1.py
python 作业2/2.py
python 作业3/3.py
python build_blog.py
作业①:五座城市的七日天气。 城市选取北京、上海、广州、成都、福州。数据来源为中国天气网北京七日页面,其他城市替换页面中的城市编码。使用 [id="7d"] ul.t.clearfix > li 定位七天预报,解析日期、天气、最高温、最低温及风力。每座城市必须得到 7 条,否则终止;五城共入库 35 条。夜间页面可能不再显示当天最高温,程序将它保留为 SQL NULL,不自行补造温度。
天气数据库:作业1/weather.db,表名 weather,以城市编码和预报日期为联合主键,重复运行更新同一天的预报。

"""采集中国天气网五座城市的七日天气,并保存到 SQLite。"""
import json
import re
import sqlite3
import time
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
from bs4 import BeautifulSoup
ROOT = Path(__file__).resolve().parent
CITIES = {"北京": "101010100", "上海": "101020100", "广州": "101280101",
"成都": "101270101", "福州": "101230101"}
def temperature(element):
"""页面夜间可能没有最高温;此时保留 NULL,不用最低温填充。"""
match = re.search(r"-?\d+", element.get_text()) if element else None
return int(match.group()) if match else None
def fetch_city(session, city, city_id):
"""请求七日页面,校验日期并提取天气、温度和风力。"""
url = f"https://www.weather.com.cn/weather/{city_id}.shtml"
response = session.get(url, timeout=(10, 30))
response.raise_for_status()
html = response.content.decode("utf-8")
(ROOT / "raw" / f"{city_id}.html").write_text(html, encoding="utf-8")
soup = BeautifulSoup(html, "html.parser")
items = soup.select('[id="7d"] ul.t.clearfix > li')
if len(items) != 7:
raise ValueError(f"{city}预报应为7条,实际为{len(items)}条")
today = datetime.now(ZoneInfo("Asia/Shanghai")).date()
first_day = int(re.search(r"\d+", items[0].h1.get_text()).group())
# 页面可能有缓存,以页面首日为准,在附近日期中寻找实际年月。
candidates = [today + timedelta(days=n) for n in range(-15, 16)]
start = min((d for d in candidates if d.day == first_day),
key=lambda d: abs((d - today).days))
rows = []
for offset, item in enumerate(items):
day = start + timedelta(days=offset)
if int(re.search(r"\d+", item.h1.get_text()).group()) != day.day:
raise ValueError("天气日期不连续,请检查网页")
rows.append((city, city_id, day.isoformat(),
item.select_one(".wea").get_text(strip=True),
temperature(item.select_one(".tem span")),
temperature(item.select_one(".tem i")),
"/".join(dict.fromkeys(e.get("title", "") for e in item.select(".win span"))),
item.select_one(".win i").get_text(strip=True), url))
return rows
def main():
"""全部城市通过校验后统一入库,重复运行更新对应日期。"""
(ROOT / "raw").mkdir(exist_ok=True)
with requests.Session() as session:
session.headers["User-Agent"] = "Mozilla/5.0"
rows = []
for city, city_id in CITIES.items():
rows.extend(fetch_city(session, city, city_id))
time.sleep(0.5)
with sqlite3.connect(ROOT / "weather.db") as db:
db.execute("""CREATE TABLE IF NOT EXISTS weather (
city TEXT NOT NULL, city_id TEXT NOT NULL, forecast_date TEXT NOT NULL,
description TEXT NOT NULL, high_c INTEGER, low_c INTEGER,
wind_direction TEXT, wind_level TEXT, source_url TEXT,
PRIMARY KEY(city_id, forecast_date))""")
db.executemany("INSERT OR REPLACE INTO weather VALUES (?,?,?,?,?,?,?,?,?)", rows)
print(json.dumps({"城市数": len(CITIES), "本次记录数": len(rows),
"示例": rows[:7]}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
作业②:requests 与 json 解析股票列表。 采用新浪财经的股票列表数据接口 Market_Center.getHQNodeData。请求使用 page 控制页码、num=40 控制单页条数、node=hs_a 指定 A 股列表、sort=symbol&asc=1 按证券标识升序排列。本次定向采集前三页共 120 条;本作业没有要求抓取全市场,因此不把这 120 条描述为全部股票。
本次选择新浪接口,它返回 symbol/code/name/trade/changepercent 等字段。题目中的 f1/f2 属于另一候选网站的字段命名,不应套用到新浪接口。用 json.loads(response.text) 解析数组,证券代码使用 TEXT,避免前导零丢失。成交额与成交量保存接口原值,不擅自更换单位。保留接口 ticktime 和带时区的采集时间;休市时返回的可能是此前最近行情。
股票数据库:作业2/stocks.db,表名 stocks。原始响应另存于 作业2/raw/。

"""使用 requests + json 采集新浪股票列表并写入 SQLite。"""
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import requests
ROOT = Path(__file__).resolve().parent
API = "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData"
def number(value):
"""空值和横线存为 NULL,避免错误地当作零。"""
return None if value in (None, "", "--", "-") else float(value)
def fetch_page(session, page, size=40):
"""page 为页码,num 为页大小,node=hs_a 选择沪深京 A 股列表。"""
params = {"page": page, "num": size, "sort": "symbol", "asc": 1, "node": "hs_a"}
response = session.get(API, params=params, timeout=(10, 30))
response.raise_for_status()
data = json.loads(response.text)
if not isinstance(data, list) or not data:
raise ValueError(f"第{page}页未返回有效股票列表")
(ROOT / "raw" / f"page_{page}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
return data
def main():
"""定向采集前三页;保留证券代码前导零和实际采集时间。"""
(ROOT / "raw").mkdir(exist_ok=True)
captured_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
records = []
with requests.Session() as session:
session.headers.update({"User-Agent": "Mozilla/5.0",
"Referer": "https://finance.sina.com.cn/stock/"})
for page in range(1, 4):
for stock in fetch_page(session, page):
records.append((str(stock["symbol"]), str(stock["code"]), stock["name"],
number(stock.get("trade")), number(stock.get("pricechange")),
number(stock.get("changepercent")), number(stock.get("volume")),
number(stock.get("amount")), stock.get("ticktime"), captured_at))
time.sleep(0.5)
if len({row[0] for row in records}) != len(records):
raise ValueError("翻页出现重复证券,请检查排序或接口响应")
with sqlite3.connect(ROOT / "stocks.db") as db:
db.execute("""CREATE TABLE IF NOT EXISTS stocks (
symbol TEXT PRIMARY KEY, code TEXT NOT NULL, name TEXT NOT NULL,
price REAL, price_change REAL, change_percent REAL, volume REAL,
amount REAL, tick_time TEXT, captured_at TEXT NOT NULL)""")
db.executemany("INSERT OR REPLACE INTO stocks VALUES (?,?,?,?,?,?,?,?,?,?)", records)
print(json.dumps({"本次记录数": len(records), "采集时间": captured_at,
"示例": records[:5]}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
1.2 作业心得
这两项练习体现了 HTML 解析与 JSON 解析的区别。天气信息直接位于 HTML 节点中,需要判断选择器和节点是否存在;股票接口已经提供结构化字段,需要优先检查响应类型和字段含义。不能因为请求状态为 200 就认定数据正确,还要检查条数、空值和唯一性。
调试天气选择器时,#7d 在 CSS 解析器中因数字开头报错,改用属性选择器 [id="7d"] 后正常。另一个细节是夜间当天最高温缺失:将缺失值保存为 NULL,才能区分“没有提供温度”和“温度为 0℃”。股票代码同样不宜直接转换成数字。
二、2021 中国大学主榜数据采集实践
2.1 作业代码、数据载荷分析与结果
目标为软科 2021 中国大学主榜。页面显示共 582 所学校,但当前页面表格仅显示前 30 所。只解析当前 HTML 表格无法满足“所有院校”的要求。
当前网站采用 Nuxt 静态数据载荷,程序先从页面的 state.js 路径发现构建版本,再请求同目录 payload.js。本次实际地址记录在 作业3/source.json 中,程序没有固定构建版本号。请求需携带页面 Referer,否则静态资源可能返回 denied by Referer ACL。
载荷不是纯 JSON,而是 __NUXT_JSONP__ 包裹的压缩 JavaScript 数据。程序解析函数参数与实参映射,再将字符串外的参数引用还原为对应值,最后用 chompjs 解析数据对象,不执行远程 JS。data[0].univData 提供所有院校记录。
数据库 作业3/universities.db 的 universities 表保存年份、排名、学校标识、学校名称、省市、类型、总分和完整原始对象。排名使用 TEXT,避免未来的并列排名或区间排名格式丢失。总分使用 REAL,学校标识与年份构成联合主键。
本次实际入库 582 所,首条为清华大学、北京、综合、969.2 分;末条排名 582,为太原学院,58.2 分。已核对总数、学校标识唯一性及 SQLite 完整性。

"""获取软科 2021 主榜完整 Nuxt 数据载荷,解析后存入 SQLite。
JSONP 中含函数参数压缩,不是纯 JSON。仅做词法替换及数据解析,不执行远程 JS。
"""
import json
import re
import sqlite3
from pathlib import Path
from urllib.parse import urljoin
import chompjs
import requests
from bs4 import BeautifulSoup
ROOT = Path(__file__).resolve().parent
URL = "https://www.shanghairanking.cn/rankings/bcur/2021"
def parse_payload(text):
"""还原 Nuxt JSONP 参数引用,不使用 eval 执行返回内容。"""
header = re.search(r"function\(([^)]*)\)\s*\{return\s+", text)
split_at = text.rfind("}(" )
if header is None or split_at < 0 or not text.rstrip().endswith(")));" ):
raise ValueError("Nuxt 载荷包装结构改变")
parameters = header.group(1).split(",")
arguments = chompjs.parse_js_object("[" + text[split_at + 2:].strip()[:-4] + "]")
if len(parameters) != len(arguments):
raise ValueError("压缩参数与实参数量不匹配")
bindings = dict(zip(parameters, arguments))
body = text[header.end():split_at]
# 跳过字符串内容,仅替换裸参数标识符,保留属性名。
token_pattern = r'"(?:\\.|[^"\\])*"|[A-Za-z_$][A-Za-z0-9_$]*'
def replace(match):
token = match.group()
if token.startswith('"') or token not in bindings:
return token
if body[match.end():].lstrip().startswith(":"):
return token
return json.dumps(bindings[token], ensure_ascii=False)
return chompjs.parse_js_object(re.sub(token_pattern, replace, body))
def main():
"""从页面发现构建版本,获取全部学校并校验首条和总数。"""
(ROOT / "raw").mkdir(exist_ok=True)
with requests.Session() as session:
session.headers.update({"User-Agent": "Mozilla/5.0", "Referer": URL})
response = session.get(URL, timeout=(10, 30))
response.raise_for_status()
html = response.content.decode("utf-8")
(ROOT / "raw" / "page.html").write_text(html, encoding="utf-8")
soup = BeautifulSoup(html, "html.parser")
state = next((s.get("src") for s in soup.find_all("script", src=True)
if "/rankings/bcur/2021/state.js" in s["src"]), None)
if state is None:
raise ValueError("页面未提供 Nuxt 静态版本路径")
payload_url = urljoin(URL, state.replace("state.js", "payload.js"))
response = session.get(payload_url, timeout=(10, 30))
response.raise_for_status()
payload = response.content.decode("utf-8")
(ROOT / "raw" / "payload.js").write_text(payload, encoding="utf-8")
data = parse_payload(payload)["data"][0]
schools = data["univData"]
count_match = re.search(r"共\s*(\d+)\s*所学校", soup.get_text())
expected = int(count_match.group(1)) if count_match else 582
if len(schools) != expected or schools[0]["univNameCn"] != "清华大学":
raise ValueError("学校数量或首条记录与页面不一致")
if len({s["univUp"] for s in schools}) != expected:
raise ValueError("学校标识重复")
rows = [(2021, str(s["ranking"]), s["univUp"], s["univNameCn"],
s["province"], s["univCategory"], float(s["score"]),
json.dumps(s, ensure_ascii=False)) for s in schools]
with sqlite3.connect(ROOT / "universities.db") as db:
db.execute("""CREATE TABLE IF NOT EXISTS universities (
year INTEGER NOT NULL, ranking TEXT NOT NULL, school_id TEXT NOT NULL,
name TEXT NOT NULL, province TEXT NOT NULL, category TEXT NOT NULL,
score REAL, raw_json TEXT NOT NULL, PRIMARY KEY(year, school_id))""")
db.executemany("INSERT OR REPLACE INTO universities VALUES (?,?,?,?,?,?,?,?)", rows)
(ROOT / "source.json").write_text(json.dumps({"page": URL, "payload": payload_url,
"expected": expected, "actual": len(rows)}, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"学校数": len(rows), "数据地址": payload_url,
"前5条": [r[:7] for r in rows[:5]], "最后一条": rows[-1][:7]},
ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
2.2 作业心得
大学榜单最关键的问题是“看见一页”和“取得全量”的区别。当前 HTML 显示 30 所学校,不等于服务端只有 30 条数据。分析页面资源后,可以直接取得包含 582 所院校的完整载荷,从而减少翻页请求,也更便于核对完整性。
JSONP 不能简单当作 JSON 处理。压缩参数中还包含 $,初始词法规则未完整识别含 $ 的变量,导致解析失败;修正标识符规则后得到完整记录。这个过程说明解析程序不仅要能处理正常示例,还要理解返回格式。后续网站若更换框架或数据结构,应保留原始响应并报错检查,不能静默写入空表。
本地验证记录见 verification.json,结果图片均由数据库真实查询生成。网络数据会变化,天气和股票后续重跑不保证与本次截图完全一致;大学榜单固定选择 2021 主榜。

浙公网安备 33010602011771号