数据采集实践第二次作业

第二次作业:基于 requests 的网络爬虫实践(天气 / 股票 / 大学排名)

本次作业共三个任务,统一使用 Python 3 + requests 发起请求,分别配合 BeautifulSoup 解析 HTML、json 解析接口数据,最终全部落地到 SQLite3 数据库。按课程要求,本次作业集中放在 作业二/ 目录下,内部按题目分为 1/、2/、3/ 三个子目录,每个子目录包含该题的 .py 源文件、数据库文件、预览 CSV 和图片目录。

仓库根目录结构:

data-collection/
├── .gitignore
└── 作业二/
    ├── 1/1.py              中国气象网 7 日天气预报
    ├── 2/2.py              新浪股票行情爬虫
    ├── 3/3.py              软科中国大学排名 2021 主榜
    ├── generate_images.py  博客配图生成脚本
    ├── requirements.txt    依赖清单
    ├── 博客.md             博客园发布用 Markdown
    └── README.md           运行说明

一、作业①:中国气象网 7 日天气预报

1.1 作业代码与运行结果

(1)数据来源分析

中国气象网的天气预报页面是服务端渲染的静态 HTML,不需要抓包找接口。打开任意城市页面后可以观察到 URL 规律:

http://www.weather.com.cn/weather/{城市代码}.shtml

城市代码可以在页面的搜索框中切换城市后从地址栏获得。本次选取 5 个覆盖东中西部、具有代表性的城市:

城市 城市代码
北京 101010100
上海 101020100
广州 101280101
成都 101270101
西安 101110101

页面的 7 天预报集中在 <ul class="t clearfix"> 下的 7 个 <li> 中,每天的结构为:

  • <h1>13日(今天)</h1>:日期标签,只有“日号”,没有年份;
  • <p class="wea">晴转多云</p>:天气现象;
  • <p class="tem"><span>16</span>/<i>3℃</i></p>:最高温 / 最低温;
  • <p class="win"><em><span title="北风"></span></em><i>&lt;3级</i></p>:风向与风力。

(2)核心代码

请求与解析部分(完整代码见 作业二/1/1.py):

BASE_URL = "http://www.weather.com.cn/weather/{code}.shtml"

CITY_CODES = {
    "北京": "101010100",
    "上海": "101020100",
    "广州": "101280101",
    "成都": "101270101",
    "西安": "101110101",
}


def fetch_weather_page(session: requests.Session, city_code: str) -> str:
    """下载指定城市的天气预报页面。"""
    url = BASE_URL.format(code=city_code)
    response = session.get(url, timeout=15)
    response.raise_for_status()
    # 中国气象网页面使用 UTF-8 编码,显式指定防止中文乱码
    response.encoding = "utf-8"
    return response.text


def parse_weather(html: str, city: str, city_code: str) -> list[dict]:
    """解析单个城市页面中的 7 日预报数据。"""
    soup = BeautifulSoup(html, "html.parser")
    items = soup.select("ul.t.clearfix > li")
    today = date.today()
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    records = []

    for index, item in enumerate(items):
        label_node = item.find("h1")
        if label_node is None:
            continue
        date_label = label_node.get_text(strip=True)
        day_match = re.search(r"(\d+)日", date_label)
        if day_match is None:
            continue
        day_number = int(day_match.group(1))

        weather_node = item.find("p", class_="wea")
        weather = weather_node.get_text(strip=True) if weather_node else ""

        # 气温节点形如“16/3℃”,第一段最高温、第二段最低温
        temp_node = item.find("p", class_="tem")
        temp_high, temp_low = None, None
        if temp_node:
            temp_text = temp_node.get_text(strip=True).replace("℃", "")
            parts = [part for part in temp_text.split("/") if part.strip()]
            if len(parts) == 2:
                temp_high = int(parts[0])
                temp_low = int(parts[1])

        # 风向:<em> 中可能有两个 <span>(白天、夜间),去重后拼接
        wind_nodes = item.select("p.win em span")
        directions = []
        for node in wind_nodes:
            direction = (node.get("title") or node.get_text(strip=True)).strip()
            if direction and direction not in directions:
                directions.append(direction)
        wind_direction = "转".join(directions)

        level_node = item.select_one("p.win i")
        wind_level = level_node.get_text(strip=True) if level_node else ""

        records.append({
            "city": city,
            "city_code": city_code,
            "forecast_date": resolve_forecast_date(day_number, today, index),
            "date_label": date_label,
            "weather": weather,
            "temp_high": temp_high,
            "temp_low": temp_low,
            "wind_direction": wind_direction,
            "wind_level": wind_level,
            "crawl_time": crawl_time,
        })

    return records

日期补全函数:页面只有“13日”,需要结合抓取当天补全年月,并处理跨月、跨年的情况:

def resolve_forecast_date(day_number: int, today: date, index: int) -> str:
    """
    把页面上只有“几号”的日期补全为完整日期(YYYY-MM-DD)。
    - 正常情况下取今天的年、月;
    - 若日号比今天小很多(超过 15 天),说明跨月了,月份加 1;
    - 若月份超过 12,则年份加 1、月份回到 1。
    """
    year, month = today.year, today.month
    if day_number < today.day - 15:
        month += 1
        if month > 12:
            month = 1
            year += 1
    try:
        return date(year, month, day_number).isoformat()
    except ValueError:
        # 处理 31 号落到 30 天月份等极端情况,退化为“今天 + 序号”
        return (today + timedelta(days=index)).isoformat()

入库部分使用 INSERT OR REPLACE,以“城市代码 + 日期”作为唯一键,重复运行不会产生脏数据:

def init_database(db_path: Path) -> sqlite3.Connection:
    connection = sqlite3.connect(db_path)
    connection.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,
            date_label    TEXT,
            weather       TEXT,
            temp_high     INTEGER,
            temp_low      INTEGER,
            wind_direction TEXT,
            wind_level    TEXT,
            crawl_time    TEXT,
            UNIQUE (city_code, forecast_date)
        )
        """
    )
    connection.commit()
    return connection

运行方式:

cd 作业二/1
python 1.py

(3)运行结果

终端成功输出 5 个城市、每个城市 7 天,共 35 条记录,并写入 weather.db、导出 weather_preview.csv:

[成功] 北京(101010100) 共解析 7 天预报
[成功] 上海(101020100) 共解析 7 天预报
[成功] 广州(101280101) 共解析 7 天预报
[成功] 成都(101270101) 共解析 7 天预报
[成功] 西安(101110101) 共解析 7 天预报

共写入 35 条记录,数据库:weather.db,CSV:weather_preview.csv

中国气象网 7 日天气预报抓取结果

数据库中的字段与页面一一对应:

字段 类型 含义
city TEXT 城市名称
city_code TEXT 中国气象网城市代码
forecast_date TEXT 补全后的完整日期
date_label TEXT 页面原始日期标签
weather TEXT 天气现象
temp_high / temp_low INTEGER 最高温 / 最低温
wind_direction TEXT 风向(白天转夜间)
wind_level TEXT 风力等级
crawl_time TEXT 抓取时间

1.2 作业心得

这个作业看起来最简单,但真正动手后发现问题都藏在细节里。

第一个坑是日期不完整。页面上写的是“13日(今天)”“14日(明天)”,只有日号没有年月,直接存进数据库以后没法做时间比较。我的处理办法是以抓取当天为基准补全年月,并额外判断“日号比今天小 15 天以上”就认为跨月,这样在月末抓取时也不会把下个月的 2 日错算成本月的 2 日。

第二个坑是文本格式不统一。气温是 16/3℃ 这种把最高温和最低温用斜杠拼在一起的字符串,需要先去掉 ℃ 再按 / 切分;风向节点里白天和夜间各有一个 <span>,广州这类城市两个都是“无持续风向”,如果直接拼接会得到“无持续风向转无持续风向”,所以我在拼接前做了去重。

第三个体会是 CSS 选择器比正则更稳。一开始想用正则直接抠 HTML,但页面标签层级一变就容易失效;改用 soup.select("ul.t.clearfix > li") 之后,代码可读性和稳定性都好了很多。这也让我理解了“解析 HTML 优先用解析库,解析接口才用 json”的分工。


二、作业②:股票信息定向爬虫(requests + json)

2.1 作业代码与运行结果

(1)接口抓包与选型

按题目提示进入东方财富网,F12 → Network → XHR,刷新行情列表可以看到列表数据由 push2.eastmoney.com/api/qt/clist/get 返回,核心参数为:

参数 含义
pn 页码
pz 每页条数
fid=f3 按涨跌幅排序
fs 市场范围,m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23 表示沪深 A 股
fields 需要返回的字段,如 f2 最新价、f3 涨跌幅、f12 代码、f14 名称

但实测发现,东方财富该接口在本机首次请求成功后,连续请求会被服务端直接断开连接(RemoteDisconnected),换用 82.push2、push2delay 等镜像域名同样失败。因此改用题目同样允许的新浪股票接口:

https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData

该接口返回标准 JSON 数组,完全满足“requests + json 解析”的要求。关键参数:

参数 取值 说明
page 1, 2, 3... 页码
num 100 每页条数,实测最大 100
sort symbol 按股票代码排序
asc 1 升序
node hs_a 沪深 A 股(含北交所)
symbol 空 不按代码过滤
_s_r_a page 按页取数据

(2)核心代码

请求与 JSON 解析(完整代码见 作业二/2/2.py):

API_URL = (
    "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/"
    "Market_Center.getHQNodeData"
)

PAGE_SIZE = 100          # 每页条数,接口实测最大支持 100
MAX_PAGES = 60           # 安全上限,防止接口异常时无限翻页
REQUEST_INTERVAL = 0.3   # 每页之间的间隔,避免给服务器造成压力


def fetch_stock_page(session: requests.Session, page: int) -> list[dict]:
    """抓取指定页的股票列表数据。"""
    params = {
        "page": page,
        "num": PAGE_SIZE,
        "sort": "symbol",
        "asc": 1,
        "node": "hs_a",
        "symbol": "",
        "_s_r_a": "page",
    }
    response = session.get(API_URL, params=params, timeout=15)
    response.raise_for_status()

    # 接口返回标准 JSON 数组,但编码声明为 GBK,需要手动指定
    response.encoding = "gbk"
    text = response.text.strip()
    if not text or text in ("null", "[]"):
        return []

    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        # 个别情况下接口会返回带 BOM 或非标准内容,按 UTF-8 再尝试一次
        response.encoding = "utf-8"
        data = json.loads(response.text.strip())
    return data if isinstance(data, list) else []

字段清洗:接口返回的数值都是字符串,停牌股票可能返回空字符串或 -,统一转换成 None 再入库:

def to_float(value) -> float | None:
    """把接口返回的字符串安全转换为浮点数。"""
    if value is None or value == "" or value == "-":
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def normalize_stock(raw: dict, crawl_time: str) -> dict:
    """把接口原始字段转换为数据库字段。"""
    return {
        "symbol": raw.get("symbol", ""),
        "code": raw.get("code", ""),
        "name": raw.get("name", ""),
        "price": to_float(raw.get("trade")),
        "change_amount": to_float(raw.get("pricechange")),
        "change_percent": to_float(raw.get("changepercent")),
        "open_price": to_float(raw.get("open")),
        "high_price": to_float(raw.get("high")),
        "low_price": to_float(raw.get("low")),
        "prev_close": to_float(raw.get("settlement")),
        "volume": to_int(raw.get("volume")),
        "amount": to_float(raw.get("amount")),
        "turnover_rate": to_float(raw.get("turnoverratio")),
        "pe_ratio": to_float(raw.get("per")),
        "pb_ratio": to_float(raw.get("pb")),
        "total_market_cap": to_float(raw.get("mktcap")),
        "circulating_market_cap": to_float(raw.get("nmc")),
        "trade_time": raw.get("ticktime", ""),
        "crawl_time": crawl_time,
    }

分页主循环:以“当前页返回条数不足 PAGE_SIZE”作为最后一页的判断条件:

for page in range(1, MAX_PAGES + 1):
    raw_list = fetch_stock_page(session, page)
    if not raw_list:
        print(f"[完成] 第 {page} 页返回空数据,共 {page - 1} 页有效数据")
        break

    records = [normalize_stock(raw, crawl_time) for raw in raw_list]
    save_records(connection, records)
    all_records.extend(records)

    if len(raw_list) < PAGE_SIZE:
        print("[完成] 当前页不足一页,已到达最后一页")
        break

    time.sleep(REQUEST_INTERVAL)

数据库表 stock_quote 共 19 个字段,以股票代码作为唯一键:

字段 含义
symbol / code / name 带市场前缀的代码 / 纯代码 / 名称
price / change_amount / change_percent 最新价 / 涨跌额 / 涨跌幅
open_price / high_price / low_price / prev_close 今开 / 最高 / 最低 / 昨收
volume / amount 成交量(股)/ 成交额(元)
turnover_rate / pe_ratio / pb_ratio 换手率 / 市盈率 / 市净率
total_market_cap / circulating_market_cap 总市值 / 流通市值(万元)
trade_time / crawl_time 行情时间 / 抓取时间

(3)运行结果

脚本自动翻页 56 页,共抓取 5578 只股票:

[进度] 第 1 页,获取 100 条,累计 100 条
[进度] 第 2 页,获取 100 条,累计 200 条
...
[进度] 第 56 页,获取 78 条,累计 5578 条
[完成] 当前页不足一页,已到达最后一页

共写入 5578 条股票记录,数据库:stock.db

新浪股票行情抓取结果(按成交额前 20 展示)

2.2 作业心得

这次作业最大的收获是学会了用 F12 定位真正的数据接口。以前爬页面只会 requests.get 整个 HTML,这次在 Network 面板里按 XHR 过滤、观察请求的 Query String,才明白列表型数据基本都是前端异步请求 JSON 再渲染的。题目里提到“f1、f2 对应不同数值、可按需删减参数”,我在东方财富接口上验证了这一点:只保留需要的字段后,返回体明显变小。

第二个体会是接口选型要考虑可用性。东方财富接口文档看起来最完整,但实际连续请求会被断连,调试了三个镜像域名都不行。这时候换用题目允许的新浪接口反而是正确选择——它不是“退而求其次”,而是让程序真正能跑通的工程决策。这一点让我意识到,爬虫代码的健壮性比“用最有名的网站”更重要。

第三个细节是编码和脏数据。新浪接口响应头声明 GBK,如果不手动设置 response.encoding = "gbk",中文股票名会变成乱码;停牌股票的字段可能是空字符串或 -,如果直接 float() 会抛异常。所以我在入库前统一做了类型清洗,保证数据库里的数值列是真正的数值类型,后续可以直接用 SQL 做排序和统计(比如按成交额取前 20)。


三、作业③:软科中国大学排名 2021 主榜

3.1 作业代码与运行结果

(1)F12 抓包分析过程(GIF 说明)

软科排名页面是前端动态渲染的,直接请求 https://www.shanghairanking.cn/rankings/bcur/2021 得到的 HTML 里没有院校列表,必须找到它背后的数据接口。操作步骤如下(可按此过程用 ScreenToGif 录制为 GIF):

  1. Chrome 打开 https://www.shanghairanking.cn/rankings/bcur/2021;
  2. 按 F12 打开开发者工具,切到 Network(网络) 面板,勾选 XHR 过滤;
  3. 按 F5 刷新页面,在请求列表中找到 bcur?bcur_type=11&year=2021&page=1&page_size=...;
  4. 点击该请求,在 Headers 中确认请求 URL 与参数,在 Preview / Response 中可以看到 rankings 数组;
  5. 右键该请求 → Copy → Copy as cURL,即可在代码中复现这次请求。

录制建议:使用免费工具 ScreenToGif(或 QQ 录屏),只录制“打开 F12 → 筛选 XHR → 刷新 → 点开 bcur 请求 → 查看 Response”这一段,保存为 3/images/f12-debug.gif,再在博客中插入 ![F12抓包过程](3/images/f12-debug.gif) 即可。

下面这张图是脚本真实请求该接口后保存的响应内容,可以看到 code=200、msg=success、rankings 中的清华、北大等数据:

软科排名接口真实响应

(2)接口参数与核心代码

参数 含义
bcur_type=11 中国大学排名(主榜)
year=2021 排名年份
page 页码,从 1 开始
page_size 每页条数

实测发现一个有意思的现象:无论 page_size 传多少,接口都会一次性返回全部 582 所院校(传 5 也会返回 582 条)。因此代码里做了兼容处理:按“学校名 + 排名”去重,如果某一页没有带来任何新数据,就判定已经到末页,避免死循环。

请求与解析代码(完整代码见 作业二/3/3.py):

API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
RANKING_YEAR = 2021
BCUR_TYPE = "11"       # 11 = 中国大学排名(主榜)
PAGE_SIZE = 600
MAX_PAGES = 10
REQUEST_INTERVAL = 0.5


def fetch_ranking_page(session: requests.Session, page: int) -> dict:
    """请求指定页的排名数据。"""
    params = {
        "bcur_type": BCUR_TYPE,
        "year": str(RANKING_YEAR),
        "page": page,
        "page_size": PAGE_SIZE,
    }
    response = session.get(API_URL, params=params, timeout=20)
    response.raise_for_status()
    payload = response.json()

    if payload.get("code") != 200:
        raise ValueError(f"接口返回异常:code={payload.get('code')}, msg={payload.get('msg')}")
    return payload.get("data") or {}


def normalize_ranking(raw: dict, crawl_time: str) -> dict:
    """把接口原始字段转换为数据库字段。"""
    ranking = raw.get("ranking")
    score = raw.get("score")

    return {
        "year": RANKING_YEAR,
        "ranking": int(ranking) if str(ranking).isdigit() else None,
        "ranking_text": str(ranking or ""),
        "school_name": raw.get("univNameCn", ""),
        "school_name_en": raw.get("univNameEn", ""),
        "province": raw.get("province", ""),
        "category": raw.get("univCategory", ""),
        "score": float(score) if score not in (None, "") else None,
        "tags": "、".join(raw.get("univTags") or []),
        "crawl_time": crawl_time,
    }

去重抓取逻辑:

def fetch_all_rankings(session: requests.Session) -> list[dict]:
    """
    抓取全部院校排名数据。
    该接口的 page_size 实际会被忽略、一次返回全量数据,因此这里做了兼容处理:
    用“学校名 + 排名”去重;若某一页没有带来任何新数据,则判定已到末页。
    """
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    collected: list[dict] = []
    seen: set[tuple[str, str]] = set()

    for page in range(1, MAX_PAGES + 1):
        data = fetch_ranking_page(session, page)
        rankings = data.get("rankings") or []
        if not rankings:
            print(f"[完成] 第 {page} 页没有数据,抓取结束")
            break

        before = len(collected)
        for raw in rankings:
            key = (raw.get("univNameCn", ""), str(raw.get("ranking", "")))
            if key in seen:
                continue
            seen.add(key)
            item = normalize_ranking(raw, crawl_time)
            if item["school_name"]:
                collected.append(item)

        added = len(collected) - before
        print(f"[进度] 第 {page} 页返回 {len(rankings)} 条,新增 {added} 条,累计 {len(collected)} 条")

        if added == 0:
            print("[完成] 本页未产生新数据,抓取结束")
            break
        time.sleep(REQUEST_INTERVAL)

    return collected

数据库表 university_rank 结构:

字段 类型 含义
year INTEGER 排名年份
ranking INTEGER 数字排名(用于排序)
ranking_text TEXT 原始排名文本(兼容并列名次)
school_name TEXT 学校中文名
school_name_en TEXT 学校英文名
province TEXT 省市
category TEXT 学校类型(综合/理工/师范等)
score REAL 总分
tags TEXT 双一流、985、211 等标签

(3)运行结果

[进度] 第 1 页返回 582 条,新增 582 条,累计 582 条
[进度] 第 2 页返回 582 条,新增 0 条,累计 582 条
[完成] 本页未产生新数据,抓取结束

共写入 582 所院校,数据库:rank.db,CSV:rank_preview.csv

软科中国大学排名 2021 主榜抓取结果

数据库查询验证(前 5 名):

SELECT ranking_text, school_name, province, category, score
FROM university_rank
ORDER BY ranking
LIMIT 5;
排名 学校 省市 类型 总分
1 清华大学 北京 综合 969.2
2 北京大学 北京 综合 855.3
3 浙江大学 浙江 综合 768.7
4 上海交通大学 上海 综合 723.4
5 南京大学 江苏 综合 654.8

3.2 作业心得

作业③是三个任务里技术含量最高的一个,因为它迫使我从“爬 HTML”转向“爬接口”。

第一个体会是动态页面必须抓包。一开始我直接用 requests 请求排名页面,返回的 HTML 有几十 KB,但里面根本没有学校名字,只有 <div id="app"></div> 这样的挂载点和一堆 JS 文件。这说明页面是 Vue/React 之类的框架在浏览器里渲染的,requests 拿到的只是“空壳”。真正有用的是 XHR 请求 api/pub/v1/bcur,它返回的 JSON 里才有完整的 rankings 数组。这也让我理解了题目为什么特意要求录制 F12 调试的 GIF——找到接口这一步才是这个作业的核心。

第二个体会是接口行为和文档不一致时要写容错。软科的 page_size 参数形同虚设,传 5 也返回全部 582 条。如果按常规“每页 30 条、循环 20 页”来写,就会把同一批数据重复插入 20 次。我改成用“学校名 + 排名”做集合去重,并检测本页新增数量,新增为 0 就停止,这样无论接口返回全量还是分页,代码都能正常结束。

第三个细节是并列名次。数据库里可以看到有两所学校同时排第 95 名,如果把 ranking 设成唯一键就会丢数据。因此我保留了 ranking_text 原始文本,唯一键改为“年份 + 学校名”,这样既不会因为并列名次丢记录,重复运行也不会产生重复数据。


四、总结

三个作业虽然都是“爬数据 + 存数据库”,但分别对应了三种典型场景:

作业 页面类型 解析方式 数据量 主要难点
① 天气预报 服务端渲染 HTML BeautifulSoup 35 条 日期补全、文本切分
② 股票行情 JSON 接口(分页) json.loads 5578 条 接口可用性、编码、脏数据
③ 大学排名 JSON 接口(全量) json.loads 582 条 动态渲染、接口去重

整体收获可以归纳为三点:

  1. 先分析、再写代码。天气页面看 DOM 结构,股票和排名用 F12 找 XHR 接口,方向对了后面就很顺;
  2. 爬虫代码要能“活着”。网络异常、接口变更、编码错误、脏数据都是常态,必须加超时、重试/跳过、类型清洗和去重;
  3. 数据落地要规范。统一用 SQLite3 建表,主键 + 唯一约束保证重复运行不产生脏数据,同时导出 CSV 方便验收。

五、Gitee 仓库地址

posted @ 2026-11-13 15:35  很具想  阅读(5)  评论(0)    收藏  举报