数据采集作业2

一、三个定向爬虫:从抓包到入库

三个脚本都是 requests + BeautifulSoup 路线,数据统一落在 SQLite 里。

1.1 代码实现与运行结果

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

抓包结论:中国气象网没有对外提供 7 日预报的 JSON 接口(d1.weather.com.cn/weather_index/ 只给 5 天,且字段是 fa/fb/fc 这种天气代码,还得自己维护一张代码表)。7 天数据其实直接写在城市页面的源码里:

http://www.weather.com.cn/weather/101200101.shtml

F12 里 Ctrl+F 搜索"今天",能看到 <ul class="t clearfix"> 下面正好 7 个 <li>,一个 <li> 就是一天。需要的信息都能拿到:

字段 位置
日期 h1 文本,如"6日(今天)"
天气 p.wea 的 title 属性
最高/最低温 p.tem > span / p.tem > i
风向、风力 p.win em span[title] / p.win > i

关键解析代码:

soup = BeautifulSoup(html, "lxml")
forecast_list = soup.select("ul.t.clearfix > li")   # 一次拿到 7 天
for index, item in enumerate(forecast_list):
    # 日期用 "今天 + index 天" 推算,比解析 "6日" 更稳,可跨月
    forecast_date = (today + timedelta(days=index)).isoformat()
    wea = item.select_one("p.wea")
    weather_desc = wea.get("title") or wea.get_text(strip=True)
    ...

两个坑:

  1. 必须带 Referer。不带 Referer: http://www.weather.com.cn/ 或者 UA 用的是 python-requests/x.x,会拿到 403 或者一个空壳页面。
  2. 温度要拆成最高/最低两列。p.tem 里是 23/13℃ 这种结构,<span> 是最高温、<i> 是最低温;个别只有 13℃ 一个值,代码里做了兜底(当作最高温)。

入库表设计成 UNIQUE(city_code, forecast_date) + INSERT OR REPLACE,脚本跑多少次都不会产生重复行:

CREATE TABLE IF NOT EXISTS weather_7d (
    city_name TEXT, city_code TEXT, forecast_date TEXT,
    weather_desc TEXT, temp_high INTEGER, temp_low INTEGER,
    wind_dir TEXT, wind_level TEXT, crawl_time TEXT,
    UNIQUE (city_code, forecast_date)
);

运行结果(5 个城市 × 7 天 = 35 条):

[INFO] 【武汉】7 日天气预报
日期          天气        气温          风向            风力
2026-11-06  晴         23/13℃      东南风/东风        <3级
2026-11-07  中雨        18/14℃      东风/北风         <3级
2026-11-08  小雨转多云     17/6℃       北风/北风         3-4级
2026-11-09  阴转多云      13/2℃       北风/北风         <3级
2026-11-10  多云转阴      15/2℃       东南风/东风        <3级
2026-11-11  阴         18/6℃       东风/东风         <3级
2026-11-12  阴         20/11℃      东风/西南风        <3级
...
[INFO] 抓取结束,共写入 35 条记录 -> weather.db

1.1-运行结果

数据库里的样子:

1.1-数据库

完整代码:

作业1 —— 1.1.py(点击展开)
CITY_CODES = {
    "武汉": "101200101", "北京": "101010100", "上海": "101020100",
    "广州": "101280101", "成都": "101270101",
}

def fetch_html(session, city_code, timeout=10):
    """下载指定城市的 7 日预报页面源码。"""
    url = BASE_URL.format(code=city_code)
    try:
        response = session.get(url, timeout=timeout)
        response.raise_for_status()
        response.encoding = "utf-8"      # 响应头未声明编码,手动指定防止乱码
        return response.text
    except requests.RequestException as exc:
        logger.error("请求失败 %s:%s", url, exc)
        return None

def parse_seven_day(html):
    """解析页面中的 7 日预报。"""
    soup = BeautifulSoup(html, "lxml")
    forecast_list = soup.select("ul.t.clearfix > li")
    ...
    return rows

def save_to_db(city_name, city_code, rows):
    """INSERT OR REPLACE + UNIQUE 约束,保证重复运行不产生脏数据。"""
    ...

完整文件见仓库 作业1/1.1.py,包含请求重试、字段容错、控制台表格打印。

作业二:东方财富股票行情

抓包结论:行情中心翻页时页面不刷新,数据只走一个接口。这里有个坑要提醒:它是 JSONP(URL 里带 cb=jQuery...),Network 里类型显示为 script 而不是 XHR——如果只勾「Fetch/XHR」筛选,会一条都看不到,得切到「全部」再按 clist 搜:

https://push2.eastmoney.com/api/qt/clist/get
    ?pn=1&pz=50                 # 页码、每页条数
    &po=1&np=1&fid=f3           # 按 f3(涨跌幅)降序
    &ut=bd1d9ddb04089700cf9c27f6f7426281   # 接口固定 token
    &fltt=2&invt=2
    &fs=m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23  # 市场范围:沪深 A 股
    &fields=f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23

题目说的"根据所要求的参数可适当更改 api 的参数值"就是这几个:

参数 作用 常用取值
pn / pz 页码 / 每页条数 pn=1..n,pz 实测最大 100
fid 排序字段 f3 涨跌幅、f6 成交额、f8 换手率、f20 总市值
po 排序方向 1 降序、0 升序
fs 市场范围 m:0+t:80 创业板、m:1+t:23 科创板
fields 返回字段 不传就拿不到,字段名要自己对

返回结构是 {"data": {"total": 5563, "diff": [...]}},diff 里每只股票的 key 是 f 序号:

{"f2": 4.55, "f3": 20.05, "f12": "300071", "f14": "福石控股", "f6": 1220204361.02, ...}
 最新价      涨跌幅       代码            名称            成交额(元)

所以代码里做了一张 FIELD_MAP(f 序号 → 中文列名),入库时统一翻译。同时把 pn/pz/fid/fs 全部做成命令行参数:

parser.add_argument("--pages", type=int, default=3)
parser.add_argument("--page-size", type=int, default=50)
parser.add_argument("--sort-by", default="f3", choices=["f3", "f6", "f8", "f2", "f20"])
parser.add_argument("--market", default="all", choices=list(MARKET_SCOPE.keys()))

个股明细用新浪的 hq.sinajs.cn,这个接口有两个硬性要求:必须带 Referer: https://finance.sina.com.cn/(否则 403 "Kick off"),返回是 GBK 编码,要用 response.content.decode("gbk") 而不是 response.text。

SINA_HEADERS = {"User-Agent": UA, "Referer": "https://finance.sina.com.cn/"}
text = session.get(f"https://hq.sinajs.cn/list={symbols}", headers=SINA_HEADERS).content.decode("gbk")
# var hq_str_sh600519="贵州茅台,1446.700,1446.440,1452.000,1466.000,...";

运行结果(3 页 × 50 条,按涨跌幅降序,抓的是当日涨停板):

代码      名称              最新价     涨跌幅%     成交额(亿)     换手率%
----------------------------------------------------------------
300071  福石控股           4.55    20.05      12.20    29.08
300709  精研科技          41.38    20.01      11.05    18.65
688316  青云科技-U        48.10    20.01       2.75    12.90
301209  联合化学          39.95    20.01       3.44     7.24
301000  肇民科技          28.08    20.00       4.42     7.35
300093  金刚光伏          18.66    20.00       4.62     9.25
...
[INFO] 行情列表共入库 150 条 -> stock.db
[INFO] 个股明细入库 10 条

2.1-运行结果

入库后 stock_quote 表里的样子(150 条,按涨跌幅降序抓的前 150 只):

2.1-数据库

F12 里那个关键请求的样子:

2.1-F12抓包

作业二 —— 2.1.py 核心代码(点击展开)
def fetch_stock_page(session, page=1, page_size=50, sort_field="f3", market="all"):
    """抓取一页行情列表,返回 (总条数, 股票列表)。"""
    params = {
        "pn": page, "pz": page_size, "po": 1, "np": 1,
        "ut": "bd1d9ddb04089700cf9c27f6f7426281",
        "fltt": 2, "invt": 2,
        "fid": sort_field,
        "fs": MARKET_SCOPE.get(market),
        "fields": REQUEST_FIELDS,
    }
    response = session.get(LIST_API, params=params, timeout=15)
    payload = response.json()
    return payload["data"]["total"], payload["data"]["diff"]

def normalize_quote(raw, market, crawl_time):
    """把 f 序号字段翻译成有意义的列名,停牌返回的 '-' 转成 None。"""
    ...

完整文件见仓库 作业2/2.1.py。

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

抓包结论:这里有个容易踩错的点——页面本身根本不请求 bcur 接口。这个站是 Nuxt 服务端渲染,F12 里能看到的只有 _nuxt/static/xxxx/rankings/bcur/2021/payload.js(约 232 KB),582 条数据全打包在这个 JS 里,点「下一页」不会产生任何新请求。我把主榜 2021、2022、学科榜、带 ?page=2 的地址都试了一遍,/api/ 下只有会员状态和版本号两个 XHR,一条数据接口都没有。

所以采集时不走页面那套,改请求站点同源的公开接口,直接 GET 就是干净的 JSON(和 payload.js 里是同一份数据):

https://www.shanghairanking.cn/api/pub/v1/bcur?bcur_type=11&year=2021

bcur_type=11 是主榜,接口一次返回整份榜单(582 所),每条形如:

{
  "ranking": "1", "univNameCn": "清华大学", "province": "北京",
  "univCategory": "综合", "score": 969.2,
  "univTags": ["双一流", "985", "211"],
  "indData": {"159": "37.9", "160": "74.3", ...}
}

indData 的 key 是各分项指标的编号(办学层次、学科水平、师资规模……),接口返回的 data.inds 里给了编号和中文名,所以代码把它整体压成 JSON 字符串存进 ind_detail 列,不做硬编码。

因为接口一次性返回全量,脚本仍然按 page 循环,但用学校名去重:某一页没有任何新增学校就说明到底了,这样接口以后真改成分页也不用动代码。

for page in range(1, MAX_PAGE + 1):
    rankings = fetch_page(session, year, page=page)
    new_count = 0
    for item in rankings:
        name = item.get("univNameCn")
        if name and name not in collected:
            collected[name] = item
            new_count += 1
    if new_count == 0:      # 没有新数据 -> 已到最后一页
        break

运行结果(582 所,与站点总数一致):

[INFO] 第 1 页返回 582 条,新增 582 条,累计 582 条
[INFO] 第 2 页返回 582 条,新增 0 条,累计 582 条
[INFO] 第 2 页无数据,翻页结束
排名    学校              省市      类型            总分
--------------------------------------------------
1     清华大学            北京      综合         969.2
2     北京大学            北京      综合         855.3
3     浙江大学            浙江      综合         768.7
4     上海交通大学          上海      综合         723.4
5     南京大学            江苏      综合         654.8
...
[INFO] 2021 年主榜共入库 582 所院校 -> university.db

入库后 university_rank 表的记录(582 所,截图只展示前 12 条):

3.1-数据库

分页过程的录屏(这里录了第 1 页到第 4页):

3.1-分页

这张 GIF 是用 作业3/record_gif.py 录的:Playwright 驱动 Chrome 真实打开榜单页、点"下一页"、逐页截图,同时监听 response 事件把所有请求记下来,最后合成 GIF。录完发现一个和直觉相反的事实:这个站点了"下一页"根本不发请求——582 条数据在首屏加载时就随页面一次性取回了(那一帧底部显示的 rankings/bcur/2021/payload.js,232.4 KB),翻页只是前端把内存里的数据换一页显示。所以"抓包分析分页"的正确结论不是"找到第 N 页的接口",而是"发现分页不需要接口"。

作业三 —— 3.1.py 核心代码(点击展开)
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
MAIN_RANK_TYPE = 11          # bcur_type:11 = 主榜

def fetch_page(session, year, page=1, page_size=50):
    params = {"bcur_type": MAIN_RANK_TYPE, "year": year,
              "page": page, "page_size": page_size}
    payload = session.get(API_URL, params=params, timeout=20).json()
    if payload.get("code") != 200:
        return []
    return payload["data"]["rankings"]

def normalize(item, year, crawl_time):
    return {
        "ranking": str(item.get("ranking", "")).strip(),
        "univ_name": item.get("univNameCn"),
        "province": item.get("province"),
        "category": item.get("univCategory"),
        "score": item.get("score"),
        "tags": ",".join(item.get("univTags") or []),
        "ind_detail": json.dumps(item.get("indData") or {}, ensure_ascii=False),
        ...
    }

完整文件见仓库 作业3/3.1.py,另外会导出一份 rankings_2021.csv(列名就叫排名/学校/省市/类型/总分,直接能贴进表格)。

1.2 作业心得

1. 先看 Network,再动键盘。 这三个作业如果上来就 requests.get 然后 BeautifulSoup,作业一能勉强成功,作业二、三会直接卡死——因为页面上根本不存在数据,数据是 JS 请求回来再塞进 DOM 的。真正省时间的是 F12 里按关键字搜请求、看 Preview 那一栏。判断标准很简单:接口路径里有 clist、rankings 这种"名词"、响应体是 JSON 的,基本就是数据源。另外筛选类型别死守 Fetch/XHR——东财那条是 JSONP,类型是 script;软科更极端,数据直接塞在页面预取的 payload.js 里,连接口都不发。

2. 参数是接口的"方言",要一个个试。 东方财富的 fields 不传就没有对应字段,fs 改成 m:0+t:80 立刻变成创业板。我第一次抓的时候只传了 f12,f14,结果价格和涨跌幅全是空——不是被反爬了,是我没要。把参数抽成命令行参数以后,改市场、改排序都不用动代码,调试成本降了一大截。

3. 反爬不只有验证码,还有"礼貌"问题。 这次遇到的都是软门槛:气象网的 Referer、新浪的 Referer + GBK 编码、东财的 ut token。这类限制的共同点是——照着浏览器发的包抄一遍就能过。抄包的时候要注意三样东西:请求头、参数顺序无所谓但参数别漏、返回的编码(response.encoding 猜错就会得到一堆 锟斤拷)。

4. 入库要考虑"跑第二遍"。 调试阶段脚本要跑几十次,如果每次都 INSERT,数据库里很快就全是重复数据,最后交作业时截图也不好看。给表加上 UNIQUE 约束、插入用 INSERT OR REPLACE,脚本就天然幂等了。这个小设计在作业二体现得最明显:UNIQUE(code, crawl_time) 让同一次抓取内代码唯一,重复跑只是覆盖。


二、把爬虫工程化:Scrapy 重构

作业二要求"用 requests 和 scrapy 两种方法",所以我又用 Scrapy 把同一份数据抓了一遍。目的不是重复劳动,而是看清框架到底替我们做了什么。

2.1 代码实现与运行结果

工程结构(在 作业2/scrapy_stock/ 下):

scrapy_stock/
├── scrapy.cfg
└── stock_spider/
    ├── items.py          # 定义一只股票有哪些字段
    ├── pipelines.py      # 写 SQLite
    ├── settings.py       # 请求头、限速、Pipeline 开关
    └── spiders/eastmoney_spider.py

爬虫本体只负责"构造请求 + 解析响应",落库交给 Pipeline:

class EastmoneySpider(scrapy.Spider):
    name = "eastmoney"

    def build_url(self, page):
        params = {"pn": page, "pz": self.page_size, "po": 1, "np": 1,
                  "ut": "bd1d9ddb04089700cf9c27f6f7426281", "fltt": 2, "invt": 2,
                  "fid": self.sort_field, "fs": MARKET_SCOPE[self.market],
                  "fields": ",".join(FIELD_MAP.keys())}
        return f"{self.api_url}?{urlencode(params)}"

    async def start(self):
        for request in self.start_requests():
            yield request

    def parse(self, response):
        payload = json.loads(response.text)
        for raw in payload["data"]["diff"]:
            item = StockItem(crawl_time=self.crawl_time)
            for field, column in FIELD_MAP.items():
                value = raw.get(field)
                item[column] = None if value in ("-", "", None) else value
            yield item

Pipeline 的生命周期是 open_spider → process_item(多次) → close_spider,所以数据库连接在开头建、结尾关,不要每个 item 都 connect():

class SqlitePipeline:
    def open_spider(self, spider):
        self.connection = sqlite3.connect(DB_FILE)
        self.connection.execute(CREATE_TABLE_SQL)

    def process_item(self, item, spider):
        self.connection.execute(
            f"INSERT OR REPLACE INTO stock_quote ({', '.join(FIELDS)}) VALUES (...)",
            tuple(item.get(f) for f in FIELDS))
        return item

    def close_spider(self, spider):
        self.connection.commit()
        self.connection.close()

运行结果:

scrapy crawl eastmoney -a pages=3 -a page_size=50
[eastmoney] INFO: 第 1 页收到 50 条(接口报告共 5563 条)
[eastmoney] INFO: 第 2 页收到 50 条(接口报告共 5563 条)
[eastmoney] INFO: 第 3 页收到 50 条(接口报告共 5563 条)
[eastmoney] INFO: 共写入 150 条行情数据 -> scrapy_stock.db
{'downloader/request_count': 3, 'item_scraped_count': 150, 'finish_reason': 'finished'}

2.1-scrapy

2.2 作业心得

1. 框架省掉的是"重试、限速、并发"这些脏活。 requests 版里我手写了 HTTPAdapter(max_retries=3),Scrapy 里只要 RETRY_ENABLED = True、DOWNLOAD_DELAY = 0.5 两行配置。要多页并发,requests 得自己开线程池,Scrapy 改 CONCURRENT_REQUESTS = 4 就行。写第二个爬虫时代码量大概少了一半。

2. 分工清晰的代价是要理解生命周期。 我一开始把 sqlite3.connect() 写在 process_item 里,150 条数据开了 150 次连接,日志里慢得能看出来。挪到 open_spider 后正常。Scrapy 的 Pipeline、Middleware 都有固定的调用时机,把"建连接/关连接"放到对应钩子里,比在函数里到处 with 要清楚。

3. 版本兼容是真实存在的坑。 Scrapy 2.13 之后入口从 start_requests() 换成了 async def start(),我先只写了 start_requests(),结果 spider opened 之后立刻 finished,一条都没爬到,request_count 是 0——不报错,但也不干活。最后两个方法都写上(start() 里转发给 start_requests()),新旧版本都能跑。这提醒我:框架"静默失败"时,先看统计里的 request_count,它比任何日志都诚实。

4. 两种实现互为校验。 requests 版和 Scrapy 版抓的是同一个接口、同一个时刻,两边条数一致(150 条),说明解析逻辑没问题。如果一边 150 一边 0,问题一定在代码而不在网站——这种"用另一份实现来验证"的思路,比盯着日志猜要快得多。


附:仓库结构

├── 作业1/1.1.py            # 天气 7 日预报(35 条)
├── 作业2/2.1.py            # 东方财富 + 新浪(150 + 10 条)
├── 作业2/scrapy_stock/     # Scrapy 版(150 条)
├── 作业3/3.1.py            # 软科 2021 主榜(582 所)
└── 博客/博客.md

三个脚本都做了失败重试、字段容错(停牌返回 - 转为 None)和重复运行去重,可以直接反复执行验证。

posted @ 2026-11-06 11:38  庄剑钇  阅读(15)  评论(0)    收藏  举报