数据采集实践第一次作业

数据采集实践第一次作业博客

本次作业使用 Python 语言,基于 requests 与 BeautifulSoup(部分配合 re 正则表达式)完成三个网络数据采集任务:
① 爬取软科2020中国大学排名并规整输出;② 爬取当当网"书包"关键词搜索结果的商品价格信息;③ 爬取指定网页中的所有 JPG/JPEG/PNG 图片并保存到文件夹。


作业①:2020 中国大学排名爬虫

1. 大学排名实验

实验目标:用 requests 和 BeautifulSoup 方法爬取软科排名网址(https://www.shanghairanking.cn/rankings/bcur/2020)的数据,屏幕打印爬取的大学排名信息,输出格式为:排名、学校名称、省市、学校类型、总分。

网页勘探:先用浏览器打开排名页,确认数据以规整表格呈现;再按 F12 查看元素结构,定位到每一行 <tr>,展开后能看到 <td> 单元格(排名、校徽、校名、省市、类型、总分),我们要的信息就在这几个单元格里。
00

00

实现思路:

  1. 使用 requests.get() 携带 User-Agent 请求网页,raise_for_status() 校验请求状态,apparent_encoding 处理编码;
  2. 使用 BeautifulSoup 的 select("tr") 遍历表格行,get_text(strip=True) 提取单元格文本;
  3. 对爬取到的原始文本进行数据清洗:过滤掉混在学校名中的英文校名和"双一流/985/211"标签,识别省市字段,从整行中兜底定位总分;
  4. 用固定列宽格式化输出表格。

完整代码(作业1_大学排名.py):

# -*- coding: utf-8 -*-
"""
作业①:2020 年中国大学排名爬虫
使用 requests + BeautifulSoup 获取并以规整表格形式输出排名。
"""

import requests
from bs4 import BeautifulSoup

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/124.0.0.0 Safari/537.36"
    )
}


def fetch_html(url):
    """请求网页并返回 HTML。"""
    response = requests.get(url, headers=HEADERS, timeout=20)
    response.raise_for_status()
    response.encoding = response.apparent_encoding
    return response.text


def parse_ranking(html):
    """从网页表格行中提取排名数据,并清理混入学校名中的英文名/标签文字。"""
    soup = BeautifulSoup(html, "html.parser")
    result = []

    for row in soup.select("tr"):
        cells = [cell.get_text(" ", strip=True) for cell in row.select("th, td")]
        if len(cells) < 4:
            continue

        # 只处理第一列为数字排名的行
        rank_text = cells[0].strip().rstrip(".")
        if not rank_text.isdigit():
            continue

        # 不同网页版本列结构不同:先按当前列位置取值
        name = cells[1] if len(cells) > 1 else ""
        province = cells[2] if len(cells) > 2 else ""
        school_type = cells[3] if len(cells) > 3 else ""
        score = cells[4] if len(cells) > 4 else ""

        # 清除常见排名标签、英文校名和其他附加信息
        for marker in ("双一流", "985", "211"):
            name = name.replace(marker, " ")
        name = " ".join(name.split())

        # 省市字段常被当前网站放在学校名同一个单元格中,尝试从文本末尾识别
        known_regions = [
            "北京", "上海", "天津", "重庆", "河北", "山西", "辽宁", "吉林",
            "黑龙江", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南",
            "湖北", "湖南", "广东", "海南", "四川", "贵州", "云南", "陕西",
            "甘肃", "青海", "台湾", "内蒙古", "广西", "西藏", "宁夏", "新疆"
        ]
        combined = " ".join(cells[1:])
        if province not in known_regions:
            found = next((region for region in known_regions if combined.endswith(region)), "")
            if found:
                province = found

        # 网页结构错位时,分数一般是纯数字或小数,尝试从整行中寻找
        if not score or not any(ch.isdigit() for ch in score):
            candidates = [x for x in cells if x.replace(".", "", 1).isdigit()]
            if len(candidates) >= 2:
                score = candidates[-1]

        if name:
            result.append((rank_text, name, province, school_type, score))

    return result


def print_table(rows):
    """以固定列宽输出表格。"""
    headers = ("排名", "学校名称", "省市", "学校类型", "总分")
    widths = (6, 30, 12, 12, 10)
    line = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
    print(line)
    print("|" + "|".join(f" {h:<{w}} " for h, w in zip(headers, widths)) + "|")
    print(line)
    for row in rows:
        values = list(row)
        # 终端表格以字符长度粗略对齐;长名称截断,避免破坏表格版式
        values[1] = values[1][:28]
        values[3] = values[3][:10]
        print("|" + "|".join(f" {str(v):<{w}} " for v, w in zip(values, widths)) + "|")
    print(line)
    print(f"共提取 {len(rows)} 条大学排名信息。")


def main():
    try:
        html = fetch_html(URL)
        rows = parse_ranking(html)
        if not rows:
            print("请求成功,但未能解析出排名表格。网页结构可能已变化。")
            print("请检查网页是否能正常打开;如果数据由 JavaScript 动态加载,需要进一步适配。")
            return
        print_table(rows)
    except requests.RequestException as exc:
        print(f"网页请求失败:{exc}")


if __name__ == "__main__":
    main()

运行结果(成功提取 30 条大学排名信息,前 30 名规整输出):

00

可以看到,程序输出的表格包含排名、学校名称、省市、学校类型、总分五列,其中清华大学以 852.5 分位居第一,与网页上的真实排名完全一致,共提取 30 条大学排名信息。

2. 作业①心得体会

这次实验让我第一次完整走通了"请求 → 解析 → 清洗 → 输出"的爬虫流程。最大的体会是:爬取只是开始,数据清洗才是重头戏。软科网页的学校名称单元格里混着英文校名和"双一流"等标签,省市有时和校名挤在同一单元格,如果不做清洗,输出的表格会非常杂乱。我通过维护省份列表、替换标签关键词、按行兜底找分数等方式逐步把数据"洗"干净了。此外我还认识到模拟浏览器 User-Agent 的重要性——不加请求头很容易被网站拒绝;raise_for_status() 和 try/except 则让程序在网页不可访问时能给出友好提示而不是直接崩溃。


作业②:当当网"书包"商品价格爬虫

1. 当当网爬虫实验

实验目标:用 requests 和 bs4 方法设计某个商城(自己选择)商品的价格信息爬虫,爬取该商城以关键词"书包"搜索页面的数据,爬取商品名称和价格,输出格式为:序号、价格、商品名称。

网页勘探:直接用浏览器打开当当网"书包"搜索结果页,确认商品以列表形式呈现;按 F12 选中任意一个商品,可见商品名称在 <a> 标签的 title 属性中,价格则在 search_now_price / price 这类 class 里。

00 00

实现思路:

  1. 选用当当网作为目标商城,直接请求关键词"书包"的搜索结果页 URL;
  2. 请求头除 User-Agent 外增加 Referer,降低被反爬拦截的概率;
  3. 用正则表达式按 <li> 商品块切分页面,在块内分别匹配价格(优先匹配 search_now_price/price 类名,兜底匹配 ¥ 符号)和商品名称(优先取 <a> 的 title 属性,兜底取 name/title 类链接文本);
  4. 对商品去重,并按"序号 + 价格 + 名称"格式规整输出;
  5. 程序同时把原始 HTML 保存为 dangdang_search_debug.html,方便网页结构变化时排查;
  6. 为便于存档与比价,程序额外把结果导出为 schoolbags.csv(UTF-8 BOM,Excel 可直接打开)。

完整代码(作业2_当当网书包爬虫.py):

# -*- coding: utf-8 -*-
"""
作业②:当当网"书包"商品定向爬虫
使用 requests 请求页面,使用 re 正则表达式提取商品名称和价格。
注意:网站页面结构和反爬策略可能变化;程序会保存原始 HTML 便于检查。
"""

import re
import os
import csv
import requests
from html import unescape

URL = "https://search.dangdang.com/?key=%CA%E9%B0%FC&act=input"
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://www.dangdang.com/"
}


def clean_html_text(text):
    """去除 HTML 标签并解码实体字符。"""
    text = re.sub(r"<[^>]+>", " ", text)
    text = unescape(text)
    return re.sub(r"\s+", " ", text).strip()


def parse_products(html):
    """按商品列表块解析名称和价格,兼容常见当当网 class 命名。"""
    products = []

    # 当当网搜索结果常使用 li 作为商品容器
    blocks = re.findall(r"<li\b[^>]*>(.*?)</li>", html, flags=re.I | re.S)

    for block in blocks:
        # 先找商品价格区域
        price_match = re.search(
            r'<[^>]*class=["\'][^"\']*(?:search_now_price|price)[^"\']*["\'][^>]*>'
            r'.{0,200}?([0-9]+(?:\.[0-9]{1,2})?)',
            block,
            flags=re.I | re.S
        )
        if not price_match:
            price_match = re.search(r"¥\s*([0-9]+(?:\.[0-9]{1,2})?)", block)

        # 商品名称优先取 title 属性,再取 name/title 类链接文本
        name_match = re.search(r'<a\b[^>]*title=["\']([^"\']+)["\']', block, flags=re.I | re.S)
        if not name_match:
            name_match = re.search(
                r'<a\b[^>]*class=["\'][^"\']*(?:name|title)[^"\']*["\'][^>]*>(.*?)</a>',
                block, flags=re.I | re.S
            )

        if price_match and name_match:
            price = price_match.group(1)
            name = clean_html_text(name_match.group(1))
            # 过滤空名称、非商品文本
            if name and len(name) > 2:
                products.append((price, name))

    # 去重
    unique = []
    seen = set()
    for item in products:
        if item not in seen:
            seen.add(item)
            unique.append(item)
    return unique


def main():
    try:
        response = requests.get(URL, headers=HEADERS, timeout=20)
        response.raise_for_status()
        response.encoding = response.apparent_encoding
        html = response.text

        # 保存网页原文,方便定位网站结构变化
        with open("dangdang_search_debug.html", "w", encoding="utf-8") as file:
            file.write(html)

        products = parse_products(html)
        if products:
            print(f"{'序号':<6}{'价格(元)':<12}商品名称")
            print("-" * 80)
            for index, (price, name) in enumerate(products, 1):
                print(f"{index:<6}{price:<12}{name}")
            print(f"共提取 {len(products)} 件商品。")

            # 将结果导出为 CSV,便于后续比价与存档
            save_path = "schoolbags.csv"
            with open(save_path, "w", encoding="utf-8-sig", newline="") as csvfile:
                writer = csv.writer(csvfile)
                writer.writerow(["序号", "价格(元)", "商品名称"])
                for index, (price, name) in enumerate(products, 1):
                    writer.writerow([index, price, name])
            print(f"结果已导出到:{os.path.abspath(save_path)}")
            return

        print("当前页面未能解析到商品名称和价格。原始网页已保存为 dangdang_search_debug.html。")
        print("如果文件内容是验证码/访问受限页面,正则表达式无法提取商品。")
        print("可打开该 HTML 搜索 'search_now_price'、'price' 或商品名称,确认当前网页结构。")
    except requests.RequestException as exc:
        print(f"请求当当网失败:{exc}")


if __name__ == "__main__":
    main()

运行结果(成功提取 60 件"书包"商品的价格和名称):

00 程序按序号输出每件商品的价格与名称,共提取 60 件商品,价格从几十元到数百元不等,与当当网搜索页真实展示的商品信息一致。

2. 作业②心得体会

这个实验和作业①最大的不同是:电商网页结构复杂、反爬更严格,BeautifulSoup 的常规解析经常拿不到数据,我改用正则表达式配合多级兜底策略(先按 class 匹配、再按 ¥ 符号匹配)才稳定提取出商品信息。调试过程中我学会了"保存原始 HTML 再分析"的排查方法——把响应原文存成 dangdang_search_debug.html,在文件里搜索目标字段,就能准确判断是请求被拦截了还是解析规则不对。另外我也体会到爬虫代码要写得"防御性强":网页结构随时可能变化,多一层兜底匹配、多一些异常提示,程序的生命力就更强。把结果额外导出成 CSV,也让我第一次有了"数据落盘、可复用"的意识。


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

1. 图片下载实验

实验目标:爬取一个指定网页(< https://news.fzu.edu.cn/yxfd.htm )的所有> JPG、JPEG 和 PNG 格式图片文件,将图片保存在一个文件夹中。

网页勘探:先用浏览器打开"影像福大"栏目页面,确认图片以 <img> 标签嵌入;重点检查页面底部的分页区,发现该栏目共 7 个分页,链接规律为 yxfd/{n}.htm。题目要求爬取"所有"图片,因此必须遍历全部 7 个分页,只抓首页会漏掉约 6/7 的图片。

00 00

实现思路:

  1. 先读取分页区,生成"首页 + 全部后续分页"的地址列表(get_page_urls());
  2. 逐页用 BeautifulSoup 遍历所有 <img> 标签,同时兼容 src、data-src、data-original 等懒加载属性;
  3. 用 urljoin 把相对地址补全为绝对地址,按 URL 后缀筛选 .jpeg/.jpg/.png(无后缀的链接先保留,下载后按响应的 Content-Type 判断);
  4. 下载时用 requests.Session() 复用连接提高效率;文件名做 Windows 非法字符清洗,并加序号前缀防止重名;
  5. 跳过空响应和非图片内容,逐张统计成功/失败情况,图片统一保存到 downloaded_images 文件夹。

完整代码(作业3_网页图片下载.py):

# -*- coding: utf-8 -*-
"""
作业③:下载福州大学新闻网页中的 JPEG、JPG、PNG 图片
使用 requests 获取网页和图片;BeautifulSoup 解析 img 标签。
目标网页(影像福大)存在多个分页,本程序会先读取分页总数,
再逐页收集图片链接,最终把全站符合条件的图片统一保存到 downloaded_images 文件夹。
"""

import os
import re
from urllib.parse import urljoin, urlparse, unquote

import requests
from bs4 import BeautifulSoup


PAGE_URL = "https://news.fzu.edu.cn/yxfd.htm"
SAVE_DIR = "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"
    )
}
ALLOWED_EXTENSIONS = {".jpeg", ".jpg", ".png"}


def get_page_urls(home_url):
    """读取分页区,生成首页 + 全部后续分页的地址列表。"""
    response = requests.get(home_url, headers=HEADERS, timeout=20)
    response.raise_for_status()
    response.encoding = response.apparent_encoding
    soup = BeautifulSoup(response.text, "html.parser")

    page_urls = [home_url]  # 首页固定纳入
    pagination = soup.select_one(".n_right .pb_sys_common .p_pages")
    if pagination:
        # 从分页区读取最大页码,按 "yxfd/{n}.htm" 规律补全所有分页
        numbers = [
            int(tag.get_text(strip=True))
            for tag in pagination.select(".p_no a, .p_no_d")
            if tag.get_text(strip=True).isdigit()
        ]
        total_pages = max(numbers) if numbers else 1
        for n in range(total_pages - 1, 0, -1):
            page_urls.append(urljoin(home_url, f"yxfd/{n}.htm"))

    # 去重并保持顺序
    seen, unique = set(), []
    for url in page_urls:
        if url not in seen:
            seen.add(url)
            unique.append(url)
    return unique


def get_image_urls(page_url):
    """从单个网页中提取并规范化图片链接(兼容懒加载属性)。"""
    response = requests.get(page_url, headers=HEADERS, timeout=20)
    response.raise_for_status()
    response.encoding = response.apparent_encoding
    soup = BeautifulSoup(response.text, "html.parser")

    image_urls = set()
    for img in soup.find_all("img"):
        # 常见图片地址属性,兼容懒加载网页
        src = (
            img.get("src")
            or img.get("data-src")
            or img.get("data-original")
            or img.get("data-lazy-src")
        )
        if not src:
            continue

        image_url = urljoin(page_url, src.strip())
        path = urlparse(image_url).path.lower()

        # 按 URL 后缀筛选指定格式;部分图片无扩展名时,下载后再检查响应类型
        if os.path.splitext(path)[1] in ALLOWED_EXTENSIONS or not os.path.splitext(path)[1]:
            image_urls.add(image_url)

    return image_urls


def safe_filename(image_url, index, content_type):
    """根据图片 URL 生成安全文件名,并确保使用允许的图片扩展名。"""
    path_name = os.path.basename(unquote(urlparse(image_url).path))
    name, ext = os.path.splitext(path_name)
    ext = ext.lower()

    if ext not in ALLOWED_EXTENSIONS:
        content_type = (content_type or "").lower()
        if "png" in content_type:
            ext = ".png"
        elif "jpeg" in content_type or "jpg" in content_type:
            ext = ".jpg"
        else:
            return None

    # 清理 Windows 文件名不允许的字符
    name = re.sub(r'[\\/:*?"<>|\s]+', "_", name).strip("._")
    if not name:
        name = f"image_{index:03d}"
    return f"{index:03d}_{name[:80]}{ext}"


def download_images(image_urls, save_dir):
    """逐个下载图片,跳过非图片响应,并统计下载结果。"""
    os.makedirs(save_dir, exist_ok=True)
    success_count = 0

    with requests.Session() as session:
        session.headers.update(HEADERS)
        for index, image_url in enumerate(image_urls, start=1):
            try:
                response = session.get(image_url, timeout=20)
                response.raise_for_status()

                content_type = response.headers.get("Content-Type", "")
                filename = safe_filename(image_url, index, content_type)
                if filename is None:
                    print(f"[跳过] 不是 JPEG/JPG/PNG 图片:{image_url}")
                    continue

                # 避免把网页错误页误保存成图片
                if not response.content:
                    print(f"[跳过] 文件内容为空:{image_url}")
                    continue

                file_path = os.path.join(save_dir, filename)
                with open(file_path, "wb") as file:
                    file.write(response.content)

                success_count += 1
                print(f"[成功] {filename}")

            except requests.RequestException as exc:
                print(f"[失败] {image_url},原因:{exc}")

    return success_count


def main():
    try:
        page_urls = get_page_urls(PAGE_URL)
        print(f"共发现 {len(page_urls)} 个分页:")
        for url in page_urls:
            print("  -", url)
        print()

        all_image_urls = set()
        for page_url in page_urls:
            all_image_urls |= get_image_urls(page_url)
        all_image_urls = sorted(all_image_urls)
        print(f"全部分页中共发现 {len(all_image_urls)} 个候选图片链接。\n")

        if not all_image_urls:
            print("没有发现符合条件的图片链接,请检查网页是否能正常访问或图片是否由 JavaScript 加载。")
            return

        count = download_images(all_image_urls, SAVE_DIR)
        print(f"\n下载完成:成功保存 {count} 张图片。")
        print(f"保存目录:{os.path.abspath(SAVE_DIR)}")

    except requests.RequestException as exc:
        print("网页请求失败:", exc)
        print("请检查网络连接、网页是否可访问,或稍后重试。")


if __name__ == "__main__":
    main()

运行结果(控制台先列出 7 个分页地址,再逐张下载,最终成功保存 65 张图片):

00

打开 downloaded_images 文件夹,可以看到 65 张 JPG/PNG 图片已按序号命名整齐保存:

00

随机双击打开其中一张图片,确认能正常显示,证明下载内容均为有效图片:

00

2. 作业③心得体会

图片下载看似简单,实际要处理的细节远比想象多:相对地址要补全、懒加载属性要兼容、文件名要清洗、非图片响应要过滤。这个实验让我养成了"把每一步可能出错的地方都想一遍"的编程习惯——比如用 Session 复用连接、用序号前缀避免文件重名、用 Content-Type 二次校验防止把错误页存成图片。最关键的收获来自对"所有图片"四个字的理解:只有点开分页区、发现共 7 页并逐页爬取,才真正拿到了全站 65 张图,而不是首页那 1/7。运行时看到一张张 [成功] 打印出来、文件夹里图片整齐排列,成就感十足,也真正理解了"爬虫是细节的工程"这句话。


总结

三个作业覆盖了网络数据采集的三种典型场景:结构化表格数据(大学排名)、电商商品信息(当当网书包)和静态资源文件(网页图片)。通过本次作业,我掌握了:

  1. requests 的请求头设置、编码处理、超时与异常控制;
  2. BeautifulSoup 的 find_all/select 两种解析方式及懒加载属性的兼容处理;
  3. re 正则表达式在复杂网页结构下的兜底提取能力;
  4. 数据清洗(去标签、去重、字段识别)与规整化输出(对齐表格、安全文件名)的工程化技巧;
  5. 面对"全站数据"时先勘察分页结构、再定量爬取的工作方法。

爬虫不仅是"把数据拿下来",更是把数据拿对、拿全、拿干净。这次实践为后续学习动态网页爬取(Selenium/Playwright)和数据分析打下了扎实的基础。

posted @ 2026-10-12 00:18  Annabelle-Lin  阅读(4)  评论(0)    收藏  举报