数据采集作业

软件工程作业1 · 网络爬虫基础(requests 库实战)

运行环境:Windows 11 · Python 3.14.7 · requests · beautifulsoup4 · lxml
代码仓库:https://gitee.com/zhuang-jianyi/data-collection-task

本次作业一共三道题,刚好覆盖三种最常见的爬虫写法:用 BeautifulSoup 解析 DOM 结构、用正则表达式提取字段、以及批量下载二进制资源。三份代码都按 Python 规范书写,关键函数和变量都加了注释。


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

1.1 代码与运行结果

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

# -*- coding: utf-8 -*-
"""
作业①:爬取上海软科中国大学排名
================================

要求:用 requests 和 BeautifulSoup 库方法定向爬取给定网址
      http://www.shanghairanking.cn/rankings/bcur/2020
      的数据,屏幕打印爬取的大学排名信息。

输出:排名  学校名称  省市  学校类型  总分

实现说明
--------
1. 页面是服务端渲染的,排名数据就在 HTML 的 <table> 里,不需要额外的接口。
2. 每行 6 个 <td>,依次是:排名 / 学校名称 / 省市 / 学校类型 / 总分 / 办学层次得分。
   其中「学校名称」格子里同时有中文名、英文名和办学层次标签,
   用 CSS 选择器 .name-cn 才能只取中文名。
3. 一页 30 条。该网站的翻页是前端异步请求完成的,直接在 URL 上加 page 参数
   返回的仍然是第一页,所以本程序解析首页的这 30 条、验证解析逻辑正确即可。
   若要拿到全部 567 所,需要另外找到它前端调用的数据接口。

运行: python 1.py
"""

import requests
from bs4 import BeautifulSoup

# 请求头:带上 User-Agent,避免被当成爬虫直接拒绝
HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                   "AppleWebKit/537.36 (KHTML, like Gecko) "
                   "Chrome/124.0.0.0 Safari/537.36"),
    "Accept-Language": "zh-CN,zh;q=0.9",
}

# 排名页地址(2020 软科中国大学排名主榜)
BASE_URL = "http://www.shanghairanking.cn/rankings/bcur/2020"


def fetch_page(url, timeout=20):
    """请求页面并返回解码后的 HTML 文本。

    软科返回的响应头里 charset 有时不准,所以先用 apparent_encoding 猜一次,
    猜不出来再退回 utf-8,避免中文校名变成乱码。
    """
    response = requests.get(url, headers=HEADERS, timeout=timeout)
    response.raise_for_status()
    response.encoding = response.apparent_encoding or "utf-8"
    return response.text


def parse_ranking(html):
    """从 HTML 里解析出排名列表。

    返回一个列表,每个元素是字典:
        {"rank": 排名, "name": 学校名称, "province": 省市,
         "category": 学校类型, "score": 总分}
    """
    soup = BeautifulSoup(html, "lxml")
    table = soup.find("table")
    if table is None:
        return []

    results = []
    for tr in table.find("tbody").find_all("tr"):
        cells = tr.find_all("td", recursive=False)
        # 正常数据行有 6 列;少于 5 列说明结构变了,跳过免得抛异常
        if len(cells) < 5:
            continue
        # 学校名称格子里含中英文和标签,用 .name-cn 精确取中文名
        name_node = tr.select_one(".name-cn")
        results.append({
            "rank": cells[0].get_text(strip=True),
            "name": name_node.get_text(strip=True) if name_node else "",
            "province": cells[2].get_text(strip=True),
            "category": cells[3].get_text(strip=True),
            "score": cells[4].get_text(strip=True),
        })
    return results


def print_ranking(items):
    """按表格样式打印排名信息。"""
    # 各列宽度:中文按 2 个字符宽计算,保证表格对齐
    widths = (6, 22, 8, 10, 8)
    header = ("排名", "学校名称", "省市", "学校类型", "总分")
    line = "-" * (sum(widths) + 4 * 2)

    print(line)
    print("".join(h.ljust(w) for h, w in zip(header, widths)))
    print(line)
    for it in items:
        row = (it["rank"], it["name"], it["province"],
               it["category"], it["score"])
        # 中文占两个显示宽度,用 ljust 前先按显示宽度补空格
        print("".join(_pad(v, w) for v, w in zip(row, widths)))
    print(line)
    print(f"共 {len(items)} 条(当前页)")


def _pad(text, width):
    """按显示宽度左对齐补空格:中文算 2 格,英文算 1 格。"""
    display = sum(2 if ord(ch) > 0x2000 else 1 for ch in text)
    return text + " " * max(1, width - display)


def main():
    print(f"正在爬取:{BASE_URL}\n")
    html = fetch_page(BASE_URL)
    items = parse_ranking(html)
    print_ranking(items)


if __name__ == "__main__":
    main()

在 作业1 目录下执行 python 1.py,运行结果:

run1

解析思路:这个页面的排名数据是服务端渲染的,直接躺在 <table> 标签里,所以不需要去翻接口。顺着 table → tbody → tr → td 往下走就行:每个数据行有 6 个 <td>,依次是排名、学校名称、省市、学校类型、总分、办学层次得分,取前 5 个正好对上题目要求的 5 列。

两个踩过的坑:

  1. 学校名称那一格不干净。 这个 <td> 里同时塞了中文名、英文名和「双一流」之类的标签。一开始我直接 cells[1].get_text(),结果清华大学被打印成一长串中英文混排。改用 CSS 选择器 .name-cn 只取中文名之后才干净。
  2. 中文表格对不齐。 str.ljust() 数的是字符个数,而一个汉字在等宽终端里占两个字符宽,直接用 ljust 会让所有列参差不齐。所以额外写了个 _pad():先用 ord(ch) > 0x2000 判断是不是宽字符,再按显示宽度补空格。

1.2 作业心得

这道题让我第一次意识到,「能取到数据」和「取到的数据是干净的」完全是两件事。第一次跑通时屏幕上确实有 30 行,但学校名称那一列全是「清华大学 Tsinghua University 双一流」这种长串,看起来能跑,其实是不合格的。真正让我找到原因的是把某个 <td> 的 HTML 单独打印出来看了一眼——很多时候调试爬虫最有效的办法就是把目标节点原样 dump 出来,而不是盯着最终结果猜。

中文对齐这个小问题算是自己给自己找的麻烦,但顺手写 _pad() 的过程中,我把「字符长度」和「显示宽度」这两个一直混着用的概念彻底分清楚了。

另外还试了一下翻页。本来想加个页码参数把全部 567 所都抓下来,实测在 URL 后面加 ?page=2 返回的仍然是第一页——说明这个网站的翻页是前端异步拉取的,服务端只渲染第一页。所以代码里我如实注明只解析首页 30 条。这件事提醒我:写爬虫之前最好先开 F12 确认分页到底是怎么实现的,比写完再回头调试省事得多。


二、作业②:商城商品比价爬虫(当当网「书包」)

2.1 代码与运行结果

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

商城选择说明:课件原本以淘宝为例,但淘宝的搜索页需要登录态并且有较强的反爬校验,直接用 requests 请求只能拿到验证页面。我改选当当网——它的搜索结果是服务端渲染的静态 HTML,商品名和价格都能在源码里直接定位,分页参数也很清晰,正好用来练习「定向爬虫 + 正则提取 + 翻页」。

# -*- coding: utf-8 -*-
"""
作业②:商城商品比价定向爬虫
============================

要求:用 requests 和 re 库方法设计某个商城(自己选择)商品比价定向爬虫,
      爬取该商城,以关键词「书包」搜索页面的数据,爬取商品名称和价格。

输出:序号  价格  商品名

商城选择说明
------------
课件原本以淘宝为例,但淘宝的搜索页需要登录态并带有较强的反爬校验,
直接请求只能拿到验证页面。这里选择**当当网**:它的搜索结果是服务端渲染的
静态 HTML,商品名和价格都能在源码中直接定位,分页参数也很清晰,
适合练习「定向爬虫 + 正则提取 + 翻页」。

搜索接口
--------
    http://search.dangdang.com/?key=<关键词>&act=input&page_index=<页码>

    key         关键词,需要 URL 编码(「书包」→ %E4%B9%A6%E5%8C%85)
    act=input   表示从搜索框输入进入
    page_index  页码,从 1 开始,改它就能翻页

页面结构
--------
    <p class="price"><span class="price_n">&yen;146.00</span></p>
    <p class="name" name="title"><a title="商品全名" href="//...">…</a></p>

    注意两点:
      1) 价格里的 ¥ 在源码中是 HTML 实体 &yen;,不是字符「¥」;
      2) 商品名的 <a> 里被插入了 <font class="skcolor_ljg">书包</font>
         高亮标签,直接取标签内文本会把关键词拆开,所以取 title 属性更干净。

运行: python 2.py
"""

import re
import sys
import time
import urllib.parse

import requests

# 搜索关键词
KEYWORD = "书包"
# 抓取页数与每页条数:3 页 × 20 条 = 60 条
PAGES = 3
PER_PAGE = 20

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                   "AppleWebKit/537.36 (KHTML, like Gecko) "
                   "Chrome/124.0.0.0 Safari/537.36"),
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}

# 价格:<span class="price_n">&yen;146.00</span>
RE_PRICE = re.compile(r'<span class="price_n">\s*&yen;\s*([\d.]+)\s*</span>')
# 商品名:<p class="name" ...><a title="商品全名" ...>
RE_NAME = re.compile(r'<p class="name"[^>]*>\s*<a\s+title="([^"]+)"')
# HTML 里声明的编码,用于判断按什么解码
RE_CHARSET = re.compile(r'charset=["\']?([\w-]+)', re.I)


# 搜索接口模板,仅用于打印提示信息(<关键词>、<页码> 由 build_url 填入)
SEARCH_API = ("http://search.dangdang.com/?key=<关键词>"
              "&act=input&page_index=<页码>")


def build_url(keyword, page):
    """拼出搜索接口的 URL —— 翻页就是改 page_index。"""
    return ("http://search.dangdang.com/?key="
            f"{urllib.parse.quote(keyword)}&act=input&page_index={page}")


def fetch(url, retry=3):
    """请求页面并按正确的编码解码(当当是 GBK)。"""
    last_error = None
    for attempt in range(1, retry + 1):
        try:
            response = requests.get(url, headers=HEADERS, timeout=20)
            response.raise_for_status()
            # 先看 HTTP 头,再看 HTML 里的 <meta charset>,都没有就按 gbk
            encoding = response.encoding or "gbk"
            if not encoding or encoding.lower() in ("iso-8859-1", "ascii"):
                match = RE_CHARSET.search(response.text[:4000])
                encoding = match.group(1) if match else "gbk"
            if encoding.lower() in ("gb2312", "gbk"):
                encoding = "gb18030"        # gb18030 是 gbk 的超集,容错更好
            return response.content.decode(encoding, "ignore")
        except Exception as error:                        # noqa: BLE001
            last_error = error
            print(f"    [重试 {attempt}/{retry}] {type(error).__name__}: {error}")
            time.sleep(2)
    raise RuntimeError(f"抓取失败:{url}({last_error})")


def parse_items(html, limit=None):
    """用正则从 HTML 中提取 (商品名称, 价格) 列表。

    页面中每个商品块都是「价格在前、名称在后」,且两者数量相等,
    因此直接按出现顺序 zip 配对即可。
    """
    names = [n.strip() for n in RE_NAME.findall(html)]
    prices = RE_PRICE.findall(html)

    items = []
    for name, price in zip(names, prices):
        # 合并名称中多余的空白字符
        name = re.sub(r"\s+", " ", name).strip()
        try:
            items.append((name, float(price)))
        except ValueError:
            continue
    return items[:limit] if limit else items


def print_items(items):
    """按「序号 价格 商品名」的格式打印。"""
    print(f"{'序号':<6}{'价格':>10}  商品名")
    print("-" * 78)
    for index, (name, price) in enumerate(items, 1):
        short = name if len(name) <= 42 else name[:41] + "…"
        print(f"{index:<8}{price:>8.2f}  {short}")


def main():
    print("=" * 78)
    print(f"商城商品比价定向爬虫 商城:当当网 关键词:{KEYWORD}")
    print(f"搜索接口:{SEARCH_API}")
    print(f"抓取策略:{PAGES} 页 × 每页 {PER_PAGE} 条 = {PAGES * PER_PAGE} 条")
    print("=" * 78)

    all_items = []
    for page in range(1, PAGES + 1):
        url = build_url(KEYWORD, page)
        print(f"\n[第 {page} 页] {url}")
        html = fetch(url)
        print(f"    返回 HTML {len(html)} 字符,"
              f"页面中价格标签 {len(RE_PRICE.findall(html))} 个、"
              f"商品名标签 {len(RE_NAME.findall(html))} 个")
        items = parse_items(html, limit=PER_PAGE)
        print(f"    本页取前 {len(items)} 条")
        all_items.extend(items)
        time.sleep(1)                 # 放慢速度,避免给对方服务器压力

    print("\n" + "=" * 78)
    print(f"共获取 {len(all_items)} 条商品信息")
    print("=" * 78)
    print_items(all_items)

    if all_items:
        prices = [p for _, p in all_items]
        print("-" * 78)
        print(f"价格统计:最低 {min(prices):.2f} 元 最高 {max(prices):.2f} 元 "
              f"平均 {sum(prices) / len(prices):.2f} 元")


if __name__ == "__main__":
    main()

抓取过程(3 页 × 每页 20 条):

run2_1

商品清单与价格统计:

run2_2

本次共抓取 60 条商品,价格统计:最低 38.00 元、最高 756.00 元、平均 121.06 元。

两条正则是整个程序的核心:

# 价格:<span class="price_n">&yen;146.00</span>
RE_PRICE = re.compile(r'<span class="price_n">\s*&yen;\s*([\d.]+)\s*</span>')
# 商品名:<p class="name" ...><a title="商品全名" ...>
RE_NAME = re.compile(r'<p class="name"[^>]*>\s*<a\s+title="([^"]+)"')

2.2 作业心得

为什么换商城。 一开始我老老实实照课件去请求淘宝,拿回来的是一个几十 KB 的验证/登录页面,re.findall 一个价格都匹配不到。换成当当之后第一次请求就拿到了 232812 个字符的完整 HTML。这让我明白:定向爬虫的「定向」不只是指选一个网址,更是指选一个技术上门槛匹配的目标——对练习正则提取来说,服务端渲染的页面才是合适的靶子。

正则踩的三个坑:

  1. 价格里的 ¥ 不是 ¥。 源码里写的是 HTML 实体 &yen;,如果正则里直接写 ¥ 会一条都匹配不到。所以正则里必须写成 &yen;。
  2. 商品名里有高亮标签。 当当会把命中的关键词用 <font class="skcolor_ljg">书包</font> 包起来,如果取 <a> 标签内的文本,商品名会在关键词处被切断。改取 title 属性就完全干净了——属性值里不会插标签,这是取文本时一个很实用的小技巧。
  3. 编码。 当当返回的是 GBK,但 HTTP 响应头有时给的是 iso-8859-1,直接 decode 就是乱码。我的处理是:先看响应头,不可信再看 HTML 里的 <meta charset>,最后统一用 gb18030 解码兜底——gb18030 是 gbk 的超集,遇到生僻字也不会抛异常。

关于 zip 配对。 页面里每个商品块都是「价格在前、名称在后」,而且两者数量严格相等,所以直接按出现顺序 zip 配对即可。为了确认没有错位,我在最后加了一个价格统计(最低 / 最高 / 平均)。这一步其实是一道自检:如果正则写错导致价格和商品名错配,或者把「划线原价」也匹配进去了,统计结果会明显不合理。看到平均 121 元、最低 38 元、最高 756 元这个区间,就知道 60 条数据基本是可信的。

礼貌爬取。 翻页之间 sleep(1),不给对方服务器压力;只抓公开的搜索结果页,不涉及任何需要登录的数据。


三、作业③:网页图片批量下载

3.1 代码与运行结果

题目要求:爬取给定网页 https://news.fzu.edu.cn/yxfd.htm(福州大学新闻中心「院系风采」栏目)的所有 JPEG、JPG 或 PNG 格式图片文件,保存在一个文件夹中。

# -*- coding: utf-8 -*-
"""
作业③:批量下载网页中的图片
============================

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

输出:把页面里所有 jpg / jpeg / png 图片保存到与本脚本同级的 images/ 目录

实现说明
--------
1. 用 BeautifulSoup 取出页面里所有 <img> 标签,再逐个看 src 属性。
2. 页面里的图片地址有两种写法:
       images/logo.png                       ← 相对路径
       /__local/F/4D/B3/xxxx.jpg             ← 以 / 开头的站内绝对路径
   统一用 urljoin(页面地址, src) 转成完整地址,否则下载必然 404。
3. 有的地址带查询串(如 `xxx.png?e=.png`),判断扩展名和取文件名时都要
   先把 `?` 后面的部分去掉,否则会把 `png?e=.png` 当成扩展名。
4. 同一张图可能出现多次(页头页脚各一份 logo),用集合去重避免重复下载。
5. 用二进制方式写文件:图片不是文本,用 'wb' 才不会损坏内容。

运行: python 3.py            # 使用默认网页
      python 3.py <网页地址>  # 爬取自选网页
"""

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

import requests
from bs4 import BeautifulSoup

# 默认网页:福州大学新闻中心「院系风采」栏目
DEFAULT_URL = "https://news.fzu.edu.cn/yxfd.htm"

# 只下载这三种格式
WANTED_EXT = (".jpg", ".jpeg", ".png")

HEADERS = {
    "User-Agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                   "AppleWebKit/537.36 (KHTML, like Gecko) "
                   "Chrome/124.0.0.0 Safari/537.36"),
    "Referer": "https://news.fzu.edu.cn/",
}

# 保存目录:与本脚本同级,避免依赖运行时的当前目录
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "images")

# 匹配以 .jpg/.jpeg/.png 结尾(后面可以跟查询串)
RE_IMAGE = re.compile(r"\.(jpe?g|png)(?:\?.*)?$", re.I)


def fetch_html(url, timeout=20):
    """请求网页并返回解码后的 HTML 文本。"""
    response = requests.get(url, headers=HEADERS, timeout=timeout)
    response.raise_for_status()
    # 福大新闻页声明的是 UTF-8-SIG,用 apparent_encoding 猜一次更稳
    response.encoding = response.apparent_encoding or "utf-8"
    return response.text


def collect_image_urls(html, base_url):
    """从 HTML 中取出所有 jpg / jpeg / png 图片的完整地址。

    返回去重后的地址列表,并保持页面中出现的先后顺序。
    """
    soup = BeautifulSoup(html, "lxml")
    urls, seen = [], set()

    for img in soup.find_all("img"):
        # 有的站点把真实地址放在 data-src(懒加载),这里一并考虑
        src = img.get("src") or img.get("data-src") or ""
        src = src.strip()
        if not src or not RE_IMAGE.search(src):
            continue
        # 相对地址转成完整地址
        full = urljoin(base_url, src)
        if full not in seen:
            seen.add(full)
            urls.append(full)
    return urls


def safe_filename(url, index):
    """根据地址生成一个安全的文件名,前面带上序号避免重名。"""
    path = urlparse(url).path                 # 去掉查询串
    name = os.path.basename(path)
    # 去掉文件名里可能引起问题的字符
    name = re.sub(r"[^\w.\-\u4e00-\u9fff]", "_", name)
    if not name or not RE_IMAGE.search(name):
        name = "image.png"
    return f"{index:02d}_{name}"


def download(url, path, timeout=25):
    """下载单个图片到指定路径,成功返回文件的字节数。"""
    response = requests.get(url, headers=HEADERS, timeout=timeout)
    response.raise_for_status()
    with open(path, "wb") as fh:              # 二进制写入,不能用 'w'
        fh.write(response.content)
    return len(response.content)


def main():
    # 支持在命令行传入自选网页
    page_url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URL

    print("=" * 70)
    print("网页图片批量下载")
    print(f"目标网页:{page_url}")
    print(f"保存目录:{SAVE_DIR}")
    print("=" * 70)

    html = fetch_html(page_url)
    print(f"\n页面下载完成,共 {len(html)} 字符")

    urls = collect_image_urls(html, page_url)
    print(f"发现 jpg / jpeg / png 图片 {len(urls)} 个\n")

    os.makedirs(SAVE_DIR, exist_ok=True)

    ok, failed, total_bytes = 0, [], 0
    for index, url in enumerate(urls, 1):
        filename = safe_filename(url, index)
        path = os.path.join(SAVE_DIR, filename)
        try:
            size = download(url, path)
            ok += 1
            total_bytes += size
            print(f"  [{index:>2}/{len(urls)}] 成功 {size / 1024:>8.1f} KB  {filename}")
        except Exception as error:                       # noqa: BLE001
            failed.append((url, str(error)))
            print(f"  [{index:>2}/{len(urls)}] 失败 {type(error).__name__}  {url}")
        time.sleep(0.3)               # 放慢速度,避免给对方服务器压力

    print("\n" + "=" * 70)
    print(f"下载完成:成功 {ok} 个,失败 {len(failed)} 个,"
          f"合计 {total_bytes / 1024:.1f} KB")
    print(f"文件已保存在:{SAVE_DIR}")
    if failed:
        print("\n以下图片下载失败:")
        for url, reason in failed:
            print(f"  {url}\n      {reason}")


if __name__ == "__main__":
    main()

执行 python 3.py,下载过程:

run3

最终把页面里的 19 张图片全部下载成功,合计 22.6 MB,保存在 作业1/images/ 目录下:

folder

3.2 作业心得

这道题最核心的一点是地址补全。 页面里的 <img> 有两种写法:一种是相对路径 images/logo.png,另一种是以 / 开头的站内绝对路径 /__local/F/4D/B3/xxxx.jpg。如果直接拿 src 去 requests.get(),两种都会 404。正确做法是用 urljoin(页面地址, src) 统一转成完整地址——它能自动处理相对路径和站内绝对路径两种情况。一开始我只处理了相对路径,结果大部分图片都下载失败,就是栽在这里。

另外几个细节:

  • 查询串要先剥掉。 有些地址长这样:xxx.png?e=.png。判断扩展名和取文件名之前必须先把 ? 后面的部分去掉,否则会把 png?e=.png 当成扩展名,文件名也会变成一长串乱码。我用 urlparse(url).path 拿干净的路径。
  • 图片必须用二进制模式写。 open(path, 'wb') 而不是 'w',用文本模式打开会把二进制内容破坏掉。
  • 去重。 页头页脚的 logo 会在同一页出现多次,用集合去重后 19 张没有重复下载。
  • 保存目录要稳。 我用 os.path.dirname(os.path.abspath(__file__)) 定位到脚本所在目录,这样不管从哪个目录执行 python 3.py,图片都会存到 作业1/images/ 下,不会跑到当前工作目录里去。
  • 异常不能中断整个任务。 单张图片下载失败只记录到失败列表,继续下载剩下的,最后统一汇报「成功几个、失败几个」。

关于合规和礼貌。 脚本只下载公开页面的图片、请求之间 sleep(0.3) 限速、并且支持从命令行传入任意自选网页地址。我觉得爬虫的边界感很重要:拿到自己需要的数据就够了,不要为了跑满 CPU 而并发猛冲别人的服务器。

posted @ 2026-10-09 12:38  庄剑钇  阅读(6)  评论(0)    收藏  举报