数据采集与融合技术第一次作业

数据采集与融合技术 第一次作业

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

(1)实验代码与运行结果

任务要求:用 requests 和 BeautifulSoup 库定向爬取软科2020中国大学排名数据,屏幕打印爬取的大学排名信息,输出字段为:排名、学校名称、省市、学校类型、总分。

爬取思路:

  1. 目标网址 https://www.shanghairanking.cn/rankings/bcur/2020 的数据以服务端渲染的 HTML 表格形式存在,每条记录对应 <tbody> 下的一个 <tr>。
  2. 第 1~5 个 <td> 依次是排名、学校名称、省市、学校类型、总分。
  3. 学校名称单元格中同时包含中英文名和“双一流/985/211”标签,需要通过 class="name-cn" 精确提取中文名。
  4. 控制台输出使用 unicodedata 计算字符显示宽度(中文算 2、英文数字算 1),实现中英文混合对齐。

代码实现:

"""作业①:requests + BeautifulSoup 定向爬取软科2020中国大学排名
输出格式:排名  学校名称  省市  学校类型  总分
"""
import re
import unicodedata
import requests
from bs4 import BeautifulSoup

URL = "https://www.shanghairanking.cn/rankings/bcur/2020"    # 目标网址:软科2020大学排名

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.shanghairanking.cn/",    # 声明请求来源,模拟从首页跳转
}


# 网络请求模块 
def fetch_html(url):
    resp = requests.get(url, headers=HEADERS, timeout=20)    # 发起请求,设置20秒超时
    resp.raise_for_status()                                  # 状态码非200时抛异常
    resp.encoding = resp.apparent_encoding                   # 自动识别编码,避免中文乱码
    return resp.text

#数据解析模块 
def parse_ranking(html):
    soup = BeautifulSoup(html, "html.parser")     # 创建 BeautifulSoup 对象
    tbody = soup.find("tbody")                    # 定位表格主体
    if not tbody:
        return []                                 # 页面结构异常,返回空列表

    rows = []
    for tr in tbody.find_all("tr"):               # 遍历表格的每一行
        tds = tr.find_all("td")                   # 获取该行的所有单元格
        if len(tds) < 5:
            continue                              # 跳过列数不足的无效行
        rank = tds[0].get_text(strip=True)        # 第1列:排名
        # 过滤掉表头等非纯数字行
        if not re.fullmatch(r"\d+", rank):
            continue
        # 第2列:学校名称——精确定位中文名所在 span,避免混入英文名和 985/211 标签
        name_span = tds[1].find("span", class_="name-cn")
        school = name_span.get_text(strip=True) if name_span else tds[1].get_text(strip=True)
        province = tds[2].get_text(strip=True)    # 第3列:省市
        school_type = tds[3].get_text(strip=True) # 第4列:学校类型
        score = tds[4].get_text(strip=True)       # 第5列:总分
        rows.append([rank, school, province, school_type, score])
    return rows


#  显示宽度计算模块 
def get_display_width(s):
    width = 0
    for ch in s:
        # east_asian_width() 返回 'W'(宽) 或 'F'(全角) 时按 2 个宽度计算
        width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
    return width


def pad_string(s, target_width, align="left"):
    padding = target_width - get_display_width(s)    # 计算需要补充的空格数
    if padding <= 0:
        return s                                     # 超出列宽,不做处理
    if align == "left":
        return s + " " * padding                     # 左对齐:右侧补空格
    if align == "right":
        return " " * padding + s                     # 右对齐:左侧补空格
    left_pad = padding // 2                          # 居中:两侧平分空格
    return " " * left_pad + s + " " * (padding - left_pad)


def print_table(rows):
    col_widths = [6, 24, 10, 10, 8]                       # 每列的显示宽度
    headers = ["排名", "学校名称", "省市", "学校类型", "总分"]
    col_gap = "    "                                       # 列与列之间的间隔

    header_str = col_gap.join(pad_string(h, w, "center") for h, w in zip(headers, col_widths))
    print(header_str)
    print("-" * (sum(col_widths) + len(col_gap) * (len(col_widths) - 1)))

    for row in rows:
        row_str = col_gap.join(
            pad_string(val, w, "right" if i == 0 else "left")
            for i, (val, w) in enumerate(zip(row, col_widths))
        )
        print(row_str)


# 主程序入口 
if __name__ == "__main__":
    html = fetch_html(URL)               # 1. 请求网页
    data = parse_ranking(html)           # 2. 解析数据
    if data:
        print_table(data)                # 3. 打印表格
    else:
        print("未获取到排名数据,请检查页面结构是否变化。")

运行结果截图:
5595a6c4f7b80035e563c1bcfacbd359

(2)心得体会

① BeautifulSoup 定位要“精确到子标签”

最初我直接对 <td> 调用 get_text(strip=True),结果学校名称输出成了“清华大学Tsinghua University双一流/985/211”这样混在一起的内容。原因是一个 <td> 里嵌套了多个 <span>,get_text() 会把它下面所有子孙节点的文本拼接起来。后来改用 tds[1].find("span", class_="name-cn") 精确定位到存放中文名的那个 span,才得到干净的“清华大学”。这让我意识到,解析 HTML 时要先看清 DOM 结构再决定用哪一级标签提取。

② 中英文混合输出的对齐难题

用 \t(制表符)分隔各列时,控制台输出看起来参差不齐。原因是中文和英文在控制台里占的显示宽度不同——一个汉字占两个字符位,一个英文/数字只占一个。后来通过 unicodedata.east_asian_width() 计算每个字符的显示宽度,再根据目标列宽进行空格填充,才实现完美对齐。

③ 正则筛选有效数据行

页面的 <tbody> 里并非所有 <tr> 都是排名数据,可能混入表头、广告等。用 re.fullmatch(r"\d+", rank) 判断第一列是否为纯数字,可以快速过滤掉无效行。

④ 请求头不能少

软科网站会检查 User-Agent,不加浏览器标识会被拒绝访问。加上 User-Agent 和 Referer 后请求就稳定了。


二、作业②:商城商品比价定向爬虫

(1)实验代码与运行结果

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

爬取思路:

  1. 目标商城选择当当网,搜索关键词“书包”。
  2. 当当网搜索页使用 GBK 编码,必须显式设置 resp.encoding = "gbk"。
  3. 通过诊断发现,价格使用 HTML 实体 &yen; 表示(不是 ¥ 字符),类名为 price_n。
  4. 商品名在 <p class="name"> 内的 <a> 标签的 title 属性中。

代码实现:

"""作业②:requests + re 爬取当当网“书包”搜索页
输出格式:序号  价格  商品名
"""
import re
import time
import requests

KEYWORD = "书包"      # 搜索关键词
PAGES = 3             # 抓取页数

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"
    ),
    "Accept-Language": "zh-CN,zh;q=0.9",       # 声明接受中文内容
    "Referer": "http://search.dangdang.com/",  # 请求来源
}

# 正则表达式模块
# 当当网价格使用 HTML 实体 &yen; 表示,不是 ¥ 字符
RE_PRICE = re.compile(r'<span class="price_n">(?:&yen;|[¥¥])?([\d.]+)</span>', re.S)
# 商品名:<p class="name" name="title"><a title="商品名"
RE_NAME = re.compile(r'<p class="name"[^>]*>\s*<a[^>]*title="([^"]+)"', re.S)

# 网络请求模块 
def fetch(keyword, page_index):
    url = "http://search.dangdang.com/"
    params = {"key": keyword, "act": "input", "page_index": page_index}
    for attempt in range(1, 4):                    # 最多重试 3 次
        try:
            resp = requests.get(url, params=params, headers=HEADERS, timeout=15)
            resp.raise_for_status()               # 状态码非200时抛异常
            resp.encoding = "gbk"                
            return resp.text
        except Exception as e:
            print(f" [重试 {attempt}/3] {type(e).__name__}: {e}")
            time.sleep(5)                         # 失败后等待 5 秒再重试
    return None

# 数据解析模块
def parse(html):
    if not html:
        return []
    names = [n.strip() for n in RE_NAME.findall(html)]    # 提取所有商品名
    prices = RE_PRICE.findall(html)                       # 提取所有价格

    items = []
    # 按出现顺序 zip 配对:第 n 个名称对应第 n 个价格
    for name, price in zip(names, prices):
        name = re.sub(r"\s+", " ", name).strip()          # 合并多余空白
        try:
            items.append((name, float(price)))            # 价格转 float,便于统计
        except ValueError:
            continue
    return items


# 主程序入口
def main():
    """主流程:多页抓取 → 解析 → 打印 → 统计"""
    print("=" * 78)
    print(f"商城商品比价定向爬虫  商城:当当网  关键词:{KEYWORD}")
    print("=" * 78)

    all_items = []
    for page in range(1, PAGES + 1):              # 逐页抓取
        print(f"\n[第 {page} 页]")
        html = fetch(KEYWORD, page)
        if not html:
            print("  抓取失败,跳过本页")
            continue
        items = parse(html)
        print(f"  抓取 {len(items)} 条")
        all_items.extend(items)
        time.sleep(3)                             # 礼貌性延时,避免触发反爬

    # 打印所有商品
    print("\n" + "=" * 78)
    print(f"共获取 {len(all_items)} 条商品信息")
    print("=" * 78)
    print(f"{'序号':<6}{'价格':>10}  商品名")
    print("-" * 78)
    for i, (name, price) in enumerate(all_items, 1):
        short = name if len(name) <= 50 else name[:49] + "…"    # 过长名称截断
        print(f"{i:<8}{price:>8.2f}  {short}")

    # 价格统计
    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()

运行结果截图:

d24b67fafd5bf13bc1f5c88f9839b351
ee79148a3f8d2615c951f432d5e0f8d0

(2)心得体会

① 编码问题是中文爬虫的第一道坎

当当网页面使用 GBK 编码,如果不显式设置 resp.encoding = "gbk",requests 会根据响应头里的 Content-Type 自动推断,有时会误判成 ISO-8859-1,导致中文全部变成乱码。最可靠的方法是查看页面 <meta charset> 标签。

② 价格标签会随网站改版而变化

最初从网上教程里看到的当当网价格类名是 search_now_price,但实际页面中该类名已经弃用。通过统计页面中所有含 price 的类名,发现真正在用的是 price_n。这告诉我,写爬虫不能照抄教程,要用诊断手段验证实际结构。

③ HTML 实体是正则匹配的“隐形陷阱”

诊断显示 price_n 出现 60 次,但价格正则还是匹配不到。最后打印 HTML 片段才发现,当当网价格用的是 &yen;(HTML 实体),而不是 ¥ 字符!修正为 (?:&yen;|[¥¥])? 后立刻成功抓取了 60 条数据。这个坑很典型,日后遇到货币符号、版权符号、箭头等特殊字符时,一定要考虑 HTML 实体的可能性。

④ 频率控制与反爬

当当网对短时间内的频繁请求比较敏感,访问间隔太短会遇到 502 Bad Gateway。把请求间隔设为 3 秒,并加上 time.sleep() 礼貌性延时后,请求就稳定多了。


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

(1)实验代码与运行结果

任务要求:爬取给定网页或自选网页中所有 JPEG、JPG、PNG 格式的图片文件,保存在一个文件夹中。

爬取思路:

  1. 目标网页选择福州大学新闻中心“院系风采”栏目。
  2. 用 BeautifulSoup 解析所有 <img> 标签,提取 src 或 data-src 属性(兼容懒加载)。
  3. 用 urljoin 把相对路径转为绝对路径,用集合去重。
  4. 用 urlparse 去掉查询串后再提取文件名,用正则清理非法字符。
  5. 以二进制模式("wb")逐张保存到 images 文件夹,每张间隔 0.5 秒。

代码实现:

"""作业③:爬取指定网页所有 JPEG、JPG、PNG 图片并保存到本地文件夹
目标网页:https://news.fzu.edu.cn/yxfd.htm
输出:所有图片保存到 images 文件夹,并打印每张图片的保存结果
"""
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"    # 默认目标网页

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://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):
    """请求网页,返回 HTML 文本
    url: 目标网址
    成功返回 HTML 文本,失败重试 3 次后返回 None
    """
    for attempt in range(1, 4):                    # 最多重试 3 次
        try:
            resp = requests.get(url, headers=HEADERS, timeout=20)
            resp.raise_for_status()               # 状态码非200时抛异常
            resp.encoding = resp.apparent_encoding or "utf-8"
            return resp.text
        except Exception as e:
            print(f" [重试 {attempt}/3] {type(e).__name__}: {e}")
            time.sleep(2)
    return None


# 图片 URL 收集模块
def collect_image_urls(html, base_url):
    """从 HTML 中提取所有 jpg/jpeg/png 图片的完整地址
    html: HTML 文本
            base_url: 页面 URL,用于将相对路径转为绝对路径
    去重后的图片 URL 列表(保持页面中出现的先后顺序)
    """
    soup = BeautifulSoup(html, "lxml")
    urls, seen = [], set()                          # seen 集合用于去重
    for img in soup.find_all("img"):
        # 优先取 src,没有则取 data-src(懒加载常见)
        src = (img.get("src") or img.get("data-src") or "").strip()
        if not src or not RE_IMAGE.search(src):
            continue                                # 跳过非目标格式
        full_url = urljoin(base_url, src)           # 相对路径 → 绝对路径
        if full_url not in seen:
            seen.add(full_url)
            urls.append(full_url)
    return urls


#文件名生成模块
def safe_filename(url, index):
    """根据 URL 生成安全的本地文件名
    url: 图片 URL
            index: 序号(避免重名,同时保留下载顺序)
    形如 01_logo.png 的文件名
    """
    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):
    """下载单张图片并保存到本地
    url: 图片 URL
            path: 本地保存路径
    下载的字节数
    """
    resp = requests.get(url, headers=HEADERS, timeout=25)
    resp.raise_for_status()
    with open(path, "wb") as f:                     # 二进制模式写入
        f.write(resp.content)
    return len(resp.content)


# 主程序入口
def main():
    """主流程:请求网页 → 收集图片 URL → 逐张下载 → 汇总结果"""
    # 支持命令行传入自选网页
    page_url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URL

    print("=" * 78)
    print(f"目标网页:{page_url}")
    print(f"保存目录:{SAVE_DIR}")
    print("=" * 78)

    # 创建保存目录(不存在时才创建)
    if not os.path.exists(SAVE_DIR):
        os.makedirs(SAVE_DIR)

    # 1. 请求网页
    html = fetch_html(page_url)
    if not html:
        print("网页抓取失败,请检查网络或 URL")
        return

    # 2. 收集图片 URL
    image_urls = collect_image_urls(html, page_url)
    print(f"\n共找到 {len(image_urls)} 张图片,开始下载...\n")

    # 3. 逐张下载
    success, failed = 0, 0
    for idx, img_url in enumerate(image_urls, 1):
        filename = safe_filename(img_url, idx)
        filepath = os.path.join(SAVE_DIR, filename)
        try:
            size = download(img_url, filepath)
            print(f"[{idx:>2}/{len(image_urls)}] ✓ {filename}  ({size} 字节)")
            success += 1
        except Exception as e:
            print(f"[{idx:>2}/{len(image_urls)}] ✗ {img_url}  —— {e}")
            failed += 1
        time.sleep(0.5)                             # 礼貌性延时

    # 4. 汇总结果
    print("\n" + "=" * 78)
    print(f"下载完成:成功 {success} 张,失败 {failed} 张")
    print(f"图片保存位置:{SAVE_DIR}")
    print("=" * 78)


if __name__ == "__main__":
    main()

运行结果截图:

image

image

(2)心得体会

1、urljoin 是处理相对路径的利器

福大新闻页的图片地址很多是相对路径,如 /upload/images/xxx.jpg。如果不处理直接拼接,会得到错误的地址。用 urllib.parse.urljoin(page_url, src) 可以自动处理所有情况(相对路径、绝对路径、协议相对路径),是图片爬虫必备的工具。

2、 懒加载图片要用 data-src

现代网站为优化首屏加载,往往把 <img> 的 src 设置为一个空白占位图,真实的图片地址放在 data-src 属性中,滚动到可见区域时才由 JavaScript 替换。因此提取时应该 src or data-src 双保险。

3、 查询串会破坏文件名的扩展名判断

有些图片地址是 xxx.png?e=.png 这种带防盗链参数的形式,如果用 os.path.splitext 直接处理,扩展名会变成 png?e=.png,导致文件类型识别错误。正确做法是先用 urlparse 去掉查询串,再提取文件名。

4、 二进制文件必须用 wb 模式

图片是二进制数据,如果误用文本模式 open(path, "w") 打开,write() 遇到 \x00 等特殊字节会报错或导致文件损坏。用 "wb" 打开后写入 resp.content(字节流)才正确。

5、 集合去重能省不少事

福大新闻页的页头页脚有重复的 logo,如果不去重会重复下载。用 seen = set() 记录已经出现过的 URL,可以避免重复请求和重复保存。

6、 礼貌性延时

每张图片之间加 time.sleep(0.5) 是爬虫的基本礼仪。如果爬取大型图片站,不加延时会因为请求频繁被对方封 IP。

7、 完整流程总结

这次作业让我完整实践了“请求网页 → 解析 HTML → 提取 URL → 下载文件 → 保存本地”这一整套流程。之前的作业都是提取文本数据,这次是下载二进制文件,对文件 I/O、编码、异常处理的细节要求更高。整体跑通后,爬取 32 张图片全部成功。

posted @ 2026-10-15 18:38  王凯凤  阅读(3)  评论(0)    收藏  举报