数据采集第二次作业

第二次作业:数据采集与数据库存储

本次作业使用 Python 完成天气、股票和大学排名信息采集,并将结果保存到 SQLite 数据库。完整代码、数据库、原始响应和运行日志均放在 Gitee 中。

作业仓库:Gitee 第二次作业
开发环境:Python 3.10+ · requests · Beautiful Soup · chompjs · SQLite

作业 采集内容 本次入库结果 数据库
① 天气预报 5 个城市的七日天气 35 条 weather.db
② 股票信息 东方财富 A 股列表前 5 页 100 条 stocks.db
③ 大学排名 2021 年中国大学主榜全部院校 582 所 universities.db

一、天气与股票信息采集

1. 作业实现与结果展示

作业① · 七日天气预报

从中国天气网采集福州、厦门、北京、上海和广州五个城市的七日天气,保存日期、天气、最高温、最低温、风向和风力等信息。本次共保存 35 条记录。

代码与数据库:作业① Gitee 文件夹

页面分析

先通过搜索栏进入城市页面,再切换到“7天”预报。使用开发者工具可以看到,七日列表位于 id="7d" 的区域,列表内的 h1 是日期,wea、tem、win 分别对应天气、温度和风。

Screenshot 2026-11-06 155553

图 1 搜索并进入目标城市页面

Screenshot 2026-11-06 155631
Screenshot 2026-11-06 155705

图 2 切换到七日天气预报

Screenshot 2026-11-06 155751
Screenshot 2026-11-06 155857

图 3 定位日期、天气、温度与风的标签

完整代码|weather.py

"""采集五个城市的七日天气,保存网页和 SQLite 数据库。"""
import re
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
# 城市名称与中国天气网城市代码,可替换为其他城市(保持 3—5 个)。
CITIES = {"福州": "101230101", "厦门": "101230201", "北京": "101010100",
          "上海": "101020100", "广州": "101280101"}


def parse_weather(html):
    """解析七日列表;夜间页面缺失最高温时保留为 None。"""
    soup = BeautifulSoup(html, "html.parser")
    items = soup.select('[id="7d"] ul.t > li')
    rows = []
    for item in items:
        def text(selector):
            node = item.select_one(selector)
            return node.get_text(strip=True) if node else ""

        def temperature(selector):
            match = re.search(r"-?\d+", text(selector))
            return int(match.group()) if match else None

        rows.append({
            "day_label": text("h1"), "weather": text(".wea"),
            "high_temp": temperature(".tem span"),
            "low_temp": temperature(".tem i"),
            "wind_direction": "转".join(dict.fromkeys(
                node.get("title", "") for node in item.select(".win span[title]"))),
            "wind_level": text(".win i"),
        })
    if len(rows) != 7 or any(not row["day_label"] or not row["weather"] for row in rows):
        raise ValueError("未解析到完整七日预报,请检查网页结构。")
    return rows


def main():
    """先完成所有城市采集,再事务写入,避免半成品覆盖上次结果。"""
    collected_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
    raw_dir = BASE_DIR / "raw"
    raw_dir.mkdir(exist_ok=True)
    records = []
    with requests.Session() as session:
        session.headers["User-Agent"] = "Mozilla/5.0"
        for city, city_code in CITIES.items():
            url = f"https://www.weather.com.cn/weather/{city_code}.shtml"
            response = session.get(url, timeout=30)
            response.raise_for_status()
            response.encoding = "utf-8"
            (raw_dir / f"{city_code}.html").write_text(response.text, encoding="utf-8")
            rows = parse_weather(response.text)
            records.extend((city, city_code, row["day_label"], row["weather"],
                            row["high_temp"], row["low_temp"], row["wind_direction"],
                            row["wind_level"], collected_at, url) for row in rows)
            print(f"{city}:采集 {len(rows)} 条预报")
            time.sleep(0.5)
    db_path = BASE_DIR / "weather.db"
    with sqlite3.connect(db_path) as connection:
        connection.execute('''CREATE TABLE IF NOT EXISTS weather (
            city TEXT, city_code TEXT, day_label TEXT, weather TEXT,
            high_temp INTEGER, low_temp INTEGER, wind_direction TEXT,
            wind_level TEXT, collected_at TEXT, source_url TEXT,
            PRIMARY KEY (city_code, day_label))''')
        connection.execute("DELETE FROM weather")
        connection.executemany("INSERT INTO weather VALUES (?,?,?,?,?,?,?,?,?,?)", records)
    print(f"共保存 {len(records)} 条;数据库:{db_path}")


if __name__ == "__main__":
    main()

运行与结果

以下截图展示本次程序运行日志和数据库查询结果,完整 35 条数据保存在 weather.db 中。

天气运行结果

图 4 天气采集日志与数据库查询结果


作业② · 股票信息

使用 requests 请求东方财富股票列表接口,通过 JSON 解析提取股票代码、名称、最新价、涨跌幅、涨跌额、成交量和成交额。本次每页请求 20 条,采集 5 页,共入库 100 条。

代码与数据库:作业② Gitee 文件夹

接口分析

在 F12 的 Network 中查找股票列表请求,可以定位到以下接口:

https://pushguest.eastmoney.com/api/qt/clist/get

Screenshot 2026-11-06 161909
Screenshot 2026-11-06 162043

图 5 股票列表接口

Screenshot 2026-11-06 162016
Screenshot 2026-11-06 162055

图 6 切二页并对比接口响应

对比两页请求,可以看到 pn 从 1 变为 2,pz=20 表示每页 20 条;fid=f3、po=1 表示按涨跌幅降序排列。

参数或字段 含义
pn / pz 页码 / 每页数量
fields 返回字段列表
f12 / f14 股票代码 / 名称
f2 / f3 / f4 最新价 / 涨跌幅 / 涨跌额
f5 / f6 成交量 / 成交额

此字段分析由豆包倾情完成

抓包请求带有 cb 时,响应由 jQuery 回调函数包裹。程序删去 cb,直接获取 JSON;同时设置 fltt=2,使价格和涨跌幅以小数形式返回。截图中 fltt=1 的整数不能直接当成实际价格或百分比。

完整代码|stocks.py

"""使用 requests 和 json 采集东方财富 A 股列表。"""
import argparse
import json
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo

import requests

BASE_DIR = Path(__file__).resolve().parent
API_URL = "https://pushguest.eastmoney.com/api/qt/clist/get"
# fltt=2 请求小数形式,避免将 fltt=1 返回的整数直接当作价格。
PARAMS = {
    "np": 1, "fltt": 2, "invt": 2, "fid": "f3", "po": 1,
    "fs": "m:0+t:6+f:!2,m:0+t:80+f:!2,m:1+t:2+f:!2,m:1+t:23+f:!2,m:0+t:81+s:262144+f:!2",
    "fields": "f12,f13,f14,f2,f3,f4,f5,f6",
}


def parse_stocks(text):
    """不传 cb 参数,直接用 json.loads 解析 JSON;停牌等空值保存为 NULL。"""
    payload = json.loads(text)
    if payload.get("rc") != 0 or not isinstance(payload.get("data"), dict):
        raise ValueError("接口未返回有效股票数据。")
    data = payload["data"]
    items = data.get("diff")
    if not isinstance(items, list):
        raise ValueError("股票列表 diff 不是数组。")

    def number(value):
        return None if value is None or value in ("-", "", "--") else float(value)

    rows = [{"code": str(item["f12"]), "market": item.get("f13"),
             "name": item["f14"], "price": number(item.get("f2")),
             "change_percent": number(item.get("f3")),
             "change_amount": number(item.get("f4")),
             "volume": number(item.get("f5")), "turnover": number(item.get("f6"))}
            for item in items]
    return rows, int(data["total"])


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--limit", type=int, default=100, help="采集条数,默认 100")
    args = parser.parse_args()
    if args.limit <= 0:
        parser.error("--limit 必须大于 0")
    collected_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
    raw_dir = BASE_DIR / "raw"
    raw_dir.mkdir(exist_ok=True)
    records, seen = [], set()
    page, total = 1, None
    with requests.Session() as session:
        session.headers.update({"User-Agent": "Mozilla/5.0", "Referer": "https://quote.eastmoney.com/"})
        while total is None or len(records) < min(args.limit, total):
            response = session.get(API_URL, params={**PARAMS, "pn": page, "pz": 20}, timeout=30)
            response.raise_for_status()
            rows, total = parse_stocks(response.text)
            (raw_dir / f"page_{page}.json").write_text(
                json.dumps(response.json(), ensure_ascii=False, indent=2), encoding="utf-8")
            if not rows:
                raise ValueError("未达到目标条数却返回空页,请检查接口分页。")
            for row in rows:
                key = (row["market"], row["code"])
                if key in seen:
                    raise ValueError("分页出现重复股票,行情排序可能变化,请重新运行。")
                seen.add(key)
                records.append((*row.values(), collected_at))
            print(f"第 {page} 页:{len(rows)} 条;接口总数:{total}")
            page += 1
            time.sleep(0.5)
    records = records[:args.limit]
    db_path = BASE_DIR / "stocks.db"
    with sqlite3.connect(db_path) as connection:
        connection.execute('''CREATE TABLE IF NOT EXISTS stocks (
            code TEXT, market INTEGER, name TEXT, price REAL, change_percent REAL,
            change_amount REAL, volume REAL, turnover REAL, collected_at TEXT,
            PRIMARY KEY (market, code))''')
        connection.execute("DELETE FROM stocks")
        connection.executemany("INSERT INTO stocks VALUES (?,?,?,?,?,?,?,?,?)", records)
    print(f"共保存 {len(records)} 条;数据库:{db_path}")


if __name__ == "__main__":
    main()

运行与结果

python 作业2/2_股票/stocks.py

股票代码使用 TEXT 保存,避免丢失前导零;缺失数值保存为 NULL。下面是本次运行和查询结果,完整数据位于 stocks.db。

股票运行结果

图 7 股票采集日志与数据库查询结果

2. 作业心得

天气采集

天气采集的关键是先确定数据所在的位置。城市首页展示的主要是当天信息,切换到七日预报后,才能找到本次作业需要的完整列表。结合网页和开发者工具,可以把每一天的 li 与日期、天气、温度、风等字段对应起来,再按同样的结构逐条提取。

编写程序时,网页上看到的标签还需要转换为正确的选择器。例如,七日预报区域的 ID 是 7d,直接使用 #7d 会产生选择器语法错误,改为 [id="7d"] 后才正确定位到数据。另外,最高温和最低温分布在不同标签中,最高温也可能缺失,因此不能假定每一天都有两个温度值。程序保留缺失值,而不是自行补上一个温度。

结果核对不能只看程序有没有报错。本次按城市检查记录数,确认五个城市各有七条预报,再查看数据库中的天气和温度是否与网页对应。这样既能发现漏采,也能检查字段是否取错。

股票采集

股票采集让我更清楚地理解了网页和数据接口的关系。网页负责展示,接口负责返回数据;在 Network 中对比第一、第二页请求,可以发现分页主要由 pn 控制,不需要分别为每一页编写解析代码。

接口返回成功也不代表数值可以直接使用。抓包请求中的 cb 会使响应带上回调函数,而本次要求使用 JSON 解析,因此程序去掉 cb,再用 json.loads 读取数据。fltt 参数也会影响价格和涨跌幅的表示形式,程序使用 fltt=2 返回小数值,避免把缩放后的整数直接存入数据库。

保存数据时,股票代码虽然由数字组成,但它是标识符,应当使用文本类型,才能保留前导零。价格、涨跌幅等字段则需要转换为数值,缺失值保存为 NULL。通过这部分作业可以看到,采集之后的数据清洗和类型选择,同样会影响结果是否正确。

后续可以把采集时间作为历史记录的一部分,保留多次采集的结果,用于观察变化。目前程序保存的是最近一次运行结果,适合本次作业展示;如果要长期采集,还需要进一步考虑请求重试和行情分页过程中排序变化的问题。


二、2021 年中国大学主榜信息采集

1. 接口分析、代码与结果展示

采集软科 2021 年中国大学主榜全部院校的排名、学校、省市、类型和总分,保存到 SQLite 数据库。本次共保存 582 所院校,与网页标注总数一致。

代码与数据库:作业③ Gitee 文件夹

F12 分析过程

通过 F12 查看网络请求,找到对应 2021 年排名的 payload.js。下面是实际录制的分析过程:

撤回

图 8 大学排名数据请求分析(GIF)

Screenshot 2026-11-06 164930

图 9 定位 2021 年排名的 payload.js

数据解析思路

该文件不是普通 JSON,而是 Nuxt 的 JavaScript 数据文件。一些字段值使用变量表示,实际值位于函数末尾的调用参数中。以下只是结构示意:

(function(a, b) {
    return {province: a, ranking: b};
})("北京", "1");

解析时将形参与实参按顺序配对,再还原数据对象中的变量。字符串字面量需要保留,不能直接进行全局文本替换。

完整代码|universities.py

"""发现 Nuxt payload 地址,解析 2021 年主榜全部学校并写入 SQLite。"""
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin
from zoneinfo import ZoneInfo

import chompjs
import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
PAGE_URL = "https://www.shanghairanking.cn/rankings/bcur/2021"


def parse_rankings(script):
    """将 IIFE 的形参与实参配对,替换值中的变量;不执行远程 JS。"""
    match = re.search(r'function\(([^)]*)\)\s*\{\s*return\s*', script)
    if not match:
        raise ValueError("未找到 Nuxt 函数,请检查 payload 格式。")
    # 当前 Nuxt 格式为 function(...){return {...}}(实参列表)。
    tail = re.search(r'\}\((.*)\)\)\);?\s*$', script, re.S)
    if not tail:
        raise ValueError("未找到 Nuxt 实参列表。")
    names = [name.strip() for name in match.group(1).split(",")]
    arguments = chompjs.parse_js_object("[" + tail.group(1) + "]")
    if len(names) != len(arguments):
        raise ValueError("Nuxt 形参与实参数量不一致。")
    variables = dict(zip(names, arguments))
    body = script[match.end():tail.start()]
    # 字符串作为完整 token 保留;对象键后接冒号时也不替换。
    tokens = re.compile(r'"(?:\\.|[^"\\])*"|\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b|[A-Za-z_$][\w$]*')

    def resolve(token):
        value = token.group()
        if value.startswith('"') or body[token.end():].lstrip().startswith(":"):
            return value
        return json.dumps(variables[value], ensure_ascii=False) if value in variables else value

    payload = chompjs.parse_js_object(tokens.sub(resolve, body))
    data = payload["data"][0]
    if int(data["year"]) != 2021:
        raise ValueError("数据年份不是 2021。")
    rows = [{"ranking": str(item["ranking"]), "name": item["univNameCn"],
             "province": item["province"], "category": item["univCategory"],
             "score": float(item["score"])} for item in data["univData"]]
    if not rows or len({row["name"] for row in rows}) != len(rows):
        raise ValueError("学校列表为空或包含重复学校。")
    return rows


def main():
    raw_dir = BASE_DIR / "raw"
    raw_dir.mkdir(exist_ok=True)
    with requests.Session() as session:
        session.headers.update({"User-Agent": "Mozilla/5.0", "Referer": PAGE_URL})
        response = session.get(PAGE_URL, timeout=30)
        response.raise_for_status()
        response.encoding = "utf-8"
        soup = BeautifulSoup(response.text, "html.parser")
        state = next((tag.get("src") for tag in soup.select("script[src]")
                      if "/rankings/bcur/2021/state.js" in tag.get("src", "")), None)
        if not state:
            raise ValueError("页面未找到 state.js 地址,不能确定 payload 路径。")
        # 从页面动态获取构建版本,避免把容易过期的版本号写死。
        payload_url = urljoin(PAGE_URL, state.replace("state.js", "payload.js"))
        result = session.get(payload_url, timeout=30)
        result.raise_for_status()
        result.encoding = "utf-8"
    rows = parse_rankings(result.text)
    count_match = re.search(r"共\s*(\d+)\s*所学校", soup.get_text(" ", strip=True))
    if not count_match or len(rows) != int(count_match.group(1)):
        raise ValueError("解析条数与网页标注的学校总数不一致。")
    (raw_dir / "page.html").write_text(response.text, encoding="utf-8")
    (raw_dir / "payload.js").write_text(result.text, encoding="utf-8")
    collected_at = datetime.now(ZoneInfo("Asia/Shanghai")).isoformat(timespec="seconds")
    db_path = BASE_DIR / "universities.db"
    with sqlite3.connect(db_path) as connection:
        connection.execute('''CREATE TABLE IF NOT EXISTS universities (
            ranking TEXT, name TEXT PRIMARY KEY, province TEXT, category TEXT,
            score REAL, year INTEGER, collected_at TEXT, source_url TEXT)''')
        connection.execute("DELETE FROM universities")
        connection.executemany("INSERT INTO universities VALUES (?,?,?,?,?,?,?,?)",
                               [(*row.values(), 2021, collected_at, payload_url) for row in rows])
    print(f"数据接口:{payload_url}")
    print(f"共保存 {len(rows)} 所院校,与网页总数一致;数据库:{db_path}")
    for row in rows[:5]:
        print(row)


if __name__ == "__main__":
    main()

运行与结果

python 作业2/3_大学排名/universities.py

程序从网页自动获取构建版本对应的文件路径,请求 payload.js 时带上页面 Referer。解析后检查年份、重复学校,以及记录数是否与网页标注一致,再写入 universities.db。

大学排名运行结果

图 10 大学排名采集日志与数据库查询结果

首条数据为:排名 1、清华大学、北京、综合、总分 969.2。完整 582 所学校的数据可在 Gitee 下载数据库查看。

2. 作业心得

从页面展示追溯数据来源

大学排名页面有分页,只解析当前页面展示的学校,不能满足“全部院校”的要求。通过 F12 查找网络请求,定位到 2021 年主榜对应的 payload.js 后,才能从数据文件中提取完整学校列表。这个过程说明,采集前应先判断数据是直接写在 HTML 中,还是通过其他请求加载,再选择解析方法。

处理 JavaScript 数据文件

payload.js 与普通 JSON 不同,一些字段使用变量表示,实际值位于末尾的函数调用参数中。解析时需要先把形参和实参按顺序配对,再还原对象里的字段值。还原过程必须区分变量、对象键和字符串字面量,否则简单的全局替换可能改坏学校名称等原始内容。

实际请求时还遇到了 403 响应。根据响应中的 Referer 限制提示,加入排名页面的 Referer 后,请求成功返回数据。这说明排查请求问题时,需要查看状态码和响应内容,而不能仅凭“浏览器能打开”就认定程序请求也一定相同。程序还从网页动态获取构建版本对应的路径,避免版本号变化后原来的链接失效。

验证完整性与保存结果

本次将解析出的 582 所院校与网页标注数量进行核对,检查学校名称是否重复、年份是否为 2021,并确认首条记录为清华大学、总分 969.2。数据库完整性检查也通过了。这些检查分别验证了记录数量、关键字段和存储结果,比只展示前几所学校更能说明采集是否完整。

三个任务使用了不同的数据解析方式,但基本步骤相同:分析来源、发送请求、解析字段、整理类型、写入数据库,再查询核对。完成这次作业后,对这些步骤之间的联系有了更具体的理解。后续如果网站结构发生变化,也可以从请求和原始响应开始逐步排查,而不是直接修改提取代码。

posted @ 2026-11-06 18:18  MieSheeeep  阅读(6)  评论(0)    收藏  举报