数据采集与融合实践作业2

数据采集与融合技术 实践作业2:三个定向爬虫与数据入库

叶思铖 102402141 大数据1班

本次作业包含三个爬虫任务:中国气象网 7 日天气预报、东方财富股票行情、软科中国大学 2021 主榜。三者均用 Python + requests 定向抓取,数据统一存入 SQLite 数据库。博客中贴出每个任务的关键代码,完整源码见 实践作业2/ 下的 1.py、2.py、3.py。

一、实验环境

项目 内容
操作系统 Windows 11 64 位
开发工具 / 语言 Visual Studio Code、Python 3.13.14
主要库 requests 2.34.2、beautifulsoup4 4.15.0、lxml 6.1.3
数据库 SQLite 3.53.1(Python 内置 sqlite3 模块)

三个脚本相互独立,数据文件存放在 data/。统一约定:请求携带 User-Agent,必要时补 Referer;建表后用 INSERT OR REPLACE 写入,脚本可重复运行而不产生重复数据;结束时打印入库条数与样例数据供核对。

二、任务一:中国气象网 7 日天气预报

2.1 抓取思路、代码与运行结果

需求:给定城市集(3~5 个城市)的 7 日天气预报,抓取后存入数据库。本次取北京、上海、广州、福州、成都 5 个城市。

抓取思路

  1. 城市预报页地址为 http://www.weather.com.cn/weather/{城市码}.shtml,如北京 101010100。
  2. 7 天数据渲染在 <ul class="t clearfix"> 的 7 个 <li> 中:h1 是日期,p.wea 是天气现象,p.tem 中最高温在 span、最低温在 i,p.win 中风向在 em > span[title]、风力在 i。
  3. 该页面为服务端直出 HTML,用 requests 取页面、BeautifulSoup + lxml 解析即可,不必抓接口。
  4. h1 只写“7日(今天)”,不含年月,需按运行当天补全;号数小于当天说明跨月。
  5. 表 weather 字段:city_code, city_name, date, week_desc, weather, temp_high, temp_low, wind_dir, wind_power, crawl_time,主键 (city_code, date)。

关键代码(1.py)

# 目标城市集(要求 3~5 个城市,这里取 5 个,代码取自气象网城市编码)
CITIES = {
    "101010100": "北京",
    "101020100": "上海",
    "101280101": "广州",
    "101230101": "福州",
    "101270101": "成都",
}

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

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"),
    "Referer": "http://www.weather.com.cn/",
}

# 数据库文件放在脚本同目录的 data 文件夹下
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "weather.db")

def build_date(day_text, today):
    """把 '7日(今天)' 这样的日期补全成 YYYY-MM-DD。

    气象网只给出“几号”,需要结合当天日期推出年月;若号数小于今天,说明跨月。
    """
    day = int(re.search(r"(\d+)日", day_text).group(1))
    year, month = today.year, today.month
    if day < today.day:          # 例如今天是 30 号而预报是 2 号,则属于下个月
        month += 1
        if month > 12:
            month, year = 1, year + 1
    return f"{year:04d}-{month:02d}-{day:02d}"


def parse_temp(tem_p):
    """从 <p class="tem"><span>6</span>/<i>1℃</i></p> 中取出最高、最低气温。"""
    high = tem_p.find("span")
    low = tem_p.find("i")
    high = int(high.get_text(strip=True)) if high else None
    low = int(re.sub(r"[^\-\d]", "", low.get_text(strip=True))) if low else None
    return high, low


def parse_wind(win_p):
    """从 <p class="win"> 中取出风向(第一个 span 的 title)和风力(i 标签文本)。"""
    spans = win_p.find_all("span")
    wind_dir = spans[0].get("title", "") if spans else ""
    power = win_p.find("i")
    wind_power = power.get_text(strip=True) if power else ""
    return wind_dir, wind_power


def crawl_city(code, name, today):
    """抓取并解析一个城市的 7 日预报,返回记录列表。"""
    url = BASE_URL.format(code=code)
    resp = requests.get(url, headers=HEADERS, timeout=15)
    resp.encoding = "utf-8"          # 页面是 UTF-8,requests 默认会猜错

    soup = BeautifulSoup(resp.text, "lxml")
    ul = soup.select_one("ul.t.clearfix")
    if ul is None:
        raise RuntimeError(f"{name} 页面结构异常,未找到预报列表")

    records = []
    for li in ul.find_all("li"):                     # 正好 7 个 <li>,即 7 天
        day_text = li.find("h1").get_text(strip=True)
        wea = li.select_one("p.wea")
        tem = li.select_one("p.tem")
        win = li.select_one("p.win")
        if not (wea and tem and win):
            continue

        week = day_text[day_text.find("(") + 1:day_text.find(")")] if "(" in day_text else ""
        high, low = parse_temp(tem)
        wind_dir, wind_power = parse_wind(win)

        records.append({
            "city_code": code,
            "city_name": name,
            "date": build_date(day_text, today),
            "week_desc": week,
            "weather": wea.get_text(strip=True),
            "temp_high": high,
            "temp_low": low,
            "wind_dir": wind_dir,
            "wind_power": wind_power,
            "crawl_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        })
    return records

# ……(init_db / save / show / main 等辅助函数见完整源码)

运行结果

[OK] 北京(101010100) 解析到 7 天预报
[OK] 上海(101020100) 解析到 7 天预报
[OK] 广州(101280101) 解析到 7 天预报
[OK] 福州(101230101) 解析到 7 天预报
[OK] 成都(101270101) 解析到 7 天预报

共写入 35 条记录,数据库:C:\Users\HP\Desktop\课程作业\数据采集与融合\实践作业2\data\weather.db

城市     日期           星期     天气           最高     最低     风向        风力
北京     2026-11-07   今天     雨夹雪          6      1      北风        <3级
北京     2026-11-08   明天     雨夹雪转多云       6      -4     东北风       <3级
北京     2026-11-09   后天     晴            10     -1     西南风       <3级
……(北京其余 4 天及上海、福州、成都、广州共 30 行,完整输出下图可见)

5 个城市各 7 天,共 35 条记录,天气、气温、风向风力与页面一致。

94a3c59bed97eb51e7e0f199c8689906

6395d621be5b6f3bd79087f19c7a905c

42e97fa93af619b2ca276e4edb3a8c83

2.2 实践心得

这个任务的坑全在细节。第一是编码:requests 对响应头未声明字符集的页面按 ISO-8859-1 猜测解码,不显式写 resp.encoding = "utf-8",“雨夹雪”就会变成乱码。第二是日期,页面只给“7日(今天)”,不补全年月就无法按日期查询,还得判断是否跨月。第三是结构定位,最高低温、风向风力分别藏在 span、i 和 title 属性里,若把“6 / 1℃”当一个字符串处理,入库后所有字段都会错位。另外以 (城市编码, 日期) 作主键并 INSERT OR REPLACE,让脚本可以随时重跑而不产生重复行,调试时不必每次清表,这个习惯在后两个任务中也一直沿用。

三、任务二:东方财富股票行情爬取

3.1 抓取思路、代码与运行结果

需求:用 requests + json 解析定向爬取股票信息并存入数据库。

抓取思路

  1. 东方财富行情页由前端渲染,HTML 源码中检索不到任何股票名称,说明数据来自异步接口,直接解析 HTML 行不通。
  2. Chrome F12 → Network → Fetch/XHR → 刷新,可见列表数据来自 https://push2.eastmoney.com/api/qt/clist/get。
  3. 关键参数:fs 板块条件(m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23 即沪深 A 股)、fid=f3 按涨跌幅排序、po=1 降序、pn/pz 页码与每页条数、fltt=2 保留两位小数、fields 指定返回字段。
  4. 返回的键是编码形式,需对照响应确定含义:f12 代码、f14 名称、f2 最新价、f3 涨跌幅、f4 涨跌额、f5 成交量、f6 成交额、f15 最高、f16 最低、f17 今开、f18 昨收、f13 市场。
  5. 接口直接返回 JSON,取 data.diff 即为记录列表,递增 pn 即可翻页。
  6. 表 stock 字段:code, name, market, price, pct_chg, chg, volume, amount, high, low, open, pre_close, crawl_time,主键 code。

关键代码(2.py)

# 东方财富行情列表接口(F12 抓包得到的真实接口)
API_URL = "https://push2.eastmoney.com/api/qt/clist/get"

# 沪深 A 股板块条件
FS_MARKET = "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23"

# 请求参数:按涨跌幅 f3 降序排,每页 50 条
BASE_PARAMS = {
    "po": 1,        # 1 降序
    "np": 1,
    "fltt": 2,      # 价格保留 2 位小数
    "invt": 2,
    "fid": "f3",    # 按涨跌幅排序
    "fs": FS_MARKET,
    "fields": "f2,f3,f4,f5,f6,f12,f13,f14,f15,f16,f17,f18",
}

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"),
    "Referer": "https://quote.eastmoney.com/",
}

PAGES = 2          # 抓取前 2 页
PAGE_SIZE = 50     # 每页 50 条,共 100 条

DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "stock.db")

def fetch_page(page):
    """请求一页数据,返回 JSON 中的 data 部分。"""
    params = dict(BASE_PARAMS, pn=page, pz=PAGE_SIZE)
    resp = requests.get(API_URL, params=params, headers=HEADERS, timeout=15)
    resp.raise_for_status()
    payload = resp.json()                 # 接口直接返回 JSON,无需解析 HTML
    data = payload.get("data")
    if not data:
        return {}
    return data


def parse_rows(data):
    """把接口返回的 diff 列表转换成入库用的字典列表。"""
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows = []
    for item in data.get("diff", []):
        rows.append({
            "code": item.get("f12"),
            "name": item.get("f14"),
            "market": "沪市" if item.get("f13") == 1 else "深市",   # f13: 1 沪市, 0 深市
            "price": item.get("f2"),
            "pct_chg": item.get("f3"),
            "chg": item.get("f4"),
            "volume": item.get("f5"),
            "amount": item.get("f6"),
            "high": item.get("f15"),
            "low": item.get("f16"),
            "open": item.get("f17"),
            "pre_close": item.get("f18"),
            "crawl_time": crawl_time,
        })
    return rows

# ……(init_db / save / show / main 等辅助函数见完整源码)

运行结果

[OK] 第 1 页:解析 50 条,全市场共 5563 只股票
[OK] 第 2 页:解析 50 条,全市场共 5563 只股票

本次共写入 100 条记录,数据库:C:\Users\HP\Desktop\课程作业\数据采集与融合\实践作业2\data\stock.db

代码       名称         市场          最新价     涨跌幅%        成交额(亿元)
301697   贝特利        深市        36.60    20.00           6.31
300686   智动力        深市        13.68    20.00           2.96
300237   美晨科技       深市         2.82    20.00           4.26
688429   时创能源       沪市        12.36    15.62           1.88
300118   东方日升       深市        10.27    15.26          22.37
300123   ST亚光       深市         4.08    13.65           2.09
300313   天山生物       深市        11.42    12.73           4.17
300949   奥雅股份       深市        56.99    12.52           5.54
688203   海正生材       沪市        15.69    10.65           3.50
600537   *ST亿晶      沪市         2.05    10.22           1.91

库中股票记录总数:100

全市场共 5563 只沪深 A 股,本次按涨跌幅取前 100 只。榜首三只涨幅均为 20.00%,与创业板、科创板 20% 涨跌停幅度吻合,说明取到的是当日实时行情。

image

image

image

image

image

3.2 实践心得

这个任务的关键不在写代码,而在找对请求。行情页源码里一个股票名都没有,用 BeautifulSoup 找 table 注定一无所获;F12 的 XHR 面板里 clist/get 一次就返回了完整 JSON。读懂参数比写代码更重要:fs 决定板块、fid 与 po 决定排序、pn/pz 控制分页、fields 决定返回哪些列,而返回的键是 f2、f3 这类编码,只能先请求少量数据、逐列与页面表格对照后才能确定含义,这也正是作业提示中“可适当更改 api 请求的参数、可删减请求的参数”的含义。另外该接口对 Referer 有校验,不带时偶发返回空 data,补齐后即稳定,UA 与 Referer 有时不是防封手段,而是拿到正确响应的前提。

四、任务三:软科中国大学 2021 主榜爬取

4.1 抓取思路、代码与运行结果

需求:爬取软科 2021 主榜(https://www.shanghairanking.cn/rankings/bcur/2021)全部院校信息存入数据库,并将 F12 调试分析过程录成 GIF 插入博客。

抓取思路

  1. 页面由 Nuxt 框架渲染,HTML 中检索“清华大学”命中 0 次,数据同样来自异步接口。
  2. F12 → Network → Fetch/XHR → 刷新,找到数据请求:GET /api/pub/v1/bcur?bcur_type=11&year=2021&page=1&size=30,其中 bcur_type=11 表示主榜、year=2021 表示年份。
  3. 站点校验来源:不带 Referer 请求前端资源会返回 403 denied by Referer ACL,故所有请求统一带 Referer: https://www.shanghairanking.cn/rankings/bcur/2021。
  4. 响应 JSON 的 data.rankings 每条含 ranking、univNameCn、province、univCategory、score,与作业给出的表格样例完全对应。
  5. 关于翻页:早期资料称该接口为 POST 且按 page/size 分页,实测已是 GET 且一次返回全部 582 所院校,分页参数被忽略。脚本因此不写死页数,而以“本页是否带来新记录”作为终止条件。
  6. 每次请求后 sleep(0.3) 限速;表 university 字段:year, ranking, univ_name, province, category, score, univ_code, crawl_time,主键 (year, univ_code)。

关键代码(3.py)

API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"

# bcur_type=11 → 主榜;year=2021 → 2021 年排名
BASE_PARAMS = {"bcur_type": 11, "year": 2021}

PAGE_SIZE = 30                # 与页面默认每页条数一致
MAX_PAGE = 50                 # 安全上限,防止接口异常时死循环

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                   "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"),
    "Referer": "https://www.shanghairanking.cn/rankings/bcur/2021",
    "Accept": "application/json, text/plain, */*",
}

DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "university.db")

def fetch_page(page):
    """请求一页院校数据,返回 data 字典;无数据时返回空字典。"""
    params = dict(BASE_PARAMS, page=page, size=PAGE_SIZE)
    resp = requests.get(API_URL, params=params, headers=HEADERS, timeout=20)
    resp.raise_for_status()
    payload = resp.json()
    if payload.get("code") != 200:
        raise RuntimeError(f"接口返回异常:{payload.get('msg')}")
    return payload.get("data") or {}


def parse_rows(rankings):
    """把接口返回的院校列表转换成入库用的字典列表。"""
    crawl_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    rows = []
    for item in rankings:
        rows.append({
            "year": BASE_PARAMS["year"],
            "ranking": item.get("ranking"),
            "univ_name": item.get("univNameCn"),
            "province": item.get("province"),
            "category": item.get("univCategory"),
            "score": item.get("score"),
            "univ_code": item.get("univUp"),
            "crawl_time": crawl_time,
        })
    return rows

def main():
    conn = init_db()
    seen = set()          # 记录已处理的院校标识,用于判断翻页是否还有新数据
    total = 0
    for page in range(1, MAX_PAGE + 1):
        data = fetch_page(page)
        rankings = data.get("rankings") or []
        if not rankings:
            print(f"第 {page} 页无数据,翻页结束")
            break

        # 过滤掉已经出现过的院校:接口若一次返回全部数据,第二页就不会有新记录
        rows = [row for row in parse_rows(rankings) if row["univ_code"] not in seen]
        if not rows:
            print(f"第 {page} 页没有新记录,说明数据已取全,结束翻页")
            break

        seen.update(row["univ_code"] for row in rows)
        save(conn, rows)
        total += len(rows)
        print(f"[OK] 第 {page:>2} 页:接口返回 {len(rankings)} 条,新增 {len(rows)} 条,累计 {total} 条")
        time.sleep(0.3)          # 放慢一点,避免给服务器造成压力

    print(f"\n本次共写入 {total} 条记录,数据库:{DB_PATH}")
    show(conn)
    conn.close()

运行结果

[OK] 第  1 页:接口返回 582 条,新增 582 条,累计 582 条
第 2 页没有新记录,说明数据已取全,结束翻页

本次共写入 582 条记录,数据库:C:\Users\HP\Desktop\课程作业\数据采集与融合\实践作业2\data\university.db

排名     学校               省市       类型             总分
1      清华大学             北京       综合          969.2
2      北京大学             北京       综合          855.3
3      浙江大学             浙江       综合          768.7
4      上海交通大学           上海       综合          723.4
5      南京大学             江苏       综合          654.8
6      复旦大学             上海       综合          649.7
7      中国科学技术大学         安徽       理工          577.0
8      华中科技大学           湖北       综合          574.3
9      武汉大学             湖北       综合          567.9
10     西安交通大学           陕西       综合          537.9

库中院校记录总数:582
按类型统计:
  理工       211 所
  综合       172 所
  师范       159 所
  农业       34 所
  林业       6 所

共入库 582 所院校,与官方公布的 2021 主榜院校数一致;按类型统计 理工 211 + 综合 172 + 师范 159 + 农业 34 + 林业 6 = 582,交叉校验通过。

image

1

image

image

4.2 实践心得

最大的教训是接口会变。网上资料写的是 POST 加分页,实测却是 GET 且一次返回全部 582 条,按页翻到第 50 页仍是同一批数据,一度以为参数名写错。最后的处理是让脚本以“本页是否带来新的院校标识”作为终止条件:接口若按页返回就一页页取完,若一次返回全部则第二页自然没有新记录、立即结束,两种行为都不会出错,也不会死循环。第二个坑是 Referer,直接下载页面资源得到 403 并明确写着 denied by Referer ACL,顺着这条报错补上请求头就通了,比盲试 UA 高效。最后是数据自校验:582 与官方数字一致、各类型数量相加等于总数,两个校验都过才敢确认没有漏抓或重复——爬虫最怕“跑完了但数据不全”,这种静默错误只能靠交叉校验发现。

五、总结

任务 数据源形态 解析方式 入库条数
任务一 天气预报 服务端渲染 HTML BeautifulSoup + lxml 35(5 城市 × 7 天)
任务二 股票行情 前端渲染 + XHR 接口 resp.json() 100
任务三 大学排名 前端渲染 + XHR 接口(需 Referer) resp.json() 582

三个任务覆盖了定向爬虫的两种常见形态:页面直出与接口异步加载。前者考验 DOM 结构定位的准确性,后者考验抓包与参数分析的能力。共同的工程习惯是:先弄清数据从哪里来,再决定用什么方式解析,最后一定要做入库校验。

posted @ 2026-11-10 22:33  yesx  阅读(0)  评论(0)    收藏  举报