数采实践—第二次作业

本次作业完成三个定向采集任务:中国天气网 7 日天气预报、沪深 A 股行情、软科中国大学 2021 主榜,数据统一存入 MySQL。全文分两部分,每部分先说明关键实现与运行结果,再记录实验心得。

完整代码(Gitee):https://gitee.com/Vivian_azad/lyr/tree/master/作业2

博客只保留各题的关键片段并作解释,完整代码见仓库。

项目 内容
编程语言 Python 3.11
主要依赖 requests、beautifulsoup4、lxml、pymysql
数据库 MySQL 5.7.26(phpstudy 集成环境),库名 homework2

一、天气与股票数据采集(作业①、作业②)

1.1 代码实现与运行结果

作业2/
├── db_config.py     # 数据库连接配置,三题共用
├── 1.py             # 作业① 天气
├── 2.py             # 作业② 股票
├── 3.py             # 作业③ 大学排名
└── init_db.sql      # 建库建表脚本


作业① 中国天气网 7 日天气预报

页面 http://www.weather.com.cn/weather/{city_id}.shtml 会 301 跳转到 https。城市用 9 位 AREAID 标识,本次选 5 个城市:北京 101010100、上海 101020100、广州 101280101、福州 101230101、成都 101270101。

关键片段 1:城市集与接口地址

CITY_MAP = {
    "北京": "101010100",
    "上海": "101020100",
    "广州": "101280101",
    "福州": "101230101",
    "成都": "101270101",
}

WEATHER_URL = "https://www.weather.com.cn/weather/{city_id}.shtml"  # 必须用 https, 否则会被重定向到广告页,导致解析失败

把城市集与 URL 提成模块级常量,增删城市只改字典,不动业务逻辑;URL 固定用 https,避免 301 跳转带来的额外请求。

关键片段 2:7 日预报区块解析

    soup = BeautifulSoup(html, "lxml")
    block = soup.select_one('[id="7d"] ul.t.clearfix')
    if block is None:
        raise ValueError("未找到 7 日预报区块,页面结构可能已改版")

    records = []
    for li in block.select("li"):
        tem = li.select_one("p.tem")     # 温度:<span>最高</span>/<i>最低℃</i>
        win = li.select_one("p.win")     # 风:<em>风向</em><i>风力</i>

        # 白天与夜间的风向常重复,用 dict.fromkeys 保序去重后拼接
        directions = [span.get("title", "").strip() for span in win.select("em span")] if win else []
        directions = [d for d in dict.fromkeys(directions) if d]

        records.append({
            "city": city,
            "city_id": city_id,
            "forecast_date": _text(li.select_one("h1")),
            "weather": _text(li.select_one("p.wea")),
            "temp_high": _to_int(_text(tem.select_one("span"))) if tem else None,
            "temp_low": _to_int(_text(tem.select_one("i"))) if tem else None,
            "wind_dir": "/".join(directions),
            "wind_level": _text(win.select_one("i")) if win else "",
            "crawl_time": crawl_time,
        })
    return records

这段是本题最容易出错的地方:

  • 预报区块的 id 是 7d,CSS 选择器写 #7d 会抛 SelectorSyntaxError(id 数字开头),必须改用属性选择器 [id="7d"]。
  • 风向在白天、夜间各出现一次且常常相同,用 dict.fromkeys 保序去重后再拼接;当天往往只有最低温、没有最高温,清洗函数返回 None,入库为 NULL 而不是 0。

其余字段用 p.wea、p.tem、p.win 下的节点抽取,完整映射与入库逻辑见仓库 1.py。

1.py 运行终端截图(5 城市共 35 条预报、入库 35 行)

3e88763047b01899e1a402fe150f66c8

phpMyAdmin 中 weather_7d 表查询结果截图

image



作业② 东方财富股票行情

F12 → Network → Fetch/XHR → 刷新行情列表页,定位到 push2.eastmoney.com/api/qt/clist/get。题目提到的「f1、f2 可获取不同数值」指的是 fields= 参数里的字段编号,不是 URL 查询参数。

参数 含义
pn / pz 页码 / 每页条数
fs 市场板块过滤,m:0+t:6,... 为沪深主板、创业板、科创板
fid 排序字段,f3 按涨跌幅排序
fields 需要返回的 f 编号字段,可按需删减
fltt=2 价格直接返回浮点;不传则返回整数,需按 10^f152 还原
编号 含义 编号 含义
f12 股票代码 f14 股票名称
f2 最新价 f3 涨跌幅(%)
f4 涨跌额 f5 成交量(手)
f6 成交额(元) f7 振幅(%)
f8 换手率(%) f13 市场(0 深 / 1 沪)
f15 ~ f18 最高 / 最低 / 今开 / 昨收 f1 精度位数

关键片段 1:抓包得到的请求参数

FIELDS = "f1,f2,f3,f4,f5,f6,f7,f8,f12,f13,f14,f15,f16,f17,f18"

BASE_PARAMS_EAST = {
    "pn": 1,             # 页码,从 1 开始
    "pz": 100,           # 每页条数
    "po": 1,             # 1 降序
    "np": 1,             # 返回数组格式
    "ut": "bd1d9ddb04089700cf9c27f6f7426281",   # 接口固定标识
    "fltt": 2,           # 关键:价格直接返回浮点;不传则返回整数,需按 10^f152 手动还原
    "invt": 2,
    "fid": "f3",
    "fs": "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23",
    "fields": FIELDS,
}
BASE_PARAMS_SINA = {
    "page": 1,
    "num": 100,                 
    "sort": "changepercent",
    "asc": 0,                   # 0 降序
    "node": "hs_a",             # 沪深 A 股
}

这段是 F12 分析接口的直接成果:改 fields 就能增删返回字段,改 fs 就能切换板块;fltt=2 让价格直接是浮点数,省掉整数还原的处理。

关键片段 2:requests 取数 + json 解析

def request_json(url, params, headers):
 
    for attempt in range(1, RETRY_TIMES + 1):
        try:
            resp = requests.get(url, params=params, headers=headers, timeout=TIMEOUT)
            resp.raise_for_status()
            return json.loads(resp.text)
        except (RequestException, ValueError) as exc:   # JSONDecodeError 是 ValueError 子类
            if attempt == RETRY_TIMES:
                raise
            time.sleep(attempt)

题目要求的两个方法各自独立成步:requests.get 拿响应文本,json.loads 解析成字典。返回体为 {"rc":0,"data":{"total":5563,"diff":[...]}},按 data.total 算总页数后翻页。单页失败按 1s / 2s 退避重试,避免一次网络抖动打断整批任务。

关键片段 3:失败页补抓

    # 东财偶发 502 / 限流,间隔拉长后补抓一轮,避免少抓整页数据
    for page_no in failed_pages:
        for retry in range(1, RETRY_TIMES):
            try:
                _, items = fetch_eastmoney_page(page_no)
                raw_items.extend(items)
                print("  [第 %d 页补抓成功] +%d 条" % (page_no, len(items)))
                break
            except (RequestException, ValueError):
                time.sleep(2 * retry)
        else:
            print("  [放弃] 第 %d 页仍不可用,本次少抓该页" % page_no)

东财接口偶发 502(本次实测出现过第 4 页失败)。若直接跳过,一次就少 100 条数据,所以先记下失败页,拉长间隔后补抓一轮,仍失败才明确提示放弃;补抓页追加在末尾,最后按涨跌幅重排,保证输出顺序与页面一致。

此外,主源整体不可达时程序会自动降级到新浪财经 Market_Center.getHQNodeData,两个数据源映射到同一张表:成交量统一换算成「手」、振幅按 (最高-最低)/昨收*100 派生、市场由 symbol 前缀判断。完整实现见仓库 2.py。

2.py 运行终端截图(数据源、500 条行情、入库 500 行)

d18c7ce36b6da2f9a636828e00c20269

stock_quote 表查询结果截图

image

1.2 实验心得

抓包要先看参数结构,不能照着 URL 猜。题目里「f1、f2 可获取不同数值」我一开始理解成 URL 上的查询参数,改了半天没有变化。打开 F12 把请求参数摊开才明白,它们指的是 fields= 里的字段编号,换编号就是换返回列。同理 fltt=2 让价格直接以浮点返回,不传就得按 10^f152 还原。这一步的收获是:先逐个确认参数含义再写请求,否则后面全在给错误假设打补丁。
其次是,页面结构不能假设它完整。天气页 7 日区块的 id 是 7d,CSS 写 #7d 直接抛 SelectorSyntaxError,换成属性选择器 [id="7d"] 才解决。当天常常只有最低温没有最高温,风向在白天和夜间重复出现。如果按「每个字段一定存在」去写,要么中途报错,要么把缺失值写成 0,后者更危险——库里看起来有数,其实是错的。所以清洗时用 None 表示缺失,风向用 dict.fromkeys 保序去重。
在这个过程中我发现,爬虫的难点不在解析,在失败处理。东财翻页时第 4 页返回过一次 502,最早的代码直接跳过,等于一次少 100 条。后来改成失败页记录、拉长间隔补抓一轮、仍失败才明确提示。主源整体不可达时会自动降级到新浪,两个源的口径必须对齐:成交量股与手相差 100 倍、新浪不给振幅要按 (最高-最低)/昨收*100 派生、市场由代码前缀判断。实测两种来源对同一只股票算出的振幅一致,才敢把它们写进同一张表。


二、中国大学 2021 主榜采集(作业③)

2.1 代码实现、F12 抓包过程与结果

F12 抓包分析过程

步骤 操作 观察结果
1 打开 shanghairanking.cn/rankings/bcur/2021 页面直接显示主榜 582 所院校
2 F12 → Network → 勾「保留日志」「禁用缓存」,过滤切「全部」→ 刷新 榜单由页面 HTML 直接渲染,请求列表里只有 collect、clarity 等第三方埋点,没有数据接口
3 过滤框输入 payload → 点开 payload.js 的 Response Nuxt 静态生成文件,__NUXT_JSONP__ 中含 582 所院校数据;它是 script 类型,Fetch/XHR 过滤下看不到
4 点开 _nuxt/*.js,在 Response 里 Ctrl+F 搜 bcur 在 asyncData 中找到 /pub/v1/bcur?bcur_type=&year= 的定义,但它由服务端渲染时调用内网地址,浏览器端不发包
5 在排名页 Console 执行 fetch('/api/pub/v1/bcur?bcur_type=11&year=2021') Network 出现 bcur 请求、状态 200;Preview 中 data.rankings 为 582 条标准 JSON

页面是 Nuxt 静态生成(SSG):首屏表格由服务端渲染在 HTML 里,全量数据放在预加载的 payload.js,所以单纯刷新页面抓不到数据接口。把 JS 中定位到的路径换成站点公开域名下的 /api/pub/v1/bcur,即可直接取得标准 JSON,这也是本题选择的取数方式。

F12 抓包分析全过程 Gif

lv_0_20261112205958

F12 Response 面板中 data.rankings JSON 结构截图

image

参数 / 请求头 含义
bcur_type=11 中国大学排名主榜
year=2021 年份
Referer 防盗链校验,必带
返回字段 对应列
ranking 排名
univNameCn 学校
province 省市
univCategory 类型
score 总分

关键片段 1:请求头与公开接口

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",
}

请求头里 User-Agent 用于模拟浏览器;Referer 对应 F12 中看到的页面来源,但实测并非必需——不带该头接口同样返回 200 和 582 条数据,保留它只是让脚本请求更接近真实浏览器。真正决定返回内容的是 bcur_type=11(主榜)与 year=2021(年份)两个查询参数,它们来自上一步在 JS asyncData 中定位到的接口定义。

关键片段 2:一次性取回全量榜单

def fetch_rankings():
    """
    请求排名 API,返回 data.rankings 
    """
    resp = requests.get(API_URL, params=PARAMS, headers=HEADERS, timeout=TIMEOUT)
    resp.raise_for_status()
    payload = json.loads(resp.text)

    data = payload.get("data")
    if not isinstance(data, dict) or not isinstance(data.get("rankings"), list):
        raise ValueError("返回体缺少 data.rankings,接口可能已改版")
    rankings = data["rankings"]
    if not rankings:
        raise ValueError("data.rankings 为空")
    return rankings

接口返回的是标准 JSON,582 所院校全在 data.rankings 里,不需要 Selenium 翻页,也不需要解析页面里的 payload.js。函数里加了结构校验,接口一旦改版会明确报错,而不是静默产出空表。

关键片段 3:并列排名决定主键设计

CREATE TABLE IF NOT EXISTS univ_rank_2021 (
    ranking   SMALLINT      NOT NULL COMMENT '排名',
    name      VARCHAR(64)   NOT NULL COMMENT '学校',
    province  VARCHAR(32)   DEFAULT NULL COMMENT '省市',
    category  VARCHAR(32)   DEFAULT NULL COMMENT '类型',
    score     DECIMAL(6,1)  DEFAULT NULL COMMENT '总分',
    PRIMARY KEY (ranking, name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='软科中国大学排名2021主榜'

实测 2021 主榜有 62 个并列名次(例如第 95 名为南京邮电大学与燕山大学)。若把 ranking 单独设为主键,并列院校会被互相覆盖,所以采用 (ranking, name) 复合主键;ranking 用 SMALLINT 保证按数值排序,避免字符串排序出现 10 排在 2 之前。表严格对应题目要求的 5 列。

3.py 运行终端截图(582 所院校、入库 582 行)
3d1c5c0a119efbc14b29a1ad66dc5410

univ_rank_2021 表查询结果截图
image
image

2.2 实验心得

在这个实验的过程中,我做的最大的一次判断错误,是把「抓不到请求」当成操作失误。按常规思路打开排名页、F12、过滤 Fetch/XHR、刷新,请求列表里只有 collect、clarity 这类第三方埋点,始终找不到 bcur。后来去读页面源码才明白:这是 Nuxt 静态生成(SSG)的页面,首屏表格由服务端直接渲染在 HTML 里,全量 582 所院校放在预加载的 payload.js,而它的类型是 script,Fetch/XHR 过滤下本来就看不见。刷新多少次也不会出现数据接口。所以教训是:先判断页面的渲染方式,再决定去哪里找请求。
此外,定位接口不能只盯 Network 列表。换成「全部」过滤后找到 payload.js,在打包脚本里全局搜 pub/v1/bcur,看到 asyncData 中的调用,但它拼的是服务端内网地址,属于服务端渲染阶段的内部请求,浏览器永远不会发。于是换成站点公开域名下的 /api/pub/v1/bcur,在排名页 Console 里用 fetch 验证,Network 才出现真正的 bcur 请求,Preview 里能摊开 582 条 data.rankings。整个过程从「看请求列表」扩展到「看 HTML、预加载资源、JS bundle、Console 验证」,这是这次抓包分析真正的收获。
探查时发现 582 所院校里有 62 个并列名次,第 95 名同时是南京邮电大学和燕山大学。若把 ranking 单独设为主键,其中一所会被直接覆盖,所以改成 (ranking, name) 复合主键;ranking 用 SMALLINT 而不是字符串,否则排序会出现 10 排在 2 前面。

posted @ 2026-11-12 21:19  Vivian_azad  阅读(0)  评论(0)    收藏  举报