Python 网络爬虫第一次作业:定向爬虫实践(软科排名 / 商城比价 / 图片批量下载)

Python 网络爬虫第一次作业:定向爬虫实践(软科排名 / 商城比价 / 图片批量下载)

实验环境:Windows 11 + Python 3.12 + PyCharm
依赖库:requests、beautifulsoup4(安装:pip install requests beautifulsoup4)
运行方式:命令行进入 code 目录后执行 python 文件名.py
说明:本文三个程序均为单文件脚本,爬取前均设置了 User-Agent,并在程序结尾加入礼貌性延时,避免对目标服务器造成压力。


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

1.1 作业代码与运行结果

爬取目标:http://www.shanghairanking.cn/rankings/bcur/2020
技术路线:requests 请求页面 + BeautifulSoup 解析表格

(1)爬取思路

  1. 用 requests.get() 携带浏览器 User-Agent 请求页面;
  2. 显式设置 response.encoding = "utf-8",避免中文乱码;
  3. 用 BeautifulSoup 定位 <tbody>,遍历其中每个 <tr>;
  4. 每条 <tr> 的 5 个 <td> 分别对应排名、学校名称、省市、学校类型、总分;
  5. 格式化输出为表格。

(2)完整代码

# -*- coding: utf-8 -*-
"""
作业①:定向爬取软科(上海软科)2020 年中国大学排名数据。

技术路线:requests + BeautifulSoup
目标网址:http://www.shanghairanking.cn/rankings/bcur/2020

说明:该页面为 Nuxt 服务端渲染页面,requests 拿到的 HTML 中已经带有
      表格 <tbody> 的排名数据(前 30 条),因此可以直接用 BeautifulSoup
      解析,无需抓取额外接口。

输出:屏幕打印「排名 / 学校名称 / 省市 / 学校类型 / 总分」。
"""

import sys

import requests
from bs4 import BeautifulSoup

# 目标网址:2020 年软科中国大学排名主榜
URL = "http://www.shanghairanking.cn/rankings/bcur/2020"

# 伪装成浏览器,否则部分站点会返回 403 或精简页面
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9",
}

TIMEOUT = 30  # 单次请求超时时间(秒)


def fetch_html(url: str) -> str:
    """下载指定网址的网页源码。

    :param url: 待爬取的网址
    :return: 解码后的 HTML 字符串
    :raises requests.RequestException: 网络异常时向上抛出,由调用方处理
    """
    response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()  # HTTP 状态码非 200 时主动抛出异常
    response.encoding = "utf-8"  # 站点实际使用 UTF-8,显式指定避免中文乱码
    return response.text


def parse_ranking(html: str) -> list:
    """从网页源码中解析出大学排名信息。

    :param html: 网页源码
    :return: 列表,每个元素为 (排名, 学校名称, 省市, 学校类型, 总分)
    """
    soup = BeautifulSoup(html, "html.parser")

    # 定位排名表格的表体;若页面结构变化则直接报错,避免静默返回空结果
    tbody = soup.find("tbody")
    if tbody is None:
        raise ValueError("未找到 <tbody> 节点,页面结构可能已变化")

    ranking_list = []
    for row in tbody.find_all("tr"):
        cells = row.find_all("td")
        if len(cells) < 5:  # 跳过不符合预期结构的行
            continue

        # 第 1 列:排名(节点形如 <div class="ranking top1">1</div>)
        rank = cells[0].get_text(strip=True)

        # 第 2 列:学校名称。页面同时含中英文名,这里取中文名 span.name-cn
        name_cn = cells[1].select_one("span.name-cn")
        school = name_cn.get_text(strip=True) if name_cn else cells[1].get_text(strip=True)

        # 第 3、4、5 列:省市、学校类型、总分
        province = cells[2].get_text(strip=True)
        school_type = cells[3].get_text(strip=True)
        score = cells[4].get_text(strip=True)

        ranking_list.append((rank, school, province, school_type, score))

    return ranking_list


def display_width(text: str) -> int:
    """计算字符串在终端中的显示宽度(中文等全角字符占 2 个字符宽度)。

    :param text: 待计算的字符串
    :return: 显示宽度
    """
    return sum(2 if ord(char) > 0x2E80 else 1 for char in text)


def pad(text: str, width: int) -> str:
    """按显示宽度对字符串右侧补空格,实现中文表格对齐。

    :param text: 原始文本
    :param width: 目标显示宽度
    :return: 补齐后的文本
    """
    return text + " " * max(0, width - display_width(text))


def print_ranking(ranking_list: list) -> None:
    """按表格形式打印排名信息。

    :param ranking_list: parse_ranking() 返回的排名列表
    """
    # 各列宽度按「显示宽度」定义:中文占 2 格,需手动补齐才能对齐
    widths = (6, 20, 10, 12, 8)
    header = ("排名", "学校名称", "省市", "学校类型", "总分")
    line_width = sum(widths)
    print("".join(pad(item, width) for item, width in zip(header, widths)))
    print("-" * line_width)

    for row in ranking_list:
        print("".join(pad(item, width) for item, width in zip(row, widths)))

    print("-" * line_width)
    print(f"共爬取到 {len(ranking_list)} 条排名数据(页面首屏数据)。")


def main() -> None:
    """程序入口:爬取 -> 解析 -> 打印。"""
    # Windows 控制台默认 GBK,统一切换为 UTF-8 以便正确输出中文
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")

    try:
        html = fetch_html(URL)
    except requests.RequestException as exc:
        print(f"网页请求失败:{exc}")
        return

    ranking_list = parse_ranking(html)
    print_ranking(ranking_list)


if __name__ == "__main__":
    main()

(3)运行结果

程序成功爬取并打印出 30 条排名数据(前 5 名如下,完整结果见截图):

排名 学校名称 省市 学校类型 总分
1 清华大学 北京 综合 852.5
2 北京大学 北京 综合 746.7
3 浙江大学 浙江 综合 649.2
4 上海交通大学 上海 综合 625.9
5 南京大学 江苏 综合 566.1

作业1-运行结果

图 1-1 作业① 运行结果:成功打印 30 条大学排名信息

1.2 作业心得

这次作业最大的收获是学会了先分析网页结构,再动手写代码。一开始我以为软科这种页面一定是 JavaScript 动态渲染的,requests 肯定拿不到数据,甚至想过用正则去页面里找接口地址。后来把响应内容打印出来才发现,该页面采用 Nuxt 服务端渲染(SSR),返回的 HTML 中已经包含了表格数据,直接解析 <tbody> 即可,完全不需要额外抓接口。这说明动手前先用浏览器"查看网页源代码"确认数据是否在源码里,能省下大量无用功。

在实践中也踩了几个坑:

  1. 学校名称重复出现,导致取错字段。页面的名称单元格里同时有中文名 span.name-cn 和英文名 span.name-en。最初我直接取整个 <td> 的文本,结果拿到一大串中英文夹杂的内容。改用 select_one("span.name-cn") 精确定位中文名后问题解决。这让我理解了"解析要尽量定位到最小、最明确的节点"。

  2. 中文表格无法对齐。用 Python 的 f"{'学校名称':<14}" 格式化时,中文字符在终端里占两个字符宽度,但 Python 只按 1 个字符计算,导致列参差不齐。我写了一个 display_width() 函数统计真实显示宽度,再用 pad() 手动补空格,表格才整齐。这个小函数也让我体会到"终端显示"和"字符串长度"并不是一回事。

  3. 中文输出乱码。Windows 控制台默认使用 GBK 编码,而页面是 UTF-8。除了在代码里显式指定 response.encoding = "utf-8",还在程序开头用 sys.stdout.reconfigure(encoding="utf-8") 统一切换输出编码,中文才正常显示。

  4. 分页问题。页面底部有分页控件,但请求 ?page=2 返回的仍是前 30 条数据,说明翻页是前端 JavaScript 行为,服务端渲染只输出首屏。因此本程序输出的就是首屏 30 条,这一点在代码注释中做了说明,避免被误认为程序有 Bug。

此外,我还在请求头中设置了 User-Agent 与 Accept-Language,并给请求设置了 timeout,既避免被服务器拒绝,也防止程序因网络异常而长时间卡住。


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

2.1 作业代码与运行结果

爬取目标:当当网搜索页 http://search.dangdang.com/?key=书包&act=input
技术路线:requests 请求 + re 正则提取(按要求不使用 BeautifulSoup)

(1)爬取思路

  1. 关键词编码:当当网搜索页使用 GBK 编码,中文关键词必须先按 GBK 编码再做百分号编码,拼成 key=%CA%E9%B0%FC;
  2. 页面请求:请求搜索页并把 response.encoding 设为 gbk;
  3. 正则提取:先用正则切出商品列表块 <ul class="bigimg ...">,再按 <li> 切成单个商品;
  4. 在每个商品块内,用正则分别提取商品名称(title 属性)与价格(price_n 节点中的 &yen; 数值);
  5. 格式化为"序号 / 价格 / 商品名"输出。

(2)完整代码

# -*- coding: utf-8 -*-
"""
作业②:商城商品比价定向爬虫(requests + re)。

商城选择:当当网( https://www.dangdang.com/ )
搜索关键词:书包
目标页面:http://search.dangdang.com/?key=<书包的GBK编码>&act=input

技术要点:
    1. 当当搜索页使用 GBK 编码,且关键词需先做 GBK 百分号编码,
       否则中文关键词会搜索不到结果;
    2. 本作业要求使用 re 库提取数据,因此不使用 BeautifulSoup,
       而是先用正则在页面中切出商品列表块,再在每块内提取商品名与价格;
    3. 取每个商品块中「第一个」title 属性作为商品名,避免把同一链接
       重复出现的 title 重复计入。

输出:屏幕打印「序号 / 价格 / 商品名」。
"""

import re
import sys
import time
from urllib.parse import quote

import requests

# 搜索关键词与商城搜索页模板
KEYWORD = "书包"
URL_TEMPLATE = "http://search.dangdang.com/?key={keyword}&act=input"

# 伪装成浏览器访问,降低被识别为爬虫的概率
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9",
}

TIMEOUT = 30  # 单次请求超时时间(秒)

# 正则①:切出承载商品列表的 <ul class="bigimg ..."> 内部内容
RE_PRODUCT_LIST = re.compile(r'<ul[^>]*class="[^"]*bigimg[^"]*"[^>]*>(.*?)</ul>', re.S)
# 正则②:按 <li> 切分出单个商品块
RE_PRODUCT_ITEM = re.compile(r"<li[^>]*>(.*?)</li>", re.S)
# 正则③:提取商品名称(每个 li 中第一个 title 属性)
RE_PRODUCT_NAME = re.compile(r'title="\s*([^"]+?)\s*"')
# 正则④:提取商品价格(形如 <span class="price_n">&yen;146.00</span>)
RE_PRODUCT_PRICE = re.compile(r'class="price_n"[^>]*>\s*&yen;\s*([\d.]+)')


def build_url(keyword: str) -> str:
    """把中文关键词做 GBK 百分号编码后拼装成搜索页网址。

    :param keyword: 搜索关键词,例如「书包」
    :return: 可直接请求的完整 URL
    """
    encoded = quote(keyword.encode("gbk"))
    return URL_TEMPLATE.format(keyword=encoded)


def fetch_html(url: str) -> str:
    """下载搜索页源码,并按页面实际编码(GBK)解码。

    :param url: 搜索页网址
    :return: 解码后的 HTML 字符串
    """
    response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
    response.raise_for_status()
    response.encoding = "gbk"  # 当当搜索页为 GBK 编码,显式指定避免乱码
    return response.text


def parse_products(html: str) -> list:
    """用正则从搜索页源码中提取商品名称与价格。

    :param html: 搜索页源码
    :return: 列表,每个元素为 (商品名, 价格字符串)
    """
    # 第一步:定位商品列表块(页面中通常只有一个 bigimg 列表)
    list_blocks = RE_PRODUCT_LIST.findall(html)
    if not list_blocks:
        raise ValueError("未匹配到商品列表,页面结构或反爬策略可能已变化")

    products = []
    # 第二步:逐个商品块提取名称与价格
    for block in list_blocks:
        for item in RE_PRODUCT_ITEM.findall(block):
            name_match = RE_PRODUCT_NAME.search(item)
            price_match = RE_PRODUCT_PRICE.search(item)
            if not (name_match and price_match):
                continue  # 广告位或结构不完整的条目直接跳过

            name = name_match.group(1).strip()
            price = price_match.group(1).strip()
            price = price.rstrip(".")  # 去掉极少数条目价格尾部多余的句点
            products.append((name, price))

    return products


def print_products(products: list) -> None:
    """按表格形式打印商品比价结果。

    :param products: parse_products() 返回的商品列表
    """
    print(f"关键词:{KEYWORD}    数据来源:当当网")
    # 序号 / 价格 / 商品名,宽度按显示宽度定义以便中文对齐
    print(f"{'序号':<8}{'价格':<12}商品名")
    print("-" * 70)

    for index, (name, price) in enumerate(products, start=1):
        # 价格统一保留两位小数并左侧对齐,便于纵向比较
        price_text = f"{float(price):.2f}"
        print(f"{str(index):<8}{price_text:<12}{name}")

    print("-" * 70)
    print(f"共爬取到 {len(products)} 件商品。")


def main() -> None:
    """程序入口:构造 URL -> 请求 -> 正则解析 -> 打印。"""
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")

    url = build_url(KEYWORD)
    print(f"正在爬取:{url}")

    try:
        html = fetch_html(url)
    except requests.RequestException as exc:
        print(f"网页请求失败:{exc}")
        return

    products = parse_products(html)
    print_products(products)

    time.sleep(1)  # 礼貌性延时,降低对目标服务器的访问压力


if __name__ == "__main__":
    main()

(3)运行结果

程序成功爬取到 60 件商品,价格从最低 36.00 元到最高 423.30 元不等。部分结果如下:

序号 价格 商品名
1 146.00 迪士尼书包小学生女童一三六年级公主女孩轻便儿童护脊减负双肩包邮1
2 89.99 书包女新款初中生高中学生大容量轻便减负护脊简约学院风双肩包
3 39.99 书包幼儿园女孩 潮小兔子书包3-6岁大中小班男童儿童卡通可爱韩版
4 224.00 儿童书包护脊减负大容量2026新款男生一二三到六年级男孩超轻书包
5 99.80 书包男女生 1-6年级大容量新款小学生耐磨透气书包儿童双肩包小学生男女孩双肩书包

作业2-运行结果

图 2-1 作业② 运行结果:爬取到 60 件"书包"商品的名称与价格

2.2 作业心得

这次作业要求用 re 库而不是 BeautifulSoup,让我真正体会到正则做网页解析的"手感",也踩到了不少坑:

  1. 中文关键词必须按 GBK 编码,否则搜不到任何商品。这是本次作业最关键的坑。当当网是 GBK 编码的老站点,如果把"书包"按 UTF-8 编码拼进 URL,服务器会把它当成乱码关键词,页面返回的是"没有找到相关商品"。正确做法是 quote(keyword.encode("gbk")),得到 %CA%E9%B0%FC 才能正常搜索。同样,解析页面时也必须把 response.encoding 设为 gbk,否则中文商品名全是乱码。编码问题贯穿"发送请求"和"解析响应"两端,必须成对处理。

  2. 正则要"先切块、再提取",不能全篇乱匹配。一开始我直接在整页 HTML 上匹配价格,结果匹配到了筛选栏里的"最低价/最高价"输入框价格区间,还有广告位的价格。后来改成两步走:先用 <ul class="bigimg..."> 切出商品列表区域,再用 <li> 切出单个商品,最后在单个商品块内提取名称和价格,数据立刻变得干净准确。这种"分治法"比写一个超长正则可靠得多。

  3. 同一属性会重复出现,取第一个即可。仔细观察会发现,一件商品里 title 属性出现了两次(图片链接和标题链接各一次),如果 findall 全部取出,就会出现同一商品被重复统计的情况。改为在每个商品块内只取第一个 title,才得到 60 条不重复的数据。这说明写正则前必须看清 HTML 的重复结构。

  4. HTML 实体与字符串清洗。价格在源码中写作 &yen;146.00,requests 解码后变成 ¥146.00,正则里直接用 &yen; 匹配、用捕获组 ([\d.]+) 只取数字部分最稳妥。另外少数商品价格尾部带有多余的句点,用 rstrip(".") 清理后再 float() 转换,避免程序因转换失败而崩溃。

最后,我在程序末尾加了 time.sleep(1) 做礼貌性延时,并设置了 User-Agent。爬虫应当尽量少给目标网站添麻烦,这是写爬虫应有的基本素养。


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

3.1 作业代码与运行结果

爬取目标:https://news.fzu.edu.cn/yxfd.htm(福州大学新闻网 - 学院风采)
技术路线:requests 请求页面与图片 + 正则筛选图片地址 + 二进制写入保存

(1)爬取思路

  1. 请求目标网页,用 apparent_encoding 自动识别页面编码;
  2. 用正则提取所有 <img> 标签的 src 属性;
  3. 只需 .jpg / .jpeg / .png 三种格式:先过滤 data: 内联图片,再按扩展名筛选;
  4. 页面里的图片地址既有相对路径(images/logo.png)也有根路径(/__local/xxx.jpg),必须用 urljoin() 补全为绝对 URL;
  5. 逐张下载,以 wb 二进制模式写入 images 文件夹;对站点模板的装饰小图标按文件名关键词和体积做过滤;
  6. 打印下载日志,统计成功数量与总字节数。

(2)完整代码

# -*- coding: utf-8 -*-
"""
作业③:爬取给定网页中所有 JPEG / JPG / PNG 格式的图片并保存到本地文件夹。

技术路线:requests
目标网址:https://news.fzu.edu.cn/yxfd.htm (福州大学新闻网 - 学院风采)

技术要点:
    1. 页面中的图片地址既有相对路径(images/logo.png),也有根路径
       (/__local/...jpg),必须用 urljoin 拼接成完整 URL 才能下载;
    2. 用正则筛出 .jpg / .jpeg / .png 结尾的图片地址;
    3. 保存文件名取 URL 中的原始文件名,并过滤掉站点模板小图标,
       只保留正文配图(可通过 MIN_IMAGE_BYTES 调整阈值)。

输出:把图片保存到 images/ 文件夹中。
"""

import os
import re
import sys
from urllib.parse import unquote, urljoin, urlparse

import requests

# 目标网页与图片保存目录
PAGE_URL = "https://news.fzu.edu.cn/yxfd.htm"
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "images")

# 网站模板中的装饰性小图标;若要严格抓取全部图片,清空该集合即可
SKIP_KEYWORDS = (
    "logo", "top_search", "footer", "icon", "bnt_", "nav", "loading", "btn",
)

# 正文配图体积阈值(字节):小于该值的图片视为模板图标而跳过
MIN_IMAGE_BYTES = 10 * 1024

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
    ),
    "Referer": PAGE_URL,  # 部分站点校验防盗链,带上来源页更稳妥
}

TIMEOUT = 30  # 单次请求超时时间(秒)

# 匹配 .jpg / .jpeg / .png 结尾的图片地址(忽略大小写,允许带查询串)
RE_IMG_SRC = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.I)
RE_TARGET_EXT = re.compile(r"\.(jpe?g|png)$", re.I)


def fetch_html(url: str) -> str:
    """下载网页源码。

    :param url: 目标网页网址
    :return: 解码后的 HTML 字符串
    """
    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: str, base_url: str) -> list:
    """从网页源码中提取所有 JPEG/JPG/PNG 图片的完整地址。

    :param html: 网页源码
    :param base_url: 页面地址,用于把相对路径补全为绝对 URL
    :return: 去重后的图片绝对 URL 列表
    """
    urls = []
    for src in RE_IMG_SRC.findall(html):
        src = src.strip()
        # 过滤 data:base64 内联图片等非 http 资源
        if src.startswith(("data:", "javascript:")):
            continue

        absolute = urljoin(base_url, src)  # 相对路径 -> 绝对 URL
        path = urlparse(absolute).path  # 只看路径部分,忽略查询串
        if not RE_TARGET_EXT.search(path):  # 只要 jpg/jpeg/png
            continue
        if absolute not in urls:
            urls.append(absolute)

    return urls


def is_body_image(url: str) -> bool:
    """判断图片是否为正文配图(过滤站点模板小图标)。

    :param url: 图片绝对地址
    :return: True 表示保留,False 表示跳过
    """
    filename = os.path.basename(urlparse(url).path).lower()
    # 命中模板图标关键词的直接跳过
    if any(keyword in filename for keyword in SKIP_KEYWORDS):
        return False
    return True


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

    :param url: 图片地址
    :param save_dir: 保存目录
    :return: 实际写入的字节数,失败或跳过时返回 0
    """
    try:
        response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
        response.raise_for_status()
    except requests.RequestException as exc:
        print(f"  [失败] {url} -> {exc}")
        return 0

    # 图片是二进制数据,直接取字节内容并判断体积
    content = response.content
    if len(content) < MIN_IMAGE_BYTES:
        print(f"  [跳过] 体积过小({len(content)} 字节),视为装饰图标:{url}")
        return 0

    # 文件名取 URL 原始文件名,去掉 URL 编码字符
    filename = unquote(os.path.basename(urlparse(url).path))
    if not filename:
        return 0

    os.makedirs(save_dir, exist_ok=True)
    file_path = os.path.join(save_dir, filename)
    with open(file_path, "wb") as file:  # 图片为二进制文件,用 wb 写入
        file.write(content)

    print(f"  [成功] {filename}({len(content)} 字节)")
    return len(content)


def main() -> None:
    """程序入口:抓页面 -> 提取图片链接 -> 逐张下载保存。"""
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")

    save_dir = os.path.abspath(SAVE_DIR)
    print(f"目标网页:{PAGE_URL}")

    try:
        html = fetch_html(PAGE_URL)
    except requests.RequestException as exc:
        print(f"网页请求失败:{exc}")
        return

    all_urls = extract_image_urls(html, PAGE_URL)
    targets = [url for url in all_urls if is_body_image(url)]
    print(f"页面共发现 {len(all_urls)} 张 JPEG/JPG/PNG 图片,其中正文配图 {len(targets)} 张。")
    print(f"开始下载到:{save_dir}")

    count, total_bytes = 0, 0
    for url in targets:
        size = download_image(url, save_dir)
        if size:
            count += 1
            total_bytes += size

    print(f"下载完成:成功 {count} 张,合计 {total_bytes / 1024:.1f} KB。")


if __name__ == "__main__":
    main()

(3)运行结果

页面共发现 19 张 JPEG/JPG/PNG 图片,其中正文配图 14 张,最终成功下载 9 张(其余为 155~503 字节的模板装饰小图标,被程序自动跳过)。

作业3-图片预览

图 3-1 作业③ 运行结果:下载日志与统计信息

下载得到的图片保存在 images 文件夹中,包含学校风景、活动海报等原图(最大一张 3840×2160):

作业3-运行结果

图 3-2 下载到 images 文件夹中的 9 张图片

3.2 作业心得

这次作业让我把"下载文件"这件事彻底搞明白了,收获主要有四点:

  1. 相对路径必须补全,否则一定下载失败。这是本次最大的坑。页面里的图片地址写法五花八门:有的是 images/logo.png(相对当前目录),有的是 /__local/F/4D/xxx.jpg(相对站点根目录),还有的是完整的 https:// 地址。如果直接把 src 丢给 requests.get(),前两种都会报 MissingSchema 或 404。用 urljoin(page_url, src) 统一补全成绝对 URL,三种情况就都能正确处理了。处理网页链接时,urllib.parse 里的 urljoin 和 urlparse 是必备工具。

  2. 图片必须用二进制模式(wb)写入。我一开始想当然地按文本方式写入,结果文件虽然生成了,却完全打不开——因为文本模式会按编码对字节做转换,把图片数据破坏了。改成 open(path, "wb") 后图片才正常显示。这也让我理解了文本文件和二进制文件的本质区别。

  3. 要区分"正文图片"和"模板图标"。页面上有很多装饰性小元素:网站 logo、搜索图标、导航箭头、loading 动画等,它们的扩展名同样符合 .png,如果照单全收,下载下来的文件夹里就会混进一堆几百字节的图标,看不出重点。我从两个维度做过滤:文件名关键词(logo、icon、nav 等)和文件体积(小于 10 KB 视为图标)。为了照顾"爬取所有图片"的字面要求,我把阈值和关键词都写成了可配置的常量,只要修改 MIN_IMAGE_BYTES 并清空 SKIP_KEYWORDS,就可以下载页面上的全部图片。

  4. 下载要做好异常处理与统计。网络请求随时可能失败(超时、404、防盗链),所以每张图片的下载都包在 try/except 里,单张失败只打印日志、不影响后续图片,程序最后统一输出成功张数和总字节数,便于确认结果。另外我顺手加上了 Referer 请求头,防止个别站点做防盗链校验。


四、总结与存在问题

作业 目标网站 核心库 爬取结果
作业① 软科 2020 中国大学排名 requests + BeautifulSoup 30 条排名数据(排名/校名/省市/类型/总分)
作业② 当当网"书包"搜索页 requests + re 60 件商品的名称与价格
作业③ 福州大学新闻网学院风采 requests 9 张 JPEG/JPG/PNG 正文配图

三次实验做下来,我对爬虫的整体流程有了清晰的框架感:分析网页结构 → 编码处理 → 发送请求 → 提取数据 → 清洗保存。其中"分析网页结构"和"处理编码"是我今后最需要继续加强的环节。

目前还存在的不足与改进方向:

  1. 翻页抓取。作业①和作业②都只抓取了首屏数据,还没有实现翻页循环;作业①的翻页由前端 JavaScript 控制,需要进一步抓取数据接口才能拿到全部 500 多所高校的数据。
  2. 并发下载。作业③的图片是串行下载的,9 张图片耗时较久,后续可以学习使用线程池提高效率。
  3. 数据存储。目前结果只打印在屏幕上,还没有保存为 CSV 或 Excel 文件,下一步计划用 csv 模块或 pandas 把数据落盘,便于后续做价格分析。
  4. 反爬应对。当当网这类商城对访问频率有限制,目前只做了设置 User-Agent 和加延时的简单处理;后续应学习更规范的抓取策略,并始终把遵守 robots.txt 与网站服务条款放在第一位。

合规声明:本文所有程序仅用于课程学习与技术练习,抓取时均设置了 User-Agent 与请求间隔,控制访问频率,不对目标网站造成压力;爬取到的数据未用于任何商业用途。

posted @ 2026-10-12 19:29  VioletQAQ  阅读(9)  评论(0)    收藏  举报