数据采集第一次作业

数据采集第一次作业

102402132 邱福铭

作业①:requests+BeautifulSoup 爬取软科中国大学排名

1)实验实现

实验说明:用 requests 获取榜单页 HTML,BeautifulSoup 定位表格行,提取排名、学校名称、省市、学校类型、总分,并按列打印。

"""作业①:爬取软科2020中国大学排名(requests + BeautifulSoup)。"""

import unicodedata

import requests
from bs4 import BeautifulSoup
from requests.exceptions import RequestException

URL = "https://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/131.0.0.0 Safari/537.36"
    ),
    "Referer": "https://www.shanghairanking.cn/",
}


def fetch_html(url: str) -> str:
    """请求榜单页面,返回解码后的 HTML 文本。"""
    try:
        response = requests.get(url, headers=HEADERS, timeout=20)
        response.raise_for_status()
        # 站点响应头未声明字符集,按页面实际编码解码,避免中文乱码。
        response.encoding = response.apparent_encoding
        return response.text
    except RequestException as exc:
        raise SystemExit(f"页面请求失败:{exc}") from exc


def parse_ranking(html: str) -> list[tuple[str, str, str, str, str]]:
    """从表格中提取排名、学校名称、省市、学校类型和总分。"""
    soup = BeautifulSoup(html, "html.parser")
    records: list[tuple[str, str, str, str, str]] = []

    for row in soup.select("table tbody tr"):
        cells = row.find_all("td")
        if len(cells) < 5:
            continue
        name_tag = cells[1].select_one(".name-cn")
        if name_tag is None:  # 跳过表头、空行等非学校记录
            continue

        rank = cells[0].get_text(" ", strip=True)
        name = name_tag.get_text(" ", strip=True)
        province = cells[2].get_text(" ", strip=True)
        category = cells[3].get_text(" ", strip=True)
        score = cells[4].get_text(" ", strip=True)
        records.append((rank, name, province, category, score))

    return records


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


def pad(text: str, width: int) -> str:
    """在文本右侧补空格,使各列在终端中对齐。"""
    return text + " " * max(1, width - display_width(text))


def print_ranking(records: list[tuple[str, str, str, str, str]]) -> None:
    """按“排名 / 学校名称 / 省市 / 学校类型 / 总分”格式打印。"""
    print(pad("排名", 6) + pad("学校名称", 20) + pad("省市", 8)
          + pad("学校类型", 10) + "总分")
    print("-" * 58)
    for rank, name, province, category, score in records:
        print(pad(rank, 6) + pad(name, 20) + pad(province, 8)
              + pad(category, 10) + score)


def main() -> None:
    """抓取页面、解析数据并输出。"""
    html = fetch_html(URL)
    records = parse_ranking(html)
    if not records:
        raise SystemExit("未解析到排名数据,请检查页面结构是否变化。")

    print(f"数据来源:{URL}")
    print(f"共解析到 {len(records)} 条排名记录。\n")
    print_ranking(records)


if __name__ == "__main__":
    main()

运行结果(节选):

数据来源:https://www.shanghairanking.cn/rankings/bcur/2020
共解析到 30 条排名记录。

排名  学校名称            省市    学校类型  总分
----------------------------------------------------------
1     清华大学            北京    综合      852.5
2     北京大学            北京    综合      746.7
3     浙江大学            浙江    综合      649.2
4     上海交通大学        上海    综合      625.9
5     南京大学            江苏    综合      566.1
……(第 6~30 条见下图)

图1-作业①运行结果

图 1 作业①运行结果

2)心得体会

这次主要卡在编码和取字段。页面响应头没给字符集,直接 resp.text 会乱码,改成 apparent_encoding 才正常。学校名那一列还带英文名和“双一流”标签,直接取 td 会把它们全混在一起,最后用 .name-cn 只取中文名。终端对齐也调了一会儿,中文按两列计算才整齐。

另外发现页面首屏只有 30 条,后面是滚动加载的。本次作业打印前 30 名;如果要全量,得继续找数据接口或用浏览器自动化。

作业②:requests+re 正则 商城书包比价爬虫

1)实验实现

实验说明:用 requests 获取当当“书包”搜索页,正则表达式按商品卡片提取名称和价格,再按序号、价格、商品名输出。

"""作业②:当当网“书包”搜索结果比价爬虫(requests + re)。"""

import html
import re
from urllib.parse import quote

import requests
from requests.exceptions import RequestException

KEYWORD = "书包"
URL = "https://search.dangdang.com/?key=" + quote(KEYWORD)
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/131.0.0.0 Safari/537.36"
    ),
    "Referer": "https://search.dangdang.com/",
}

# 每个商品是一个 <li ddt-pit="...">;用非贪婪匹配截取单张商品卡片。
CARD_RE = re.compile(r'<li\s+ddt-pit="\d+"[^>]*>(.*?)</li>', re.S)
NAME_RE = re.compile(r'<p\s+class="name"[^>]*>\s*<a\s+title="([^"]+)"', re.S)
PRICE_RE = re.compile(
    r'<span\s+class="price_n"[^>]*>\s*&yen;\s*'
    r'([0-9]+(?:\.[0-9]{1,2})?)\s*</span>',
    re.S,
)


def fetch_html(url: str) -> str:
    """请求搜索页,并按当当网实际使用的 GB18030 解码。"""
    try:
        response = requests.get(url, headers=HEADERS, timeout=20)
        response.raise_for_status()
        return response.content.decode("gb18030", errors="replace")
    except RequestException as exc:
        raise SystemExit(f"搜索页请求失败:{exc}") from exc


def parse_products(page: str) -> list[tuple[str, str]]:
    """用正则提取(商品名称,价格),并去掉重复项。"""
    products: list[tuple[str, str]] = []
    seen: set[tuple[str, str]] = set()

    for card in CARD_RE.findall(page):
        name_match = NAME_RE.search(card)
        price_match = PRICE_RE.search(card)
        if not name_match or not price_match:
            continue  # 广告位、无货商品等缺少名称或价格,跳过

        name = re.sub(r"\s+", " ", html.unescape(name_match.group(1))).strip()
        price = price_match.group(1)
        if name and (name, price) not in seen:
            products.append((name, price))
            seen.add((name, price))

    return products


def print_products(products: list[tuple[str, str]]) -> None:
    """按“序号 / 价格 / 商品名”打印,并给出名称含书包的最低价 5 项。"""
    print(f"商城:当当网    搜索关键词:{KEYWORD}")
    print(f"搜索页:{URL}")
    print(f"共提取到 {len(products)} 件商品。\n")

    print(f"{'序号':<6}{'价格/元':<10}商品名")
    print("-" * 88)
    for index, (name, price) in enumerate(products, start=1):
        print(f"{index:<6}{price:<10}{name}")

    # 搜索结果可能混入“随书包含……”等书名,比价时只统计名称含“书包”的商品。
    bags = [item for item in products
            if KEYWORD in item[0] and "随书" not in item[0]]
    cheapest = sorted(bags, key=lambda item: float(item[1]))[:5]
    print("\n名称含“书包”的价格最低 5 件商品:")
    for name, price in cheapest:
        print(f"¥{price:>7}  {name}")


def main() -> None:
    """抓取、解析并输出比价结果。"""
    page = fetch_html(URL)
    products = parse_products(page)
    if not products:
        raise SystemExit("未解析到商品,请检查页面结构或是否出现验证页面。")
    print_products(products)


if __name__ == "__main__":
    main()

运行结果(节选):

商城:当当网    搜索关键词:书包
搜索页:https://search.dangdang.com/?key=%E4%B9%A6%E5%8C%85
共提取到 60 件商品。

序号    价格/元      商品名
----------------------------------------------------------------------------------------
1     89.99     书包女新款初中生高中学生大容量轻便减负护脊简约学院风双肩包
2     224.00    儿童书包护脊减负大容量2026新款男生一二三到六年级男孩超轻书包
3     39.99     书包幼儿园女孩 潮小兔子书包3-6岁大中小班男童儿童卡通可爱韩版
4     99.80     书包男女生 1-6年级大容量新款小学生耐磨透气书包儿童双肩包小学生男女孩双肩书包
5     75.80     软书包女孩小学生一二三到六年级可爱卡通超轻便减负儿童双肩背包
6     116.90    UR生日礼物可爱小熊通勤双肩包新款潮女大学生背包大容量书包
7     132.00    迪士尼幼儿园书包大班女童流沙卡通可爱女孩学前班儿童宝宝双肩包
8     26.50     仙逆1 网络大神耳根代表作 随书包含巨型珠光纸手幅、探亲仙符
……(第 9~60 件见下图)

名称含“书包”的价格最低 5 件商品:
¥  24.90  多用帆布包女韩版新款大容量学生上课斜挎书包百搭补习手提袋单肩
¥  36.00  大的一只帆布包单肩包学生书包百搭大包包环保帆布袋 春夏季女包
¥  38.00  户外大容量轻便旅行徒步背包男士电脑包旅游双肩包防水女运动书包
¥  39.99  书包幼儿园女孩 潮小兔子书包3-6岁大中小班男童儿童卡通可爱韩版
¥  49.00  爱背书包的小怪兽

图2-作业②运行结果上

图 2 作业②运行结果(第 1~20 件)

图3-作业②运行结果下

图 3 作业②运行结果(第 21~60 件及最低价)

2)心得体会

用 re 比 BeautifulSoup 难写。商品卡片跨很多行,不加 re.S 就匹配不到;当当页面是 GB18030,用 UTF-8 解码会乱码。最开始把价格和名称分开匹配,偶尔会串到下一件商品,后来先按 <li ddt-pit> 切出卡片,再在卡片里找名称和价格,结果才稳定。

搜索页还混进了“随书包含”的书名,算最低价时会被带到前面,所以比价时把这类条目过滤掉了。正则写起来快,但页面一改版就得跟着改,适合这种结构固定的单页练习。

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

1)实验实现

实验说明:抓取福大新闻“学院风采”页面,筛选 JPG、JPEG、PNG 图片,拼接完整地址后保存到本地文件夹。

"""作业③:下载福州大学新闻网“学院风采”页面中的 JPG/JPEG/PNG 图片。"""

from hashlib import md5
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit

import requests
from bs4 import BeautifulSoup
from requests.exceptions import RequestException

INDEX_URL = "https://news.fzu.edu.cn/yxfd.htm"
OUTPUT_DIR = Path(__file__).resolve().parent / "images_fzu"
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/131.0.0.0 Safari/537.36"
    ),
    "Referer": INDEX_URL,
}
TARGET_SUFFIXES = {".jpg", ".jpeg", ".png"}
IMAGE_SIGNATURES = (b"\xff\xd8\xff", b"\x89PNG\r\n\x1a\n")


def fetch_html(url: str) -> str:
    """请求页面并返回正确解码的 HTML。"""
    try:
        response = requests.get(url, headers=HEADERS, timeout=20)
        response.raise_for_status()
        response.encoding = response.apparent_encoding
        return response.text
    except RequestException as exc:
        raise SystemExit(f"页面请求失败:{exc}") from exc


def collect_image_urls(html: str, base_url: str) -> list[str]:
    """提取页面中所有 JPG/JPEG/PNG 图片的绝对地址并去重。"""
    soup = BeautifulSoup(html, "html.parser")
    urls: list[str] = []
    seen: set[str] = set()

    for image in soup.find_all("img"):
        src = image.get("src") or image.get("data-src")
        if not src or src.startswith("data:"):
            continue
        absolute_url = urljoin(base_url, src.strip())
        suffix = Path(urlsplit(absolute_url).path).suffix.lower()
        if suffix in TARGET_SUFFIXES and absolute_url not in seen:
            seen.add(absolute_url)
            urls.append(absolute_url)

    return urls


def download_images(urls: list[str], output_dir: Path) -> tuple[int, int]:
    """逐张下载图片,返回(成功数,失败数)。"""
    output_dir.mkdir(parents=True, exist_ok=True)
    success = 0
    failed = 0
    used_names: set[str] = set()
    content_hashes: set[str] = set()

    for index, url in enumerate(urls, start=1):
        try:
            response = requests.get(url, headers=HEADERS, timeout=20)
            response.raise_for_status()
            content = response.content

            # 按文件头判断真实格式,防止把错误页当作图片保存。
            if not content.startswith(IMAGE_SIGNATURES):
                raise ValueError("响应内容不是有效的 JPEG/PNG 图片")

            digest = md5(content).hexdigest()
            if digest in content_hashes:  # 同一张图片在页面上可能重复出现
                print(f"[{index:02d}/{len(urls)}] 跳过重复图片:{url}")
                continue
            content_hashes.add(digest)

            original_name = unquote(Path(urlsplit(url).path).name)
            filename = f"{index:02d}_{original_name}"
            if filename in used_names:
                filename = f"{index:02d}_{digest[:8]}_{original_name}"
            used_names.add(filename)

            (output_dir / filename).write_bytes(content)
            success += 1
            print(f"[{index:02d}/{len(urls)}] 已保存 {filename}")
        except (RequestException, OSError, ValueError) as exc:
            failed += 1
            print(f"[{index:02d}/{len(urls)}] 下载失败:{url}({exc})")

    return success, failed


def main() -> None:
    """抓取图片地址并批量保存到 images_fzu 文件夹。"""
    html = fetch_html(INDEX_URL)
    image_urls = collect_image_urls(html, INDEX_URL)
    print(f"页面:{INDEX_URL}")
    print(f"共发现 {len(image_urls)} 个 JPG/JPEG/PNG 图片地址。\n")

    success, failed = download_images(image_urls, OUTPUT_DIR)
    print(f"\n下载完成:成功 {success} 张,失败 {failed} 张。")
    print(f"保存位置:{OUTPUT_DIR}")


if __name__ == "__main__":
    main()

运行结果(节选):

页面:https://news.fzu.edu.cn/yxfd.htm
共发现 19 个 JPG/JPEG/PNG 图片地址。

[01/19] 已保存 01_top_search.png
[02/19] 已保存 02_logo.png
[03/19] 已保存 03_footer_logo.png
……
[19/19] 已保存 19_9D5EF45533E69056CF06F1A0115_9201CE3A_198.png

下载完成:成功 19 张,失败 0 张。
保存位置:D:\数据融合\作业1\images_fzu

图4-作业③终端输出

图 4 作业③运行结果:成功下载 19 张

图5-作业③图片文件夹

图 5 images_fzu 文件夹中的 19 张图片

2)心得体会

图片下载本身不难,麻烦的是地址处理。src 有相对路径,也有 /__local/... 这种根路径,直接请求会失败,要用 urljoin 拼完整;有的地址后面带 ?e=.png,判断后缀时得先取 urlsplit(url).path。

保存时用 wb 写二进制,另外加了文件头校验和 MD5 去重,避免把错误页或重复图存下来。本次 19 张都成功,其中包括 Logo 和小图标,因为题目要求的是所有 JPG/PNG。

小结:三个任务分别练了 BeautifulSoup 解析表格、re 提取文本和二进制图片下载。代码都能跑通,也踩了编码、相对路径和页面结构的坑,比只看教材印象深。

posted @ 2026-10-22 17:21  friberg  阅读(5)  评论(0)    收藏  举报