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

作业①:大学排名信息定向爬取

1)实验代码与效果展示

实验目的:使用 Python 的 requests 和 BeautifulSoup 库,爬取指定的 2020 年软科中国大学排名页面,在屏幕上打印“排名、学校名称、省市、学校类型、总分”五个字段。原题给出 HTTP 地址,代码使用同一路径的 HTTPS 地址。

运行环境与文件说明(三个作业共用):本次使用 Windows、Python 3.13.15、requests 2.34.2、beautifulsoup4 4.15.0。实测日期为 2026 年 10 月 20 日(北京时间)。文档共三个一级标题,每个一级标题下设两个二级标题,依次给出代码、图片和心得。心得部分为可供结合个人操作过程修改的参考稿。

解压配套压缩包,在包含 requirements.txt 的目录打开终端,安装依赖并执行:

python -m pip install -r requirements.txt
python code/task1.py
python code/task2.py
python code/task3.py

如本地 Python 命令为 python3,相应替换命令。三个程序都可独立运行,输出路径相对于程序所在位置确定。results/ 保存数据,results/images/ 集中保存图片,assets/ 保存博客展示图。Pillow 只用于本报告制图,运行三个作业程序不需要安装它。

实验步骤与实现说明:

  1. 用 requests.Session.get() 获取网页,设置超时时间并检查 HTTP 状态码。
  2. 用 BeautifulSoup 解析 HTML,定位 tbody tr,逐行读取表格。
  3. 学校名称单元格同时含英文校名和标签,因此定位 .name-cn,避免将这些内容拼接进中文名称。
  4. 输出五个指定字段,并写入带 UTF-8 BOM 的 CSV,便于在 Windows 中查看中文。

完整代码(code/task1.py):

"""作业①:用 requests 和 BeautifulSoup 提取大学排名。"""
import csv
import os
from pathlib import Path

import requests
from bs4 import BeautifulSoup

URL = "https://www.shanghairanking.cn/rankings/bcur/2020"
OUT = Path(__file__).resolve().parents[1] / "results"


def fetch_rankings():
    """提取初始 HTML 中的排名行;不把首页行数当成全榜数量。"""
    with requests.Session() as session:
        # 仅在环境代理不可用时,显式设置 CRAWLER_DIRECT=1 使用直连。
        session.trust_env = os.getenv("CRAWLER_DIRECT") != "1"
        response = session.get(URL, timeout=30)
        response.raise_for_status()
    response.encoding = "utf-8"
    soup = BeautifulSoup(response.text, "html.parser")
    rows = []
    for row in soup.select("tbody tr"):
        cells = row.find_all("td", recursive=False)
        if len(cells) < 5:
            continue
        name = cells[1].select_one(".name-cn")
        if name is None:
            raise ValueError("学校名称节点缺失,请检查页面结构。")
        rows.append([
            cells[0].get_text(strip=True), name.get_text(strip=True),
            cells[2].get_text(strip=True), cells[3].get_text(strip=True),
            cells[4].get_text(strip=True),
        ])
    if not rows:
        raise ValueError("未找到排名,请检查页面结构或访问状态。")
    return rows


def main():
    """屏幕打印五个指定字段,并另存 CSV 便于核对。"""
    rows = fetch_rankings()
    header = ["排名", "学校名称", "省市", "学校类型", "总分"]
    print("\t".join(header))
    for row in rows:
        print("\t".join(row))
    OUT.mkdir(parents=True, exist_ok=True)
    with (OUT / "rankings.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(header)
        writer.writerows(rows)
    print(f"共提取 {len(rows)} 条:仅为初始 HTML 中的可见排名。")


if __name__ == "__main__":
    main()

实际运行结果:程序成功提取 30 条排名。下表展示前五条,完整屏幕输出保存在 results/task1.log,完整数据保存在 results/rankings.csv。

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

ranking

图 1:根据本次运行生成的 CSV 排版制作的结果图,不是终端截图。

范围说明:网页主榜标注 567 所学校,但初始 HTML 只提供第一页 30 条。本程序按题目要求抓取并打印页面中的排名信息,没有将 30 条误称为完整的 567 所大学;它也没有通过更改年份来替代题目指定的 2020 年数据。如教师另行要求全榜,应在此基础上研究分页数据来源。

运行核验:字段数均为五列;首条数据为“清华大学、北京、综合、852.5”;排名按页面顺序输出。当前环境的系统代理曾导致连接失败,使用 PowerShell 命令 $env:CRAWLER_DIRECT="1" 后完成实测。代码默认仍遵循系统代理,仅在显式设置此变量后改为直连。

2)心得体会

这道题让我理解了网页获取和网页解析是两个不同环节:requests 负责得到服务器返回的 HTML,BeautifulSoup 负责在 HTML 中定位需要的内容。仅仅取得 HTTP 200 并不代表已经得到有效排名,因此程序还要检查表格行是否存在。

解析学校名称时,直接对整个单元格调用 get_text() 会把英文名称和院校标签一起提取出来。先观察实际结构,再定位 .name-cn,可以使输出更准确。对空白字符使用 strip=True,也能避免换行和缩进影响显示。

本实验还说明,浏览器显示的总条数不等于一次请求返回的条数。初始 HTML 的 30 条结果只能代表第一页。以后处理分页或动态网页时,应先确认数据来源,再确定爬取范围,不能仅根据页面的总数宣称已抓取完整数据。

作业②:商城“书包”商品比价定向爬取

1)实验代码与效果展示

实验目的:使用 requests 和 re,选择当当网作为商城,以“书包”为关键词抓取搜索结果页,提取商品名称和价格,按“序号、价格、商品名”输出。为便于比价,在同一搜索页内按价格从低到高排序。

数据来源:当当网“书包”搜索页。当当搜索使用 GBK 编码的关键词,代码通过 quote(KEYWORD, encoding="gbk") 生成查询字符串。仅抓取第一页公开响应,不执行登录或购物操作。

实验步骤与实现说明:

  1. 获取搜索页面,使用响应的推测编码解码中文。
  2. 用正则表达式找到 bigimg 商品列表,再分离每个 li 商品块。
  3. 在同一个商品块中分别提取名称和 price_n 现价,避免从全页分别取两个列表而造成名称与价格错配。
  4. 清除名称中的高亮标签,还原 HTML 实体;用商品 ID 去重,跳过缺少有效价格的条目。
  5. 用 Decimal 处理金额,按现价升序排序后重新编号,再打印并保存 CSV。

完整代码(code/task2.py):

"""作业②:用 requests 和 re 提取当当网书包搜索结果。"""
import csv
import html
import os
import re
from decimal import Decimal
from pathlib import Path
from urllib.parse import quote

import requests

OUT = Path(__file__).resolve().parents[1] / "results"
KEYWORD = "书包"


def clean_text(value):
    """移除商品名中的高亮标签,并还原 HTML 实体。"""
    return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", "", value))).strip()


def parse_products(source):
    """先限定商品列表和商品块,再在同一块中配对名称与现价。"""
    listing = re.search(
        r'<ul\b[^>]*class=[\'"][^\'"]*\bbigimg\b[^\'"]*[\'"][^>]*>(.*?)</ul>',
        source, re.S | re.I,
    )
    if listing is None:
        raise ValueError("未找到商品列表,可能遇到验证页或页面结构改变。")
    products = []
    seen = set()
    for block in re.findall(r"<li\b[^>]*>.*?</li>", listing.group(1), re.S | re.I):
        name = re.search(
            r'<p\b[^>]*class=[\'"]name[\'"][^>]*>\s*<a\b[^>]*>(.*?)</a>',
            block, re.S | re.I,
        )
        price = re.search(
            r'<span\b[^>]*class=[\'"]price_n[\'"][^>]*>(.*?)</span>',
            block, re.S | re.I,
        )
        product_id = re.search(r'<li\b[^>]*\bid=[\'"](\d+)[\'"]', block, re.I)
        if name is None or price is None or product_id is None:
            continue
        number = re.search(r"\d[\d,]*(?:\.\d{1,2})?", clean_text(price.group(1)))
        if number is None or product_id.group(1) in seen:
            continue
        seen.add(product_id.group(1))
        products.append((Decimal(number.group().replace(",", "")), clean_text(name.group(1))))
    if not products:
        raise ValueError("列表中没有有效商品名和价格,请检查响应内容。")
    return sorted(products, key=lambda item: item[0])


def main():
    """搜索“书包”,按现价升序展示,序号在排序后生成。"""
    # 当当搜索接口使用 GBK 编码的关键词。
    url = "https://search.dangdang.com/?key=" + quote(KEYWORD, encoding="gbk")
    with requests.Session() as session:
        session.trust_env = os.getenv("CRAWLER_DIRECT") != "1"
        response = session.get(url, timeout=30)
        response.raise_for_status()
    response.encoding = response.apparent_encoding
    products = parse_products(response.text)
    rows = [[i, f"{price:.2f}", name] for i, (price, name) in enumerate(products, 1)]
    header = ["序号", "价格", "商品名"]
    print("\t".join(header))
    for row in rows:
        print("\t".join(map(str, row)))
    OUT.mkdir(parents=True, exist_ok=True)
    with (OUT / "products.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(header)
        writer.writerows(rows)
    print(f"共提取 {len(rows)} 件商品;价格单位:元;范围:搜索结果第一页。")


if __name__ == "__main__":
    main()

实际运行结果:本次成功提取 60 件商品,完整结果保存在 results/products.csv;下表为排序后的前五件商品。

序号 价格 商品名
1 26.50 仙逆1 网络大神耳根代表作 随书包含巨型珠光纸手幅、探亲仙符
2 26.50 仙逆3 网络大神耳根代表作 随书包含禁幡(书签)、人物小卡套装(王林+李慕婉)
3 26.50 仙逆2 网络大神耳根代表作 随书包含青龙玉简(书签)、王林(马良版)人物海报
4 36.00 大的一只帆布包单肩包学生书包百搭大包包环保帆布袋 春夏季女包
5 38.00 户外大容量轻便旅行徒步背包男士电脑包旅游双肩包防水女运动书包

products

图 2:由实际运行数据制作的价格排序展示图;图中长商品名做了截断,CSV 与程序输出保留完整名称。

搜索结果的相关性:本次搜索结果中也出现了名称含“随书包含”等字样的图书。程序保留商城实际返回的数据,没有把它们伪装成书包;排序只比较返回商品的展示价格。若要进一步比较同类书包,需要在抓取后增加商品分类核验。

结果解释与核验:核对名称非空、价格可转为金额、排序为非降序,并以离线样例检验名称含嵌套标签和 HTML 实体时仍能与同块价格配对。所取价格是请求时页面展示的现价,未计入运费、优惠券或商品规格差异。图中最低价只代表本页提取结果中的最低展示价,不代表全商城最低价,也不代表不同商品完全可比。再次执行时结果可能随页面更新变化。

2)心得体会

这道题的关键是建立商品名与价格之间的一一对应关系。如果先提取整个页面的价格列表,再提取名称列表并按下标组合,一旦某个商品缺少价格,后面的数据就可能全部错位。先定位商品块再提取字段,可以让每一条记录都来自同一商品。

正则表达式中的非贪婪匹配和 re.S 很有用:前者限制匹配范围,后者使表达式能够处理跨行内容。商品名还包含关键词高亮标签,因此提取后要清除标签、还原实体,再规范空白。与此同时,正则表达式依赖网页结构,商城改版后需要重新检查模式,不能保证长期无需维护。

比价不仅是把数字排好序。页面可能存在不同规格、不同运费和不同优惠条件,因此实验输出用于比较所抓取的展示价格。使用 Decimal 保留金额精度,并明确数据的时间和范围,能使结果更容易核查。

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

1)实验代码与效果展示

实验目的:抓取指定的 福州大学新闻网“影像福大”页面,将该页面引用的 JPEG、JPG、PNG 图片集中保存到一个文件夹。

抓取范围:目标单页 HTML 中可识别的图片地址,包括普通图片、常见延迟加载属性、srcset、直接图片链接、内联样式、直接引用的外部 CSS 中的 url(),以及 HTML 内以引号包围的目标格式图片路径。不会递归进入新闻详情页,也不执行 JavaScript;不将“该页面图片”扩大为全站图片。GIF、SVG 等非题目指定格式不下载。外链 CSS 的多层 @import 和脚本运行后才生成的地址不在静态解析范围内。

实验步骤与实现说明:

  1. 请求页面,用 BeautifulSoup 解析元素,收集候选地址。
  2. 用 urljoin() 补全相对路径,以 URL 的路径部分判断扩展名,支持大写后缀及带查询参数的地址。
  3. 用集合去重,以 URL 的 SHA-256 摘要生成文件名,避免不同目录下的同名文件覆盖。
  4. 逐个下载,检查 HTTP 状态和 PNG/JPEG 文件签名,以二进制保存到 results/images/。
  5. 请求间隔 0.2 秒,逐条记录成功或失败,生成 image_manifest.csv 便于检查遗漏。

完整代码(code/task3.py):

"""作业③:下载目标页面引用的 JPEG、JPG、PNG 图片到同一文件夹。"""
import csv
import hashlib
import os
import re
import time
from pathlib import Path
from urllib.parse import urldefrag, urljoin, urlsplit

import requests
from bs4 import BeautifulSoup

URL = "https://news.fzu.edu.cn/yxfd.htm"
OUT = Path(__file__).resolve().parents[1] / "results"
IMAGE_DIR = OUT / "images"
EXTENSIONS = {".jpeg", ".jpg", ".png"}


def collect_urls(session, response):
    """收集图片标签、延迟加载、srcset、内联及外链 CSS 图片地址。"""
    soup = BeautifulSoup(response.text, "html.parser")
    base_tag = soup.find("base", href=True)
    base = urljoin(response.url, base_tag["href"]) if base_tag else response.url
    found = set()

    def add(raw, origin):
        """补全相对路径、移除片段,以完整 URL 去重。"""
        url = urldefrag(urljoin(origin, raw.strip()))[0]
        if urlsplit(url).scheme in {"http", "https"}:
            if Path(urlsplit(url).path).suffix.lower() in EXTENSIONS:
                found.add(url)

    def scan_css(text, origin):
        for match in re.finditer(r'url\(\s*[\'"]?([^\'"\)]+)', text, re.I):
            add(match.group(1), origin)

    for tag in soup.find_all(True):
        for attr in ("src", "data-src", "data-original", "data-lazy-src", "href", "poster"):
            value = tag.get(attr)
            if value:
                add(value, base)
        for attr in ("srcset", "data-srcset"):
            for part in tag.get(attr, "").split(","):
                if part.strip():
                    add(part.strip().split()[0], base)
        scan_css(tag.get("style", ""), base)
    for tag in soup.find_all("style"):
        scan_css(tag.get_text(), base)
    # 页面中的脚本也可能保存以引号包围的图片地址。
    for value in re.findall(r'[\'"]([^\'"<>\s]+\.(?:jpe?g|png)(?:\?[^\'"<>\s]*)?)[\'"]', response.text, re.I):
        add(value, base)
    for link in soup.select('link[rel~="stylesheet"][href]'):
        css_url = urljoin(base, link["href"])
        time.sleep(0.2)
        css = session.get(css_url, timeout=30)
        css.raise_for_status()
        scan_css(css.text, css.url)
    return sorted(found)


def image_extension(data):
    """检查文件签名,防止把错误页面当作图片保存。"""
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        return ".png"
    if data.startswith(b"\xff\xd8\xff"):
        return ".jpg"
    raise ValueError("响应不是 PNG 或 JPEG 图片")


def main():
    """逐个下载并记录清单;一张失败不影响其余图片。"""
    IMAGE_DIR.mkdir(parents=True, exist_ok=True)
    records = []
    with requests.Session() as session:
        session.trust_env = os.getenv("CRAWLER_DIRECT") != "1"
        response = session.get(URL, timeout=30)
        response.raise_for_status()
        response.encoding = response.apparent_encoding
        urls = collect_urls(session, response)
        if not urls:
            raise ValueError("未发现目标格式图片,请检查页面。")
        for index, url in enumerate(urls, 1):
            time.sleep(0.2)
            try:
                result = session.get(url, timeout=30, headers={"Referer": response.url})
                result.raise_for_status()
                suffix = image_extension(result.content)
                # URL 哈希避免不同目录中的同名图片互相覆盖。
                filename = hashlib.sha256(url.encode()).hexdigest()[:20] + suffix
                (IMAGE_DIR / filename).write_bytes(result.content)
                records.append([url, filename, len(result.content), "成功"])
                print(f"[{index}/{len(urls)}] 已保存 {filename} ({len(result.content)} 字节)")
            except (requests.RequestException, ValueError) as error:
                records.append([url, "", 0, f"失败:{error}"])
                print(f"[{index}/{len(urls)}] 失败:{url}:{error}")
    with (OUT / "image_manifest.csv").open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["图片URL", "文件名", "字节数", "状态"])
        writer.writerows(records)
    success = sum(row[3] == "成功" for row in records)
    print(f"发现 {len(records)} 张,成功 {success} 张,失败 {len(records) - success} 张。")
    print(f"保存目录:{IMAGE_DIR}")


if __name__ == "__main__":
    main()

实际运行结果:本次发现 50 个去重后的目标图片地址,成功下载 50 张,失败 0 张。图片均保存在 results/images/;原地址、文件名、字节数与状态见 results/image_manifest.csv。已用图像解码库检查全部成功文件可读取,避免只根据后缀判断下载成功。

downloads

图 3:从实际下载清单生成的结果图,仅展示部分记录。

image_preview

图 4:实际保存图片的部分缩略预览,图片来源为福州大学新闻网,预览保留原图比例。

异常处理:主页面或 CSS 请求失败时程序会报错,不把不完整的地址收集冒充成功;单张图片失败时继续处理其他图片并在清单中记录原因。可以检查清单后重新运行。PNG 和 JPEG 按文件签名分别保存为 .png、.jpg,.jpeg 与 .jpg 均属于 JPEG。重复执行会覆盖相同 URL 对应的文件,旧目录中已失效的历史文件不会自动删除,因此统计应以本次清单为准。

2)心得体会

下载图片需要处理的不只是网络请求。页面里的相对路径必须先补全,带查询参数的地址不能简单地对整个字符串判断是否以 .jpg 结尾。将 URL 拆成路径和查询参数后再判断扩展名,可以避免漏掉合法图片。

同一图片可能在页头、页尾或多个元素中重复出现,使用集合去重可以减少重复下载;不同路径的图片又可能具有相同文件名,因此使用 URL 摘要命名可以避免覆盖。程序同时保留原 URL 与本地文件名的映射,方便追溯下载来源。

网络请求可能失败,服务器也可能返回带有图片后缀的错误页面。检查 HTTP 状态和文件签名,并在单张失败后继续下载,可以提高程序的可靠性。最后通过下载清单与本地图片核对,才能确认实际完成情况;“发现地址数量”“下载成功数量”和“文件夹现有数量”应分别理解。

posted @ 2026-10-20 17:49  jisl  阅读(4)  评论(0)    收藏  举报