数据采集与融合博客园第一次作业

三个定向爬虫的作业记录

102402127 金若曦

这次作业要做三件事,爬大学排名、爬商城书包价格、把一个网页里的图片存到本地。三个脚本都放在 20261019 目录下,共用仓库里的 .venv,运行环境是 Python 3.12.12、requests 2.34.2、beautifulsoup4 4.15.0。

一、大学排名爬虫

1.1 代码与运行结果

目标页面是软科 2020 中国大学排名。表格在服务端返回的 HTML 里,用 requests 取回后交给 BeautifulSoup,定位 table.rk-table,再逐行取五列。代码里对表头、分页行和校名单元格做了单独处理。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业一:定向爬取软科 2020 中国大学排名,并打印排名、校名、省市、类型和总分。

目标网址:http://www.shanghairanking.cn/rankings/bcur/2020
技术路线:requests 负责发起请求,BeautifulSoup 负责解析表格。
运行方式:python3 ranking_crawler.py [--limit N]
"""

from __future__ import annotations

import argparse
import unicodedata

import requests
from bs4 import BeautifulSoup

# 目标网址和请求头。缺少浏览器 UA 时,站点会返回一个没有排名表格的简化页面。
URL = "http://www.shanghairanking.cn/rankings/bcur/2020"
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",
}

# 表格五列的标题、打印宽度和对齐方式。中文按两个字符宽计算,表格才不会错位。
COLUMNS = [
    ("排名", 6, "right"),
    ("学校名称", 20, "left"),
    ("省市", 8, "left"),
    ("学校类型", 10, "left"),
    ("总分", 8, "right"),
]


def fetch_html(url: str) -> str:
    """请求目标页面并返回 UTF-8 文本。

    站点响应头没有声明字符集,requests 会按 ISO-8859-1 猜测,
    直接使用 response.text 会把中文变成乱码,所以这里显式解码。
    """
    response = requests.get(url, headers=HEADERS, timeout=20)
    response.raise_for_status()
    return response.content.decode("utf-8", errors="replace")


def parse_ranking(html: str) -> list[dict[str, str]]:
    """从 HTML 中定位 rk-table 表格,逐行提取五个字段。"""
    soup = BeautifulSoup(html, "html.parser")
    table = soup.find("table", class_="rk-table")
    if table is None:
        raise RuntimeError("页面中没有找到 class 为 rk-table 的排名表格")

    records: list[dict[str, str]] = []
    for row in table.find_all("tr"):
        cells = row.find_all("td")
        if len(cells) < 5:
            continue  # 表头使用 th,分页行也不足五列,都跳过

        rank = cells[0].get_text(strip=True)
        if not rank.isdigit():
            continue  # 过滤掉表格底部的分页行

        # 校名单元格里同时有中文名和英文名,优先只取 span.name-cn。
        name_box = cells[1].find("span", class_="name-cn")
        if name_box is not None:
            name = name_box.get_text(strip=True)
        else:
            name = cells[1].get_text(" ", strip=True).split(" ")[0]

        records.append(
            {
                "rank": rank,
                "name": name,
                "province": cells[2].get_text(strip=True),
                "type": cells[3].get_text(strip=True),
                "score": cells[4].get_text(strip=True),
            }
        )
    return records


def display_width(text: str) -> int:
    """计算终端显示宽度,中日韩全角字符按两列计算。"""
    return sum(2 if unicodedata.east_asian_width(char) in "WF" else 1 for char in text)


def pad(text: str, width: int, align: str = "left") -> str:
    """按显示宽度补空格,使中英文混排的表格能够对齐。"""
    space = " " * max(0, width - display_width(text))
    return space + text if align == "right" else text + space


def print_table(records: list[dict[str, str]], limit: int = 0) -> None:
    """打印表头和数据行,limit 大于 0 时只打印前 limit 名。"""
    header = "  ".join(pad(title, width, align) for title, width, align in COLUMNS)
    print(header.rstrip())
    print("-" * 62)

    rows = records[:limit] if limit > 0 else records
    for item in rows:
        values = [item["rank"], item["name"], item["province"], item["type"], item["score"]]
        line = "  ".join(
            pad(value, width, align) for value, (_, width, align) in zip(values, COLUMNS)
        )
        print(line.rstrip())
    print("-" * 62)


def main() -> None:
    parser = argparse.ArgumentParser(description="爬取软科 2020 中国大学排名")
    parser.add_argument("--limit", type=int, default=0, help="只打印前 N 名,0 表示打印当前页全部记录")
    args = parser.parse_args()

    print(f"目标网址:{URL}")
    html = fetch_html(URL)
    records = parse_ranking(html)

    print(f"页面大小:{len(html)} 个字符")
    print(f"解析到排名记录:{len(records)} 条(服务端返回的第 1 页)")
    print()
    print_table(records, args.limit)
    print("说明:排名页由前端分页,HTML 中一次只包含当前页的 30 条记录。")


if __name__ == "__main__":
    main()

运行命令是 python3 ranking_crawler.py。终端打印了 30 条记录,前三名是清华大学、北京大学、浙江大学。

01_ranking_site

02_ranking_terminal

1.2 作业心得

软科的页面需要带浏览器 UA,UA 不对时返回的内容里没有 rk-table。代码里固定使用 Chrome 124 的 UA,拿到 34 万字符的 HTML 后再交给 BeautifulSoup。另一个坑是编码,响应头没有写字符集,requests 默认按 ISO-8859-1 解码,中文会变成乱码。显式用 UTF-8 解码 response.content 之后,校名才正常。

第二个坑在“页面里有多少数据”。浏览器上显示 567 所学校,HTML 里只有 30 行,翻页是前端做的,地址栏不会变。我试过 ?page=2 这类参数,返回的还是第一页。

解析表格本身也有细节。表头用的是 th,底部分页行也是 td,但第一列是页码而不是数字,所以用 rank.isdigit() 过滤。这一步不能省。校名单元格里同时有中文名和英文名,直接取文本会把英文名一起带出来,改用 span.name-cn 只取中文名。打印时中文占两列,用 east_asian_width 算宽度,表格才能对齐。

二、商城书包比价爬虫

2.1 代码与运行结果

商城选的是当当网。搜索“书包”的地址是 http://search.dangdang.com/,关键词要按 GBK 编码,page_size=60 时一页返回 60 个商品块。脚本用 requests 取回 HTML,再用正则先按 <p class="name"> 切块,然后在块内取 title 和 price_n。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业二:用 requests 和 re 写一个书包比价定向爬虫。

目标商城:当当网( http://www.dangdang.com )
搜索接口:http://search.dangdang.com/?key=<GBK 编码的关键词>&act=input&page_index=<页码>&page_size=60
输出字段:序号、价格、商品名(按价格从低到高排序)
运行方式:python3 dangdang_crawler.py [--limit N]
"""

from __future__ import annotations

import argparse
import csv
import html
import re
import time
import urllib.parse
from pathlib import Path

import requests

# 搜索入口、关键词和每页条数。page_size=60 时,一页正好返回 60 个商品块。
BASE_URL = "http://search.dangdang.com/"
KEYWORD = "书包"
PAGE_SIZE = 60
DEFAULT_LIMIT = 60
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": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.9",
}

# 每个商品块以 <p class="name"> 开头,块内同时包含商品名和价格。
ITEM_RE = re.compile(
    r'<p class="name"[^>]*>\s*<a[^>]*title="(?P<name>.*?)"'
    r'.*?<p class="price\s*"[^>]*>\s*<span class="price_n">(?P<price>.*?)</span>',
    re.S,
)
TAG_RE = re.compile(r"<[^>]+>")
PRICE_RE = re.compile(r"\d+(?:\.\d+)?")
# 搜索“书包”时,图书文案里的“随书包含”也会被高亮成关键词,这里按常见图书词排掉。
NOISE_WORDS = ("随书包含", "书签", "海报", "小说", "图书", "读物")

# 结果目录,和脚本放在同一级,便于提交作业时一起打包。
RESULT_DIR = Path(__file__).resolve().parent / "artifacts" / "results"


def build_url(keyword: str, page_index: int, page_size: int = PAGE_SIZE) -> str:
    """拼出搜索地址。当当按 GBK 识别中文关键词,所以要先编码再转百分号。"""
    params = {
        "key": keyword.encode("gbk"),
        "act": "input",
        "page_index": str(page_index),
        "page_size": str(page_size),
    }
    return BASE_URL + "?" + urllib.parse.urlencode(params)


def fetch_page(url: str) -> str:
    """请求一页搜索结果并返回 GBK 文本,失败时最多重试三次。"""
    last_error: Exception | None = None
    for attempt in range(1, 4):
        try:
            response = requests.get(url, headers=HEADERS, timeout=20)
            response.raise_for_status()
            # 响应头写的是 gb2312,GB18030 是它的超集,能多认一些生僻字。
            return response.content.decode("gb18030", errors="ignore")
        except requests.RequestException as error:
            last_error = error
            print(f"第 {attempt} 次请求失败:{error}")
            if attempt < 3:
                time.sleep(2 * attempt)
    raise RuntimeError(f"请求失败:{url}") from last_error


def clean_text(raw: str) -> str:
    """去掉高亮标签、还原 HTML 实体,并压掉多余空白。"""
    text = TAG_RE.sub("", raw)
    return " ".join(html.unescape(text).split()).strip()


def to_price(raw: str) -> float:
    """把 ¥146.00 这类价格文本转换成浮点数,没有数字时返回 0。"""
    match = PRICE_RE.search(clean_text(raw))
    return float(match.group()) if match else 0.0


def parse_page(
    html_text: str, keyword: str, limit: int
) -> tuple[list[dict[str, object]], int]:
    """用正则从一页 HTML 中抽取商品名和价格。

    先按 <p class="name"> 把整页切成若干商品块,再在块内匹配价格,
    避免某个商品没有价格时,正则一路吞到下一个商品块。
    搜索页会把“随书包含”里的“书包”也高亮,所以再按关键词过滤一次标题。
    """
    blocks = re.findall(r'<p class="name".*?(?=<p class="name"|$)', html_text, re.S)
    items: list[dict[str, object]] = []
    skipped = 0

    for block in blocks:
        match = ITEM_RE.search(block)
        if match is None:
            skipped += 1
            continue
        name = clean_text(match.group("name"))
        price = to_price(match.group("price"))
        if (
            not name
            or price <= 0
            or keyword not in name
            or any(word in name for word in NOISE_WORDS)
        ):
            skipped += 1
            continue
        items.append({"name": name, "price": price})
        if len(items) >= limit:
            break
    return items, skipped


def crawl(keyword: str, limit: int) -> list[dict[str, object]]:
    """逐页抓取,直到凑够 limit 项或者某一页没有商品为止。"""
    collected: list[dict[str, object]] = []
    page_index = 1
    while len(collected) < limit:
        url = build_url(keyword, page_index)
        html_text = fetch_page(url)
        page_items, skipped = parse_page(html_text, keyword, limit - len(collected))
        print(
            f"第 {page_index} 页  HTTP 200  页面长度 {len(html_text)} 字符  "
            f"解析出 {len(page_items)} 项"
            + (f"  跳过无报价或无关条目 {skipped} 条" if skipped else "")
        )
        if not page_items:
            break
        collected.extend(page_items)
        page_index += 1
        if len(collected) < limit:
            time.sleep(1)  # 翻页前停一秒,别给服务器添麻烦
    return collected[:limit]


def display_width(text: str) -> int:
    """中文按两个字符宽计算,保证输出表格对齐。"""
    return sum(2 if ord(char) > 127 else 1 for char in text)


def pad(text: str, width: int, align: str = "left") -> str:
    """按显示宽度补空格。"""
    space = " " * max(0, width - display_width(text))
    return space + text if align == "right" else text + space


def print_table(items: list[dict[str, object]]) -> None:
    """按序号、价格、商品名三列打印比价结果。"""
    ranked = sorted(items, key=lambda item: float(item["price"]))
    name_width = 64
    print(f"{pad('序号', 4)}  {pad('价格', 9, 'right')}  {'商品名'}")
    print("-" * (4 + 2 + 9 + 2 + name_width))

    for index, item in enumerate(ranked, 1):
        name = str(item["name"])
        price = float(item["price"])
        if display_width(name) > name_width:
            name = name[: name_width - 1] + "…"
        print(f"{pad(str(index), 4)}  {pad(f'{price:.2f}', 9, 'right')}  {name}")
    print("-" * (4 + 2 + 9 + 2 + name_width))

    prices = [float(item["price"]) for item in ranked]
    print(
        f"共 {len(ranked)} 项  最低 {min(prices):.2f} 元  "
        f"最高 {max(prices):.2f} 元  均价 {sum(prices) / len(prices):.2f} 元"
    )


def save_csv(items: list[dict[str, object]], path: Path) -> None:
    """把比价结果写成带 BOM 的 CSV,Excel 打开不会乱码。"""
    path.parent.mkdir(parents=True, exist_ok=True)
    ranked = sorted(items, key=lambda item: float(item["price"]))
    with path.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(["序号", "价格", "商品名"])
        for index, item in enumerate(ranked, 1):
            writer.writerow([index, f"{float(item['price']):.2f}", item["name"]])


def main() -> None:
    parser = argparse.ArgumentParser(description="当当网书包比价爬虫")
    parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="抓取条数,默认 60")
    args = parser.parse_args()
    if args.limit <= 0:
        parser.error("--limit 必须大于 0")

    started = time.time()
    print(f"关键词:{KEYWORD}    目标商城:当当网    抓取上限:{args.limit} 项")
    print(f"搜索接口:{build_url(KEYWORD, 1)}")
    print()

    items = crawl(KEYWORD, args.limit)
    print()
    print_table(items)
    print(f"抓取 {len(items)} 项,耗时 {time.time() - started:.1f} 秒")

    csv_path = RESULT_DIR / "dangdang_bags.csv"
    save_csv(items, csv_path)
    print(f"明细已保存到 {csv_path}")


if __name__ == "__main__":
    main()

运行命令是 python3 dangdang_crawler.py,抓了 60 项,按价格从低到高排序,最低 26.50 元,最高 269.00 元,均价 103.06 元。结果同时写进了 artifacts/results/dangdang_bags.csv。

当当网书包搜索结果页

比价爬虫终端输出上半段

比价爬虫终端输出下半段

下面是这次抓到的前 10 条,价格和商品名来自 CSV。

序号 价格 商品名
1 26.50 迪士尼幼儿园书包大班女童流沙卡通可爱女孩学前班儿童宝宝双肩包
2 36.00 新款初中生书包女撞色百搭可手提学生补习包男大容量校园双肩背包
3 39.99 儿童书包护脊减负大容量2026新款男生一二三到六年级男孩超轻书包
4 54.80 街拍书包女韩版原宿ulzzang 高中学生校园百搭简约双肩包潮酷背包
5 55.00 迪卡侬旗舰店儿童学生书包轻便户外运动背包男一年级女双肩包KIDD
6 58.00 14寸双肩背包男大容量初高中大学生书包女简约帆布通勤电脑包情侣
7 58.00 七星狐2018新款小学生1-6年级书包男女孩儿童双肩背包LOGO定制
8 59.30 迪士尼儿童书包小学生男童一二三到六年级女孩蜘蛛侠背包护脊减负
9 59.99 书包小学生女生大容量儿童护脊女孩三到六新款一年级轻便男童
10 62.00 书包男女生 小学生男女6-14岁男女童大容量双肩背包轻便休闲儿童书包开学书包简约日韩风格初中生书包

2.2 作业心得

当当的搜索页是 GBK,关键词要先编码再拼进 URL。我先用 keyword.encode("gbk") 得到字节串,再交给 urlencode 转百分号,这样“书包”才会变成 %CA%E9%B0%FC。按 UTF-8 也能返回页面,但页面本身用 GBK 解码,关键词按 GBK 编码更符合接口。

正则的第一个问题是跨块匹配。整页直接用一个 .*? 从商品名找到价格,遇到没有价格的商品时会一路吞到下一个商品块,价格就配错了。我改成先按 <p class="name"> 把页面切成块,再在每块内部匹配,跳过没有价格的条目。价格就不会串到下一块。商品名里的 <font> 高亮标签和价格里的 &yen; 实体也要处理,html.unescape 比手写替换表省事。

“书包”这个关键词会命中图书文案里的“随书包含”,因为“书”和“包”正好挨在一起,当当也会把这两个字高亮。第一版结果里混进了《仙逆》的小说,价格 26.50 元,看起来跟书包差不多。我加了一组图书类噪声词,把“随书包含”“书签”“海报”这类标题排掉。这个办法不通用,换关键词就要重新看数据,但它比硬编码商品 ID 稳妥。

比价结果按价格升序输出,序号、价格、商品名 三列和题目要求一致。翻页之间停了 1 秒,60 条抓完大约 2.5 秒。商城页面会变,这次的价格只能代表抓取时的快照。

三、网页图片批量下载

3.1 代码与运行结果

目标网页是福州大学新闻网“影像福大”栏目 https://news.fzu.edu.cn/yxfd.htm。脚本用 requests 取回页面,BeautifulSoup 找出所有 img,从 orisrc、data-original、data-src、src 里取地址,补全相对路径后只保留 .jpg、.jpeg、.png 三种后缀。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""作业三:爬取给定网页内所有 JPEG、JPG、PNG 图片并保存到本地文件夹。

目标网页:https://news.fzu.edu.cn/yxfd.htm
技术路线:requests 负责下载,BeautifulSoup 负责收集 img 标签,
          再用 urllib.parse 把相对地址补全。
运行方式:python3 image_crawler.py [--url 网页地址] [--out 保存目录]
"""

from __future__ import annotations

import argparse
import csv
import re
import time
import urllib.parse
from pathlib import Path

import requests
from bs4 import BeautifulSoup

# 目标网页和输出目录。输出目录默认放在脚本旁边,方便和代码一起提交。
TARGET_URL = "https://news.fzu.edu.cn/yxfd.htm"
DEFAULT_OUTPUT = Path(__file__).resolve().parent / "downloaded_images"
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/",
}
# 题目只要求这三种格式,扩展名统一转成小写再判断。
IMAGE_SUFFIXES = (".jpg", ".jpeg", ".png")


def fetch_page(url: str) -> str:
    """请求网页并返回 UTF-8 文本,页面开头带 BOM,用 utf-8-sig 去掉。"""
    response = requests.get(url, headers=HEADERS, timeout=20)
    response.raise_for_status()
    return response.content.decode("utf-8-sig", errors="replace")


def collect_image_urls(html_text: str, page_url: str) -> list[dict[str, str]]:
    """收集页面中所有 JPEG/JPG/PNG 图片地址,并按 URL 去重。

    懒加载图片的真实地址常放在 orisrc、data-original 或 data-src 中,
    所以先读这些属性,最后才读普通的 src。每个 img 标签只取一个地址。
    """
    soup = BeautifulSoup(html_text, "html.parser")
    images: list[dict[str, str]] = []
    seen: set[str] = set()

    for tag in soup.find_all("img"):
        alt = (tag.get("alt") or "").strip()
        candidates: list[str] = []
        for attr in ("orisrc", "data-original", "data-src", "src"):
            value = tag.get(attr)
            if value:
                candidates.append(value)
        if tag.get("srcset"):
            candidates.append(tag["srcset"].split(",")[0].strip().split(" ")[0])

        for candidate in candidates:
            if candidate.startswith("data:"):
                continue
            absolute = urllib.parse.urldefrag(
                urllib.parse.urljoin(page_url, candidate)
            )[0]
            path = urllib.parse.urlparse(absolute).path.lower()
            if not path.endswith(IMAGE_SUFFIXES):
                continue
            if absolute in seen:
                continue  # 同一个地址可能在页脚重复出现,只保留第一次
            seen.add(absolute)
            images.append({"url": absolute, "alt": alt})
            break
    return images


def detect_format(data: bytes) -> str | None:
    """根据文件头判断真实格式,防止把 404 页面当成图片存下来。"""
    if data.startswith(b"\xff\xd8\xff"):
        return "JPEG"
    if data.startswith(b"\x89PNG\r\n\x1a\n"):
        return "PNG"
    return None


def make_filename(index: int, url: str) -> str:
    """用序号加原文件名生成安全的本地文件名,避免重名覆盖。"""
    path = urllib.parse.unquote(urllib.parse.urlparse(url).path)
    stem = Path(path).stem
    stem = re.sub(r"[^\w.-]+", "_", stem).strip("._") or "image"
    suffix = Path(path).suffix.lower()
    return f"{index:02d}_{stem[:48]}{suffix}"


def download_images(
    images: list[dict[str, str]], output_dir: Path
) -> tuple[list[dict[str, object]], int]:
    """逐张下载图片,返回成功清单和失败数量。"""
    output_dir.mkdir(parents=True, exist_ok=True)
    records: list[dict[str, object]] = []
    failed = 0

    for index, image in enumerate(images, 1):
        url = image["url"]
        try:
            response = requests.get(url, headers=HEADERS, timeout=20)
            response.raise_for_status()
            data = response.content
            image_format = detect_format(data)
            if image_format is None:
                print(f"[{index:02d}/{len(images)}] 跳过,内容不是 JPEG/PNG:{url}")
                failed += 1
                continue

            filename = make_filename(index, url)
            (output_dir / filename).write_bytes(data)
            records.append(
                {
                    "序号": index,
                    "文件名": filename,
                    "格式": image_format,
                    "字节数": len(data),
                    "图片地址": url,
                    "alt": image["alt"],
                }
            )
            print(
                f"[{index:02d}/{len(images)}] {response.status_code} "
                f"{image_format:<4} {len(data):>8} B  {filename}"
            )
        except requests.RequestException as error:
            failed += 1
            print(f"[{index:02d}/{len(images)}] 下载失败:{error}")
        time.sleep(0.3)  # 稍微停一下,别连续请求给学校服务器造成压力

    return records, failed


def save_manifest(records: list[dict[str, object]], output_dir: Path) -> Path:
    """把下载清单写成 CSV,方便核对每张图的来源。"""
    manifest = output_dir / "manifest.csv"
    with manifest.open("w", encoding="utf-8-sig", newline="") as file:
        writer = csv.DictWriter(
            file, fieldnames=["序号", "文件名", "格式", "字节数", "图片地址", "alt"]
        )
        writer.writeheader()
        writer.writerows(records)
    return manifest


def main() -> None:
    parser = argparse.ArgumentParser(description="下载网页内所有 JPEG/JPG/PNG 图片")
    parser.add_argument("--url", default=TARGET_URL, help="目标网页地址")
    parser.add_argument("--out", default=str(DEFAULT_OUTPUT), help="图片保存目录")
    args = parser.parse_args()

    output_dir = Path(args.out)
    print(f"目标网页:{args.url}")
    html_text = fetch_page(args.url)
    images = collect_image_urls(html_text, args.url)
    print(f"页面大小:{len(html_text)} 个字符")
    print(f"发现 JPEG/JPG/PNG 图片:{len(images)} 个地址")
    print()

    records, failed = download_images(images, output_dir)
    manifest = save_manifest(records, output_dir)
    jpeg_count = sum(1 for item in records if item["格式"] == "JPEG")
    png_count = sum(1 for item in records if item["格式"] == "PNG")

    print()
    print(
        f"下载完成:成功 {len(records)} 张(JPEG {jpeg_count} 张,PNG {png_count} 张),"
        f"失败 {failed} 张"
    )
    print(f"保存目录:{output_dir}")
    print(f"下载清单:{manifest}")


if __name__ == "__main__":
    main()

这次一共发现 19 个图片地址,下载成功 19 张,其中 JPEG 4 张、PNG 15 张,保存在 downloaded_images 目录。每张图的来源、格式和字节数写进了 manifest.csv。

影像福大页面

图片下载终端输出

下载图片内容图预览

3.2 作业心得

这个页面的图片没有全放在 src 里。内容图的真实地址在 orisrc,另外一些图用了 data-original 和 data-src,只读 src 会漏掉一部分。还有地址是 /__local/...jpg?e=.png 这种带查询串的形式,判断后缀时不能直接对整条 URL 用 endswith,要先取 urlparse(url).path。相对地址用 urljoin 补全,页脚重复出现的 logo 用集合去重。路径要对。

下载时我没有只看扩展名。服务器出错时可能返回一个 HTML 页面,扩展名仍然是 .jpg,直接保存会得到打不开的假图片。脚本读了文件头,JPEG 是 FF D8 FF,PNG 是 89 50 4E 47,对不上就跳过。这个检查在这次运行里没有触发,但留着比事后翻文件夹可靠。

19 张图里,9 张是内容封面,7 张是站点图标,3 张是页脚小图标。题目要求“所有 JPEG、JPG、PNG”,所以站点图标也留下来了。如果只想交内容图,按尺寸过滤一下就能去掉这些小图,manifest.csv 里也能直接看出每张图来自哪里。

截图时还碰到一件事。无头浏览器打开页面后,有一半封面没有加载,终端里的 requests 却已经把 19 个地址都拿到了。原因是页面用了懒加载,浏览器要滚动到位置才去请求。后来我先滚到底再回到顶部,封面才齐。这件事提醒我,爬虫看到的是 HTML 源码里的地址,浏览器看到的是渲染后的页面,两边不能混着判断。

posted @ 2026-10-20 18:53  山冇木兮木有枝  阅读(3)  评论(0)    收藏  举报