1024021116蔡靖熹-数据采集第一次作业

一、作业①:基于 requests 与 BeautifulSoup 的大学排名爬虫

1.1 作业代码与运行截图

实验目标: 使用 requests 请求 软科中国大学排名(2020),使用 BeautifulSoup 解析页面中的表格数据,并在终端输出排名、学校名称、省市、学校类型和总分。另将结果保存为 CSV 文件,便于后续查看。

实现思路: 先向目标页面发送 HTTP GET 请求;请求成功后,以 html.parser 解析返回的 HTML;利用 CSS 选择器定位表格行,提取对应列的文本;最后输出到终端并写入 CSV。对于网络失败或页面结构变化的情况,给出提示,避免误认为抓取成功。

源代码:01_university_ranking.py

import csv
from pathlib import Path

import requests
from bs4 import BeautifulSoup

URL = "https://www.shanghairanking.cn/rankings/bcur/2020"
BASE_DIR = Path(__file__).resolve().parent.parent
OUTPUT_FILE = BASE_DIR / "data" / "university_ranking.csv"


def fetch_page(url):
    """请求目标网页并返回 HTML 文本。"""
    headers = {
        "User-Agent": "Mozilla/5.0",
        "Accept-Language": "zh-CN,zh;q=0.9",
    }
    response = requests.get(url, headers=headers, timeout=20)
    response.raise_for_status()
    response.encoding = "utf-8"
    return response.text


def parse_ranking(html):
    """解析排名表格,提取要求的五个字段。"""
    soup = BeautifulSoup(html, "html.parser")
    universities = []
    rows = soup.select("table.rk-table tbody tr")

    for row in rows:
        cells = row.find_all("td")
        if len(cells) < 5:
            continue

        rank_tag = cells[0].select_one(".ranking")
        name_tag = cells[1].select_one(".name-cn")
        rank = (rank_tag.get_text(strip=True)
                if rank_tag else cells[0].get_text(strip=True))
        name = name_tag.get_text(strip=True) if name_tag else ""
        province = cells[2].get_text(strip=True)
        school_type = cells[3].get_text(strip=True)
        score = cells[4].get_text(strip=True)

        if rank and name:
            universities.append([rank, name, province, school_type, score])
    return universities


def print_ranking(universities):
    """将排名信息输出到终端。"""
    print(f"{'排名':<6} {'学校名称':<16} {'省市':<8} {'类型':<8} {'总分'}")
    print("-" * 65)
    for rank, name, province, school_type, score in universities:
        print(f"{rank:<6} {name:<16} {province:<8} {school_type:<8} {score}")


def save_csv(universities):
    """保存抓取结果,采用 utf-8-sig 方便表格软件打开。"""
    OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
    with OUTPUT_FILE.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["排名", "学校名称", "省市", "学校类型", "总分"])
        writer.writerows(universities)
    print(f"\nCSV 已保存:{OUTPUT_FILE}")


def main():
    try:
        html = fetch_page(URL)
        universities = parse_ranking(html)
        if not universities:
            print("未解析到排名数据,请检查网页结构。")
            return
        print_ranking(universities)
        print(f"\n成功爬取 {len(universities)} 所大学。")
        save_csv(universities)
    except requests.RequestException as error:
        print(f"网络请求失败:{error}")


if __name__ == "__main__":
    main()

运行结果: 程序成功输出排名前 30 所大学,包含五项要求的字段。前三名为清华大学(852.5 分)、北京大学(746.7 分)、浙江大学(649.2 分)。此次抓取的是当前页面展示的 30 条记录,没有实现全部分页抓取。

运行截图:

01_ranking

1.2 作业心得

这次实验让我熟悉了从网页请求到数据提取的基本流程。requests 负责获取网页内容,BeautifulSoup 负责把 HTML 转换为可以按标签和 CSS 选择器查询的结构。解析表格时不能仅依赖固定的文本位置,还需要检查 td 数量,并针对学校名称等字段选择对应标签,以减少页面结构差异带来的错误。

我也注意到终端中文字符的显示宽度与英文字符不同,仅使用字符串宽度格式化时,表格列可能不完全对齐,但不影响数据内容。后续可以改用表格格式化工具输出,或直接使用 CSV 查看。另一个改进方向是遍历排名的所有分页,获取更完整的数据集。

二、作业②:基于 requests 与 re 的书包商品比价爬虫

2.1 作业代码与运行截图

实验目标: 以当当网搜索结果为数据源,以“书包”为检索关键词,使用 requests 获取网页、re 正则表达式提取商品名称和价格,并按价格升序显示,同时保存 CSV 文件。

实现思路: 先下载搜索结果 HTML;通过正则表达式识别每个商品对应的 HTML 区块,再在同一商品区块中分别提取价格和名称,避免商品名称与价格错位;随后使用 Decimal 表示价格、去重并排序。保存网页原文用于出现匹配失败时进行排查。

源代码:02_schoolbag_prices.py

import csv
import html
import re
from decimal import Decimal
from pathlib import Path

import requests

URL = "https://search.dangdang.com/?key=%CA%E9%B0%FC&act=input"
BASE_DIR = Path(__file__).resolve().parent.parent
OUTPUT_FILE = BASE_DIR / "data" / "schoolbag_prices.csv"
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 Chrome/120.0 Safari/537.36"
    )
}


def fetch_page():
    """发送 GET 请求,获取书包搜索结果页。"""
    response = requests.get(URL, headers=HEADERS, timeout=20)
    response.raise_for_status()
    response.encoding = "gb18030"
    return response.text


def parse_products(page_html):
    """使用正则表达式提取商品名称和售价。"""
    item_pattern = re.compile(
        r'<li\b[^>]*\bddt-pit="[^"]*"[^>]*>.*?</li>', re.S | re.I
    )
    price_pattern = re.compile(
        r'class=["\'][^"\']*\bprice_n\b[^"\']*["\']'
        r'[^>]*>\s*(?:&yen;|&#165;|¥)?\s*'
        r'(\d+(?:\.\d{1,2})?)',
        re.S | re.I
    )
    name_pattern = re.compile(
        r'<a\b(?=[^>]*\btitle=["\']([^"\']+)["\'])'
        r'(?=[^>]*\bname=["\']itemlist-title["\'])'
        r'[^>]*>',
        re.S | re.I
    )

    products = []
    seen = set()
    for item in item_pattern.findall(page_html):
        price_match = price_pattern.search(item)
        name_match = name_pattern.search(item)
        if not price_match or not name_match:
            continue

        price = Decimal(price_match.group(1))
        name = html.unescape(name_match.group(1)).strip()
        if not name or (name, price) in seen:
            continue
        seen.add((name, price))
        products.append((price, name))
    return sorted(products, key=lambda product: product[0])


def print_products(products):
    """按价格升序输出商品信息。"""
    print(f"{'序号':<6}{'价格':<12}商品名称")
    print("-" * 85)
    for index, (price, name) in enumerate(products, start=1):
        print(f"{index:<6}{str(price):<12}{name}")
    print("-" * 85)
    print(f"成功提取 {len(products)} 件商品。")


def save_csv(products):
    """保存商品数据。"""
    OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
    with OUTPUT_FILE.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["序号", "价格", "商品名称"])
        for index, (price, name) in enumerate(products, start=1):
            writer.writerow([index, f"{price:.2f}", name])
    print(f"CSV 保存位置:{OUTPUT_FILE}")


def main():
    try:
        page_html = fetch_page()
        raw_file = BASE_DIR / "data" / "dangdang_schoolbag.html"
        raw_file.write_text(page_html, encoding="utf-8")
        products = parse_products(page_html)
        if not products:
            print("未提取到商品。请检查网页结构或访问限制。")
            print(f"原始 HTML 已保存:{raw_file}")
            return
        print_products(products)
        save_csv(products)
    except requests.RequestException as error:
        print(f"网络请求失败:{error}")


if __name__ == "__main__":
    main()

运行结果: 实际抓取并显示 57 条商品记录,按照价格从低到高排列。截图所示最低价格为 26.50 元,最高价格为 289.00 元。检索结果中也存在与“书包”主题相关性较低的商品,说明商城搜索结果本身可能包含泛化或混合匹配内容,不能将搜索关键词等同于严格的品类筛选条件。

运行截图:

02_products

2.2 作业心得

与第一题使用 BeautifulSoup 不同,本题重点练习了正则表达式,包括利用 re.S 跨行匹配、捕获组提取字段以及通过正则条件约束匹配范围。最大的收获是认识到商品数据需要以“单个商品区块”为提取单位,如果先分别提取所有价格和名称再用序号配对,一旦某件商品缺少字段,就可能造成整批数据错位。

在结果分析时,我发现按关键词搜索得到的列表未必全部都是目标品类。因此,价格排序只能表示抓取结果中的价格次序,不能直接用于判断同类书包的性价比。后续可以增加商品类别或标题关键词过滤、分页抓取以及异常价格排查,使比价结果更有参考价值。

三、作业③:福州大学新闻网图片批量下载

3.1 作业代码与运行截图

实验目标: 爬取 福州大学新闻网“影像福大”页面 中引用的 JPEG、JPG 或 PNG 格式图片,并将图片保存到一个文件夹中。

实现思路: 利用 requests 获取网页;利用 BeautifulSoup 查找 <img> 标签,从 src、data-src、data-original 和 srcset 等属性中提取图片链接;借助 urljoin 处理相对链接;下载图片并检查文件头,只保留 JPEG 和 PNG;以图片内容哈希命名以避免重复保存。

源代码:03_image_downloader.py

import hashlib
import time
from pathlib import Path
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup

URL = "https://news.fzu.edu.cn/yxfd.htm"
BASE_DIR = Path(__file__).resolve().parent.parent
IMAGE_DIR = BASE_DIR / "data" / "images"
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
        "AppleWebKit/537.36 Chrome/120.0 Safari/537.36"
    )
}
MAX_SIZE = 20 * 1024 * 1024


def fetch_page():
    """获取网页 HTML。"""
    response = requests.get(URL, headers=HEADERS, timeout=20)
    response.raise_for_status()
    response.encoding = response.apparent_encoding
    return response.text


def extract_image_urls(page_html):
    """从 img 元素中提取并补全图片链接。"""
    soup = BeautifulSoup(page_html, "html.parser")
    image_urls = set()

    for img in soup.find_all("img"):
        for attr in ("src", "data-src", "data-original"):
            source = img.get(attr)
            if not source:
                continue
            source = source.strip()
            if source.startswith(("data:", "blob:")):
                continue
            parsed = urlparse(urljoin(URL, source))
            if parsed.scheme in ("http", "https"):
                image_urls.add(parsed._replace(fragment="").geturl())

        srcset = img.get("srcset", "")
        for candidate in srcset.split(","):
            parts = candidate.strip().split()
            if not parts:
                continue
            source = parts[0]
            if source.startswith(("data:", "blob:")):
                continue
            parsed = urlparse(urljoin(URL, source))
            if parsed.scheme in ("http", "https"):
                image_urls.add(parsed._replace(fragment="").geturl())
    return sorted(image_urls)


def detect_image_type(data):
    """根据文件头识别 JPG 或 PNG。"""
    if data.startswith(b"\xff\xd8\xff"):
        return ".jpg"
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        return ".png"
    return None


def download_image(session, image_url, index):
    """下载、检查图片内容并保存到本地。"""
    try:
        with session.get(
            image_url, timeout=20, stream=True,
            headers={"Referer": URL}
        ) as response:
            response.raise_for_status()
            content = bytearray()
            for chunk in response.iter_content(chunk_size=8192):
                if not chunk:
                    continue
                content.extend(chunk)
                if len(content) > MAX_SIZE:
                    print(f"[跳过] 图片超过 20 MB:{image_url}")
                    return False

        image_data = bytes(content)
        extension = detect_image_type(image_data)
        if extension is None:
            print(f"[跳过] 非 JPEG/PNG:{image_url}")
            return False

        digest = hashlib.sha256(image_data).hexdigest()
        filename = f"image_{digest[:16]}{extension}"
        file_path = IMAGE_DIR / filename
        if file_path.exists():
            print(f"[重复] {filename}")
            return False

        file_path.write_bytes(image_data)
        print(f"[成功 {index}] {filename} ({len(image_data) / 1024:.1f} KB)")
        return True
    except requests.RequestException as error:
        print(f"[失败] {image_url}:{error}")
        return False


def main():
    """执行网页图片批量下载任务。"""
    IMAGE_DIR.mkdir(parents=True, exist_ok=True)
    print("=" * 60)
    print("福州大学新闻网图片爬虫")
    print(f"目标网址:{URL}")
    print("=" * 60)

    try:
        page_html = fetch_page()
    except requests.RequestException as error:
        print(f"网页请求失败:{error}")
        return

    image_urls = extract_image_urls(page_html)
    print(f"\n发现 {len(image_urls)} 个图片资源地址。\n")
    if not image_urls:
        print("未找到图片,请检查网页结构。")
        return

    success_count = 0
    with requests.Session() as session:
        session.headers.update(HEADERS)
        for index, image_url in enumerate(image_urls, start=1):
            if download_image(session, image_url, index):
                success_count += 1
            if index < len(image_urls):
                time.sleep(0.3)

    print("\n" + "=" * 60)
    print("爬取完成")
    print(f"发现资源:{len(image_urls)} 个")
    print(f"本次新下载:{success_count} 张")
    print(f"保存路径:{IMAGE_DIR}")
    print("=" * 60)


if __name__ == "__main__":
    main()

运行结果: 程序共发现 20 个图片资源地址,其中 19 张 JPEG/PNG 图片成功保存;另有 1 个资源为 loading.gif,不属于本次要求的图片格式,因此跳过。图片统一保存在 data/images/ 文件夹内。

运行截图:

03_images

3.2 作业心得

第三题让我了解到网页中的图片并不一定是完整的绝对 URL,有些图片使用相对路径,有些则通过懒加载属性提供地址。因此,爬虫除了遍历 <img> 标签,还需要补全链接并去重。下载环节也不能只根据 URL 的扩展名判断文件类型:部分链接没有扩展名,或者实际返回的是其他格式。检查图片文件头可以更可靠地筛选出 JPEG 和 PNG。

这次运行中,程序发现的 20 个资源并没有全部保存,其中 GIF 格式的加载占位图被正确排除,说明格式检测发挥了作用。同时,通过限制下载大小和适当控制请求间隔,可以降低异常资源和高频请求带来的影响。今后可以进一步扩展到 CSS 背景图、动态加载图片以及新闻详情页,但本次作业只处理指定页面中的图片引用。

posted @ 2026-10-20 16:45  tchhpk  阅读(4)  评论(1)    收藏  举报