102402148 周凯 第一次作业-博客

数据采集与融合技术 · 第一次作业(软科排名 + 当当比价 + 网页图片爬取)

姓名:周凯 学号:102402148 专业:数据科学与大数据技术(2024 级)
开发环境:Windows 11 + Python 3.13 + PyCharm 主要库:requests、BeautifulSoup4、re
说明:本文三个程序均在本机真实运行,文中所有截图都是程序的实际运行结果,不是示意图。

本次作业共三个题目,分别对应三种最典型的爬虫场景:

题号 任务 目标站点 使用的库 解析方式
① 爬取大学排名 软科中国大学排名 2020 requests + bs4 BeautifulSoup 选择器解析表格
② 商品比价 当当网"书包"搜索结果 requests + re 正则表达式提取商品名与价格
③ 爬取网页图片 福州大学新闻网"影像福大" requests + re 正则提取 img 地址后下载保存

三个题目共用的基础套路只有四步:发请求(带上 UA)→ 拿到 HTML → 解析出目标数据 → 打印或存文件。区别只在第三步用什么工具:结构规整的表格用 BeautifulSoup,结构松散、只关心几个字段的页面用正则更快。

一、作业①:定向爬取软科中国大学排名

(一)题目要求、代码与运行结果

要求:用 requests 和 BeautifulSoup 方法定向爬取 http://www.shanghairanking.cn/rankings/bcur/2020 的数据,屏幕打印大学排名信息,输出排名、学校名称、省市、学校类型、总分。

先看目标页面长什么样:

[图 1-1 软科 2020 中国大学排名页面]
web_shanghairanking

页面结构分析(F12 审查元素的结果):

<table class="rk-table">
  <tbody>
    <tr>
      <td>1</td>                                  <!-- 排名 -->
      <td class="align-left">                     <!-- 学校名称 -->
        <div class="univname">清华大学 Tsinghua University 双一流/985/211</div>
      </td>
      <td>北京</td>                                <!-- 省市 -->
      <td>综合</td>                                <!-- 学校类型 -->
      <td>852.5</td>                              <!-- 总分 -->
      <td>38.2</td>                               <!-- 办学层次(本题不需要) -->
    </tr>
  </tbody>
</table>

关键点是:学校名称那一格并不干净,里面同时塞了校徽图片、中文校名、英文校名和"双一流/985/211"标签。如果直接 get_text(),拿到的是一长串混杂文本。解决办法是先定位 .univname,再用 get_text("|") 按竖线切开,取第 0 段就是干净的中文校名。

程序代码(ex1_shanghairanking.py):

# -*- coding: utf-8 -*-
"""
作业①:定向爬取"软科中国大学排名 2020(主榜)",在屏幕上打印排名信息
------------------------------------------------------------------------
目标网址:http://www.shanghairanking.cn/rankings/bcur/2020
技术路线:requests 发送请求 + BeautifulSoup 解析 HTML 表格

页面结构(用 F12 审查元素看到的样子):
    <table class="rk-table">
        <thead> ... 表头 ... </thead>
        <tbody>
            <tr>
                <td>1</td>                       排名
                <td class="align-left">          学校名称(含校徽、中文名、英文名、标签)
                    <div class="univname">清华大学 ...</div>
                </td>
                <td>北京</td>                    省市
                <td>综合</td>                    学校类型
                <td>852.5</td>                   总分
                <td>38.2</td>                    办学层次(本题不需要)
            </tr>
        </tbody>
    </table>

实现要点:
    1) 请求头要带 User-Agent,否则部分服务器会拒绝访问;
    2) 该站响应使用 br(brotli) 压缩,requests 需要 brotli 库才能自动解压,
       若提示乱码,执行 pip install brotli 即可;
    3) 页面已用 UTF-8 编码,直接指定 response.encoding = "utf-8" 最稳妥;
    4) 学校名称单元格里混着英文名与"双一流/985/211"标签,
       取 .univname 的文本后按 | 切分,第 0 段才是中文校名;
    5) 服务端首屏只渲染前 30 所高校(其余由前端脚本动态加载),
       若要完整榜单可加 --all 参数改从页面背后的数据接口取全部 567 所。

用法:
    python ex1_shanghairanking.py          # 解析页面表格,打印前 30 所
    python ex1_shanghairanking.py --all    # 打印完整榜单(567 所)

作者:周凯  学号:102402148
"""

import argparse
import csv
import os
import unicodedata

import requests
from bs4 import BeautifulSoup

# ---------------------------------------------------------------------------
# 全局配置
# ---------------------------------------------------------------------------
PAGE_URL = "http://www.shanghairanking.cn/rankings/bcur/2020"
# 页面背后的数据接口:bcur_type=11 表示主榜(综合性大学排名),year 为年份
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
YEAR = 2020
TIMEOUT = 20

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

# 表格每列的名称与显示宽度(单位:终端字符列,一个汉字占 2 列)
COLUMNS = [("排名", 6), ("学校名称", 26), ("省市", 8), ("学校类型", 10), ("总分", 8)]

# 输出目录:本文件所在目录的上一级 / out
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(BASE_DIR, "out")


# ---------------------------------------------------------------------------
# 通用工具函数
# ---------------------------------------------------------------------------
def display_width(text):
    """计算字符串在等宽终端中的显示宽度。

    汉字、全角符号在终端里占两列,英文字母和半角符号占一列,
    只有按显示宽度补空格,表格才能真正对齐。
    """
    width = 0
    for char in str(text):
        # east_asian_width 返回 W(宽)/F(全角) 时按 2 列计算,其余按 1 列
        width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
    return width


def pad(text, width):
    """把文本右侧补空格到指定显示宽度,用于表格列对齐。"""
    text = str(text)
    return text + " " * max(0, width - display_width(text))


def fetch(url, **kwargs):
    """发送 GET 请求并返回响应对象。

    :param url: 请求地址
    :param kwargs: 透传给 requests.get 的其它参数(如 params)
    :return: requests.Response 对象
    :raises requests.RequestException: 网络异常或状态码非 2xx
    """
    response = requests.get(url, headers=HEADERS, timeout=TIMEOUT, **kwargs)
    response.raise_for_status()          # 状态码 4xx/5xx 时直接抛异常
    response.encoding = "utf-8"          # 页面是 UTF-8,指定编码避免中文乱码
    return response


# ---------------------------------------------------------------------------
# 核心:用 BeautifulSoup 解析页面中的排名表格
# ---------------------------------------------------------------------------
def parse_page_table(html):
    """解析页面中的排名表格,返回每所高校的排名信息。

    :param html: 排名页面 HTML
    :return: list[dict],字典的键为 排名/学校名称/省市/学校类型/总分
    """
    soup = BeautifulSoup(html, "html.parser")
    records = []
    for row in soup.select("table.rk-table tbody tr"):
        cells = row.find_all("td")
        if len(cells) < 5:                       # 结构异常的行直接跳过
            continue

        # 名称单元格文本形如 "清华大学|Tsinghua University|双一流/985/211"
        # 按 | 切开取第 0 段,即可得到干净的中文校名
        name_cell = row.select_one(".univname") or cells[1]
        name = name_cell.get_text("|", strip=True).split("|")[0].strip()

        records.append({
            "排名": cells[0].get_text(strip=True),
            "学校名称": name,
            "省市": cells[2].get_text(strip=True),
            "学校类型": cells[3].get_text(strip=True),
            "总分": cells[4].get_text(strip=True),
        })
    return records


def fetch_full_rankings(year=YEAR):
    """请求页面背后的数据接口,取回完整榜单(页面首屏只渲染 30 所)。

    :param year: 排名年份
    :return: list[dict],字段与 parse_page_table 保持一致
    :raises RuntimeError: 接口返回的业务状态码不是 200
    """
    params = {"bcur_type": "11", "year": str(year)}
    payload = fetch(API_URL, params=params).json()
    if payload.get("code") != 200:
        raise RuntimeError("接口返回异常:%s" % payload.get("msg"))

    records = []
    for item in payload["data"]["rankings"]:
        try:
            score = "%.1f" % float(item.get("score"))     # 统一保留一位小数
        except (TypeError, ValueError):
            score = ""
        records.append({
            "排名": str(item.get("ranking", "")),
            "学校名称": str(item.get("univNameCn", "")),
            "省市": str(item.get("province", "")),
            "学校类型": str(item.get("univCategory", "")),
            "总分": score,
        })
    return records


# ---------------------------------------------------------------------------
# 输出
# ---------------------------------------------------------------------------
def print_records(records):
    """按 排名 / 学校名称 / 省市 / 学校类型 / 总分 的格式打印排名信息。"""
    header = "".join(pad(name, width) for name, width in COLUMNS)
    print(header)
    print("-" * display_width(header))
    for record in records:
        print("".join(pad(record[name], width) for name, width in COLUMNS))


def save_csv(records, filename):
    """把抓取结果写入 CSV 文件(utf-8-sig 编码,Excel 打开不乱码)。"""
    os.makedirs(OUT_DIR, exist_ok=True)
    path = os.path.join(OUT_DIR, filename)
    with open(path, "w", newline="", encoding="utf-8-sig") as file:
        writer = csv.DictWriter(file, fieldnames=[name for name, _ in COLUMNS])
        writer.writeheader()
        writer.writerows(records)
    return path


def parse_args():
    """解析命令行参数:--all 表示抓取完整榜单。"""
    parser = argparse.ArgumentParser(description="软科中国大学排名(2020 主榜)定向爬取")
    parser.add_argument("--all", action="store_true",
                        help="抓取完整榜单(约 567 所),默认只解析页面上的前 30 所")
    parser.add_argument("--year", type=int, default=YEAR, help="排名年份,默认 2020")
    return parser.parse_args()


def main():
    """主流程:请求页面 → BeautifulSoup 解析表格 → 打印 → 保存 CSV。"""
    args = parse_args()

    print("=" * 62)
    print("作业① 软科中国大学排名 %d(主榜)定向爬取" % args.year)
    print("=" * 62)

    html = fetch(PAGE_URL).text
    print("已获取页面 HTML,长度 %d 字符" % len(html))

    records = parse_page_table(html)
    print("BeautifulSoup 从页面表格中解析到 %d 所高校" % len(records))

    if args.all:
        print("已指定 --all,改从数据接口抓取完整榜单……")
        records = fetch_full_rankings(args.year)
        print("完整榜单共 %d 所高校" % len(records))
        filename = "软科%d中国大学排名_完整.csv" % args.year
    else:
        filename = "软科%d中国大学排名.csv" % args.year

    print("-" * 62)
    print_records(records)

    path = save_csv(records, filename)
    print("-" * 62)
    print("结果已保存到:%s" % path)


if __name__ == "__main__":
    main()

运行结果:

[图 1-2 作业①运行结果(页面表格中的 30 所高校)]

term_ex1

服务端首屏只渲染了 30 所,加 --all 参数可以从页面背后的数据接口取回完整的 567 所(下图展示了头部和尾部,中间省略):

[图 1-3 作业①加 --all 抓取完整 567 所榜单]
term_ex1_all

(二)作业心得

这一题踩的坑最多,也最值得记下来。

1. 乱码不等于反爬,可能只是压缩格式没解开。
第一次运行时,response.text 打出来全是乱码,连 <title> 都搜不到,我一度以为是网站反爬。后来用 r.headers 打印响应头,才发现 content-encoding: br —— 服务器用 brotli 压缩了响应,而 requests 只内置 gzip/deflate 解压,遇到 br 需要额外装 brotli 库。pip install brotli 之后立刻就正常了。教训:先看响应头,再怀疑反爬。

2. 编码不能靠猜,直接指定最稳。
requests 在没有 charset 声明时会退回 ISO-8859-1,中文必然乱码;用 apparent_encoding 自动探测有时也会误判(我这次就被判成了 ISO-8859-1)。页面 meta 里写的是 UTF-8,那就直接 response.encoding = "utf-8",比任何自动探测都可靠。

3. 页面上的表格和 requests 拿到的表格可能不是同一份。
浏览器里看到榜单有 567 所,但 requests 拿到的 HTML 里只有 30 行 <tr>,剩下的数据是前端脚本再请求接口后追加进去的。这让我理解了"静态源码"和"渲染后 DOM"的区别:requests 只能拿到服务器发来的那一份 HTML,拿不到 JS 执行后的结果。所以程序里默认解析页面的 30 行(题目要求的 BS4 解析),再用 --all 走接口补齐——两种方式对照,正好把这件事讲清楚。

4. 中文表格对齐要用"显示宽度"而不是 len()。
一个汉字在等宽字体里占两列,但 len("北京") 返回 2,如果按 ljust(8) 补齐,中文列会比英文列短一截,表格就歪了。用 unicodedata.east_asian_width() 判断全角字符按 2 列计算,输出才真正对齐。

5. 字段清洗是爬虫的一半工作量。
原始数据里校名混着英文名和标签、分数有的带小数点有的没有(488 而不是 488.0)。这些都要在解析阶段处理掉,否则后面存进 CSV 里还得再清洗一遍。

二、作业②:商城商品比价定向爬虫(当当网 · 关键词"书包")

(一)题目要求、代码与运行结果

要求:用 requests 和 re 方法设计某个商城商品比价定向爬虫,以关键词"书包"爬取搜索页面的商品名称和价格,输出序号、价格、商品名。

为什么选当当网:本题要求用正则解析,前提是商品数据必须直接写在 HTML 里。我先试了淘宝和京东,requests 拿到的 HTML 里只有空壳,商品是脚本异步塞进去的,正则无从下手;当当的搜索页是服务端渲染,商品名、价格都在 HTML 里,正好适合练正则。

[图 2-1 当当网"书包"搜索结果页]
web_dangdang

页面里每个商品是一段这样的结构:

<li ddt-pit="1" class="line1" id="1508098919">
  <a class="pic" href="//product.dangdang.com/1508098919.html">
    <img src="//img3m9.ddimg.cn/41/9/1508098919-1_b_xxx.jpg">
  </a>
  <p class="price"><span class="price_n">&yen;89.99</span></p>
  <p class="name"><a title="书包女新款初中生……">书包女新款初中生……</a></p>
</li>

程序代码(ex2_dangdang_price.py):

# -*- coding: utf-8 -*-
"""
作业②:商城商品比价定向爬虫 —— 以关键词"书包"爬取当当网搜索结果
------------------------------------------------------------------------
目标商城:当当网( https://search.dangdang.com/ )
技术路线:requests 发送请求 + re(正则表达式)提取商品名称与价格

为什么选当当:
    淘宝、京东、苏宁等商城的搜索结果都是脚本异步加载的,requests 拿到的
    HTML 里没有商品信息;当当的搜索页是服务端渲染,商品名、价格都直接写在
    HTML 里,正好适合练习"requests + 正则"这种解析方式。

实现要点:
    1) 搜索页使用 GBK 编码,请求后必须手动指定 encoding = "gbk",否则中文乱码;
    2) 先用正则把每个 <li class="lineN"> 商品块整体切出来,再在块内分别取
       标题与价格,这样商品名和价格不会错位;
    3) 标题取 <a title="..."> 属性,避开 <font class="skcolor_ljg"> 这类
       关键词高亮标签(直接取标签文本会把高亮标签一起带出来)。

作者:周凯  学号:102402148
"""

import csv
import os
import re
import time
import unicodedata
from html import unescape

import requests

# ---------------------------------------------------------------------------
# 全局配置
# ---------------------------------------------------------------------------
KEYWORD = "书包"                 # 搜索关键词
PAGES = 3                        # 抓取页数(每页 60 件商品)
SEARCH_URL = "https://search.dangdang.com/"
TIMEOUT = 20
DELAY = 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.dangdang.com/",
}

# 三条核心正则:商品块分隔 / 商品名 / 商品价格
# 商品块的写法是 <li ddt-pit="1" class="line1" id="1508098919">,
# class 前面还有别的属性,所以不能用 <li class="lineN" 直接匹配,
# 这里改为"按商品起点切分整页",既不怕属性顺序变化,也不会被内部的 </li> 提前截断
ITEM_SPLIT_PATTERN = re.compile(r'<li[^>]*class="line\d+"[^>]*>')
TITLE_PATTERN = re.compile(r'<p class="name".*?<a[^>]*title="([^"]*)"', re.S)
PRICE_PATTERN = re.compile(r'<span class="price_n">&yen;([\d.]+)</span>')
LINK_PATTERN = re.compile(r'href="(//product\.dangdang\.com/\d+\.html)"')

# 输出目录与文件名
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(BASE_DIR, "out")
CSV_NAME = "当当_书包_比价.csv"

# 表格列宽(终端字符列,汉字占 2 列)
COLUMNS = [("序号", 6), ("价格", 10), ("商品名", 62)]


# ---------------------------------------------------------------------------
# 通用工具函数
# ---------------------------------------------------------------------------
def display_width(text):
    """计算字符串在等宽终端中的显示宽度(汉字等全角字符按 2 列计)。"""
    width = 0
    for char in str(text):
        width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
    return width


def pad(text, width):
    """把文本右侧补空格到指定显示宽度,用于表格列对齐。"""
    text = str(text)
    return text + " " * max(0, width - display_width(text))


# ---------------------------------------------------------------------------
# 抓取与解析
# ---------------------------------------------------------------------------
def get_page(page_index, keyword=KEYWORD):
    """请求搜索结果页,返回解码后的 HTML 文本。

    :param page_index: 页码,从 1 开始
    :param keyword: 搜索关键词
    :return: 已按 GBK 解码的 HTML 字符串
    :raises requests.RequestException: 网络异常或状态码非 2xx
    """
    params = {"key": keyword, "act": "input", "page_index": page_index}
    response = requests.get(SEARCH_URL, params=params, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()
    response.encoding = "gbk"        # 当当搜索页是 GBK 编码,不指定会乱码
    return response.text


def parse_items(html):
    """用正则从搜索结果页中提取商品名称与价格。

    :param html: 搜索结果页 HTML
    :return: list[dict],每项包含 商品名 / 价格 / 链接
    """
    items = []
    blocks = ITEM_SPLIT_PATTERN.split(html)[1:]   # 第 0 段是首个商品之前的页面内容
    for block in blocks:
        title_match = TITLE_PATTERN.search(block)
        price_match = PRICE_PATTERN.search(block)
        if not (title_match and price_match):   # 缺少标题或价格(如广告位)则跳过
            continue

        name = unescape(title_match.group(1)).strip()   # 还原 &amp; 等 HTML 实体
        price = float(price_match.group(1))
        link_match = LINK_PATTERN.search(block)
        items.append({
            "商品名": name,
            "价格": price,
            "链接": "https:" + link_match.group(1) if link_match else "",
        })
    return items


def crawl(keyword=KEYWORD, pages=PAGES):
    """按页抓取搜索结果,返回全部商品记录。

    :param keyword: 搜索关键词
    :param pages: 抓取页数
    :return: list[dict]
    """
    all_items = []
    for page_index in range(1, pages + 1):
        html = get_page(page_index, keyword)
        items = parse_items(html)
        print("第 %d 页:解析到 %d 件商品" % (page_index, len(items)))
        all_items.extend(items)
        if page_index < pages:
            time.sleep(DELAY)        # 礼貌间隔,避免给服务器造成压力
    return all_items


# ---------------------------------------------------------------------------
# 输出
# ---------------------------------------------------------------------------
def print_items(items):
    """按 序号 / 价格 / 商品名 的格式打印比价结果。"""
    header = "".join(pad(name, width) for name, width in COLUMNS)
    print(header)
    print("-" * display_width(header))
    for index, item in enumerate(items, start=1):
        name = item["商品名"]
        if display_width(name) > 60:                 # 商品名过长时截断,保持表格整齐
            name = name[:28] + "…"
        print(pad(index, COLUMNS[0][1])
              + pad("%.2f" % item["价格"], COLUMNS[1][1])
              + pad(name, COLUMNS[2][1]))


def print_summary(items):
    """打印统计信息:数量、最低价、最高价、平均价,并给出最便宜的商品。"""
    if not items:
        print("未抓取到商品数据")
        return
    prices = [item["价格"] for item in items]
    cheapest = min(items, key=lambda item: item["价格"])
    dearest = max(items, key=lambda item: item["价格"])
    print("-" * 78)
    print("商品总数:%d 件" % len(items))
    print("最低价  :%.2f 元 —— %s" % (cheapest["价格"], cheapest["商品名"][:34]))
    print("最高价  :%.2f 元 —— %s" % (dearest["价格"], dearest["商品名"][:34]))
    print("平均价  :%.2f 元" % (sum(prices) / len(prices)))


def save_csv(items, filename=CSV_NAME):
    """把比价结果写入 CSV 文件(utf-8-sig,Excel 打开不乱码)。"""
    os.makedirs(OUT_DIR, exist_ok=True)
    path = os.path.join(OUT_DIR, filename)
    with open(path, "w", newline="", encoding="utf-8-sig") as file:
        writer = csv.writer(file)
        writer.writerow(["序号", "价格", "商品名", "商品链接"])
        for index, item in enumerate(items, start=1):
            writer.writerow([index, "%.2f" % item["价格"], item["商品名"], item["链接"]])
    return path


def main():
    """主流程:逐页抓取 → 解析 → 打印 → 保存。"""
    print("=" * 78)
    print("作业② 当当网商品比价爬虫    关键词:%s    抓取页数:%d" % (KEYWORD, PAGES))
    print("=" * 78)

    items = crawl(KEYWORD, PAGES)
    print("-" * 78)

    # 屏幕上展示前 20 件,完整结果写入 CSV
    print("(屏幕展示前 20 件,完整结果见 CSV 文件)")
    print_items(items[:20])
    print_summary(items)

    path = save_csv(items)
    print("结果已保存到:%s" % path)


if __name__ == "__main__":
    main()

运行结果(抓 3 页共 180 件,屏幕展示前 20 件):

[图 2-2 作业②运行结果]
term_ex2

完整结果同时导出成 CSV,方便用 Excel 排序比价:

![图 2-3 导出的 CSV 文件(前 12 行)]
sheet_ex2

(二)作业心得

1. 选对目标网站,比写巧妙的解析代码重要得多。
同样是"商城搜索页",淘宝、京东要靠 XHR 接口或者 Selenium 才能拿到数据,当当一行 requests 就拿到了完整 HTML。动手写解析之前,先花两分钟确认数据到底在不在 HTML 里,能省下后面一小时。

2. GBK 编码必须手动指定。
当当搜索页是 GBK 编码,响应头里的 charset 不可靠,requests 猜成 ISO-8859-1,中文全是"锟斤拷"。明确写 response.encoding = "gbk" 才对。这一点和作业①正好相反:编码没有通用答案,要看具体站点。

3. 正则匹配标签,不能假设属性顺序。
我第一版写的是 <li class="line\d+"(.*?)</li>,跑出来 0 条商品。回头查页面才发现真实写法是 <li ddt-pit="1" class="line1" id="1508098919"> —— class 前面还有别的属性,正则要求 class 紧跟在 <li 后面,自然匹配不到。改成 <li[^>]*class="line\d+"[^>]*> 之后立刻正常。

4. 用"切分"代替"非贪婪匹配"更安全。
(.*?)</li> 这种写法遇到商品块内部还嵌套 </li>(比如满减标签列表)时会提前截断。我改成用商品起始标签把整页 re.split 成若干段,每段就是一件商品,块内再分别 search 标题和价格——既不担心截断,也保证了商品名和价格不会错位。

5. 标题要取 title 属性,不要取标签文本。
搜索关键词"书包"在结果里会被 <font class="skcolor_ljg"> 高亮包裹,直接取 get_text() 会把标签一起带出来。取 <a title="..."> 的值最干净。

6. 爬虫只按结构取数,不理解语义。
结果里混进了几本《仙逆》小说(价格 26.50),因为它们的简介里有"随书包含"字样被搜了出来。这说明爬虫是"照单全收"的:想过滤掉无关商品,得自己在代码里加关键词判断,不能指望网站给的数据一定干净。

7. 控制访问频率是基本礼貌。
每页之间 time.sleep(1),只抓 3 页就停手。爬虫写多了容易忘记对面是别人的服务器。

三、作业③:爬取网页中的所有 JPEG / JPG / PNG 图片

(一)题目要求、代码与运行结果

要求:爬取给定网页 https://news.fzu.edu.cn/yxfd.htm(或自选网页)的所有 JPEG、JPG、PNG 格式图片,保存到一个文件夹中。

目标页面是福州大学新闻网的"影像福大"栏目,整页都是校园影像作品:

![图 3-1 福州大学新闻网"影像福大"页面]
web_fzu

图片地址在页面里有三种写法,这是本题的关键:

<img src="images/logo.png">                        <!-- 相对路径 -->
<img src="/__local/F/4D/B3/xxx.jpg">               <!-- 站点绝对路径 -->
<img src="/__local/B/62/55/xxx.png?e=.png">        <!-- 带查询参数 -->

程序代码(ex3_fzu_images.py):

# -*- coding: utf-8 -*-
"""
作业③:爬取指定网页中所有 JPEG / JPG / PNG 格式图片并保存到本地文件夹
------------------------------------------------------------------------
默认目标网页:https://news.fzu.edu.cn/yxfd.htm (福州大学新闻网 · 影像福大)
技术路线:requests 下载 HTML 与图片 + re 提取 <img> 标签中的图片地址

实现要点:
    1) 页面里的图片地址既有相对路径(images/logo.png),也有以 / 开头的
       站点绝对路径(/__local/xxx.jpg),统一用 urljoin 拼成完整 URL;
    2) 有的图片地址带查询参数(xxx.png?e=.png),判断格式时必须先去掉参数,
       只看路径部分再取扩展名,否则会漏掉这类图片;
    3) logo、图标、按钮等装饰性图片由 EXCLUDE_KEYWORDS 过滤,只保留正文图片;
    4) 保存时按 序号_原文件名 命名,避免同名覆盖。

用法:
    python ex3_fzu_images.py                     # 爬取默认网页
    python ex3_fzu_images.py --url <网页地址>     # 爬取自选网页
    python ex3_fzu_images.py --out <保存目录>     # 指定保存位置

作者:周凯  学号:102402148
"""

import argparse
import os
import re
from html import unescape
from urllib.parse import urljoin, urlparse

import requests

# ---------------------------------------------------------------------------
# 全局配置
# ---------------------------------------------------------------------------
PAGE_URL = "https://news.fzu.edu.cn/yxfd.htm"
TIMEOUT = 20

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

# 需要保存的图片格式
VALID_EXTENSIONS = (".jpg", ".jpeg", ".png")

# 装饰性图片的关键字,命中则不下载(可留空以保存全部图片)
EXCLUDE_KEYWORDS = ("icon", "ico", "logo", "btn", "bnt", "nav", "search", "loading", "back")

# 小于该字节数的图片一般是定位图标、占位图,直接跳过(设为 0 则不过滤)
MIN_BYTES = 1024

# 正则:提取 <img ... src="..."> 中的地址
IMG_SRC_PATTERN = re.compile(r'<img[^>]+src="([^"]+)"', re.I)

# 默认保存目录:本文件所在目录的上一级 / out / images_yxfd
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_OUT = os.path.join(BASE_DIR, "out", "images_yxfd")


# ---------------------------------------------------------------------------
# 抓取与解析
# ---------------------------------------------------------------------------
def get_html(url):
    """请求网页并返回解码后的 HTML 文本。

    :param url: 网页地址
    :return: HTML 字符串
    :raises requests.RequestException: 网络异常或状态码非 2xx
    """
    response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()
    response.encoding = response.apparent_encoding    # 自动识别编码,避免中文乱码
    return response.text


def extract_image_urls(html, page_url):
    """从 HTML 中提取所有 JPEG/JPG/PNG 图片的完整地址(已去重)。

    :param html: 网页 HTML
    :param page_url: 网页地址,用于把相对路径拼成绝对地址
    :return: list[str],图片的完整 URL 列表
    """
    urls = []
    for raw in IMG_SRC_PATTERN.findall(html):
        raw = unescape(raw.strip())
        if not raw or raw.startswith("data:"):        # 跳过 base64 内嵌图片
            continue

        absolute = urljoin(page_url, raw)             # 相对路径 → 绝对地址
        path = urlparse(absolute).path.lower()        # 只看路径部分,忽略 ?e=.png 等参数
        extension = os.path.splitext(path)[1]
        if extension not in VALID_EXTENSIONS:         # 只保留 jpg/jpeg/png
            continue
        if any(keyword in absolute.lower() for keyword in EXCLUDE_KEYWORDS):
            continue                                  # 跳过图标、logo 等装饰图
        if absolute in urls:                          # 同一张图可能在页面出现多次
            continue

        urls.append(absolute)
    return urls


def download_image(url, save_dir, index):
    """下载单张图片并保存到指定目录。

    :param url: 图片地址
    :param save_dir: 保存目录
    :param index: 序号,用于文件命名
    :return: (保存路径, 字节数);下载失败或图片过小时返回 (None, 字节数)
    """
    try:
        response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
        response.raise_for_status()
    except requests.RequestException as exc:
        print("  [失败] %s(%s)" % (url, exc))
        return None, 0

    size = len(response.content)
    if size < MIN_BYTES:                              # 过滤掉几像素大的装饰性小图
        print("         → 跳过(仅 %.1f KB,属装饰性图标)" % (size / 1024))
        return None, size

    extension = os.path.splitext(urlparse(url).path)[1].lower()
    filename = "%02d%s" % (index, extension)
    path = os.path.join(save_dir, filename)
    with open(path, "wb") as file:                    # 二进制写入,图片不能被解码
        file.write(response.content)
    return path, size


# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def parse_args():
    """解析命令行参数,支持爬取自选网页与自定义保存目录。"""
    parser = argparse.ArgumentParser(description="爬取网页中的 JPEG/JPG/PNG 图片")
    parser.add_argument("--url", default=PAGE_URL, help="目标网页地址")
    parser.add_argument("--out", default=DEFAULT_OUT, help="图片保存目录")
    return parser.parse_args()


def main():
    """主流程:取页面 → 提取图片地址 → 逐张下载 → 输出统计。"""
    args = parse_args()
    os.makedirs(args.out, exist_ok=True)

    print("=" * 68)
    print("作业③ 网页图片爬取")
    print("目标网页:%s" % args.url)
    print("保存目录:%s" % args.out)
    print("=" * 68)

    html = get_html(args.url)
    print("网页 HTML 长度:%d 字符" % len(html))

    image_urls = extract_image_urls(html, args.url)
    print("筛选出 JPEG/JPG/PNG 图片:%d 张" % len(image_urls))
    print("-" * 68)

    saved = 0
    total_bytes = 0
    for index, url in enumerate(image_urls, start=1):
        print("[%02d/%02d] %s" % (index, len(image_urls), url))
        path, size = download_image(url, args.out, index)
        if path:
            saved += 1
            total_bytes += size
            print("         → 已保存 %s(%.1f KB)" % (os.path.basename(path), size / 1024))

    print("-" * 68)
    print("下载完成:成功 %d 张 / 共 %d 张,合计 %.2f MB"
          % (saved, len(image_urls), total_bytes / 1024 / 1024))
    print("文件位置:%s" % args.out)


if __name__ == "__main__":
    main()

运行结果:

![图 3-2 作业③运行结果]
term_ex3

程序把筛选出的 12 张图片逐个下载,其中 3 张只有 0.4 KB 的装饰性图标被自动跳过,最终保存 9 张正文图片:

![图 3-3 保存到 out/images_yxfd 文件夹中的图片]
folder_images

(二)作业心得

1. 判断图片格式要看 URL 的 path,不是整个 URL。
页面里有 xxx.png?e=.png 这种带查询参数的地址,我最初用 url.split(".")[-1] 判断格式,得到的是 png?e=.png,直接判错、图片漏掉。正确做法是 urlparse(url).path 只取路径部分,再 os.path.splitext() 取扩展名。

2. 相对路径必须拼成绝对地址才能下载。
images/logo.png 单独拿去请求会 404,必须用 urljoin(页面地址, 相对路径) 拼成完整 URL。这一步对 /__local/... 这种以斜杠开头的站点绝对路径同样适用,一个函数解决两种写法。

3. 保存二进制文件必须用 "wb"。
用文本模式 "w" 打开会按编码转换字节,图片存下来就打不开了。图片、音频、压缩包一律 open(path, "wb") 然后 write(response.content)。

4. "所有图片"里有一半是小图标,得自己筛。
页面一共 21 个 <img>,符合扩展名的有 12 张,但其中 logo、导航箭头、加载动画只有十几像素、0.4 KB。程序里做了两层过滤:先按文件名关键字(logo/icon/btn)排除导航图,再按文件大小(< 1 KB)排除小图标,最终留下 9 张真正的正文图片。题目要"所有图片",但交上去一堆看不清的小图标显然不是想要的效果,过滤规则是可以自己加的。

5. 去重和命名。
同一张图片在页面里可能出现多次(尤其是模板里的 footer logo),所以提取 URL 时用列表判重;保存时统一用 01.jpg、02.png 这样的序号命名,既避免同名覆盖,也方便按顺序核对。

6. 顺手把程序做成通用的。
加了 --url 和 --out 两个参数,换一个网页、换一个保存目录都能直接用,不用改代码——题目说"或者自选网页",那就干脆让它对任何网页都能跑。

附:运行方式与文件说明

三个程序互相独立,可以直接单独运行,也可以一键跑完全部:

python run_all.py                              # 依次运行三题,输出存到 out/*.txt
python code/ex1_shanghairanking.py             # 作业①:解析页面表格,打印前 30 所
python code/ex1_shanghairanking.py --all       # 作业①:抓取完整 567 所榜单
python code/ex2_dangdang_price.py              # 作业②:当当"书包"比价,抓 3 页 180 件
python code/ex3_fzu_images.py                  # 作业③:下载"影像福大"页面图片
python code/ex3_fzu_images.py --url <网址>      # 作业③:换任意网页爬图片

目录结构:

第一次作业/
├─ code/
│  ├─ ex1_shanghairanking.py      # 作业① 软科排名
│  ├─ ex2_dangdang_price.py       # 作业② 当当比价
│  └─ ex3_fzu_images.py           # 作业③ 图片爬取
├─ out/
│  ├─ out_ex1.txt / out_ex2.txt / out_ex3.txt   # 三题的真实运行输出
│  ├─ 软科2020中国大学排名.csv                   # 作业① 结果
│  ├─ 当当_书包_比价.csv                         # 作业② 结果
│  └─ images_yxfd/                              # 作业③ 下载的 9 张图片
├─ shots/                          # 博客用截图(已加水印)
├─ run_all.py                      # 一键运行
└─ make_shots.py                   # 截图生成脚本

依赖安装:

pip install requests beautifulsoup4 brotli

其中 brotli 是作业①必需的:软科网站用 brotli 压缩响应,不装这个库,response.text 拿到的是乱码。

三点提醒(也是我这次踩坑的总结):

  1. 网站结构会变,如果哪天跑不通了,先用浏览器 F12 看一眼现在的 HTML 长什么样,再针对性地改选择器或正则;
  2. 遇到乱码先查响应头 content-encoding 和页面 charset,不要一上来就怀疑反爬;
  3. 爬虫要节制,加 time.sleep()、限制页数,别给学校和对面的服务器添麻烦。

posted @ 2026-10-20 14:07  zkk321  阅读(6)  评论(0)    收藏  举报