数据采集第一次作业 —— 定向爬虫

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

姓名:姜思源  学号:102402110
我的gitee仓库:aarctic
课程:数据采集与融合技术

本次共三个小作业:①用 requests + BeautifulSoup 爬取软科中国大学排名;②用 requests + re 设计当当网"书包"商品比价爬虫;③爬取指定网页中的所有 JPEG/JPG/PNG 图片并保存到本地。


一、作业①:爬取软科2020中国大学排名

1.1 作业代码与运行结果

(1)实现思路

目标网址为 https://www.shanghairanking.cn/rankings/bcur/2020。排名数据以服务端渲染的 HTML 表格形式存在于页面中:每一条排名记录对应 <tbody> 下的一个 <tr>,其中第 1~5 个 <td> 依次是排名、学校名称、省市、学校类型、总分。
image

流程分为三步:

  1. 用 requests 携带 User-Agent 请求头发起 GET 请求,拿到网页源码;
  2. 用 BeautifulSoup 以 lxml 解析器解析 HTML,定位 tbody 并遍历每个 tr;
  3. 从每个 tr 中按列提取五个字段,整理成字典后格式化打印。

(2)完整代码



import requests
from bs4 import BeautifulSoup

# 目标网页地址
URL = "https://www.shanghairanking.cn/rankings/bcur/2020"

# 请求头:User-Agent模拟浏览器访问,避免被服务器拒绝
HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    )
}


def get_html(url, timeout=30):
    try:
        response = requests.get(url, headers=HEADERS, timeout=timeout)
        response.raise_for_status()          # 状态码非 200 时抛出异常
        response.encoding = "utf-8"          # 软科页面为 UTF-8 编码
        return response.text
    except requests.RequestException as exc:
        print("网络请求失败:", exc)
        return None


def parse_ranking(html):
    
    #用 BeautifulSoup 解析排名表格,返回字典列表。
    
    
    soup = BeautifulSoup(html, "lxml")

    # 排名数据放在 <tbody> 的每一个 <tr> 中
    tbody = soup.find("tbody")
    if tbody is None:
        return []

    rankings = []
    for tr in tbody.find_all("tr"):
        tds = tr.find_all("td")
        if len(tds) < 5:                     # 跳过表头等异常行
            continue
        try:
            rank = tds[0].get_text(strip=True)                       # 排名
            name = tds[1].find("span", class_="name-cn").get_text(strip=True)  #校名
            province = tds[2].get_text(strip=True)                   # 省市
            univ_type = tds[3].get_text(strip=True)                  # 学校类型
            score = tds[4].get_text(strip=True)                      # 总分
        except AttributeError:
            continue                        
        
        rankings.append({
            "排名": rank,
            "学校名称": name,
            "省市": province,
            "学校类型": univ_type,
            "总分": score,
        })
    return rankings


def print_table(data, top=30):
   
    header = "{:<6}{:<18}{:<10}{:<10}{:<10}".format(
        "排名", "学校名称", "省市", "学校类型", "总分")
    line = "-" * 56
    print(line)
    print(header)
    print(line)

    for item in data[:top]:
        print("{:<6}{:<18}{:<10}{:<10}{:<10}".format(
            item["排名"], item["学校名称"], item["省市"],
            item["学校类型"], item["总分"]))
    print(line)
    print("共爬取到 {} 所高校的排名数据。".format(len(data)))


def main():

    html = get_html(URL)
    if html is None:
        return

    rankings = parse_ranking(html)
    print("【软科2020中国大学排名top30】")
    print_table(rankings, top=30)


if __name__ == "__main__":
    main()

(3)运行结果

程序成功打印出排名、学校名称、省市、学校类型、总分五列对齐的表格。

截图如下:
48e751a33da59e27c1bbadcd8c7af2ac

1.2 作业心得

写这个作业最大的体会是:爬虫的三要素是"请求—解析—提取",三者必须各自独立、职责清晰。

一开始我把解析逻辑直接写在主函数里,后来发现 parse_ranking() 单独抽出来以后,既方便单独调试,也方便以后换成别的排名页面复用。第二个体会是异常处理不能省:软科服务器偶尔会返回结构不完整的行(比如表头行、空行),如果不加 len(tds) < 5 的判断和 try/except,程序跑一半就会因为 NoneType 报错中断。

调试中还踩到一个很有意思的坑:同样的代码有时能取到完整 HTML,有时却取到一段乱码。查了响应头才发现服务器返回了 Brotli 压缩的内容,而 requests 默认只支持 gzip/deflate,装上 brotli 库后问题解决。这让我明白,爬虫出问题不一定在代码逻辑上,HTTP 协议层面的细节(编码、压缩、请求头)同样需要关注。


二、作业②:当当网"书包"商品比价爬虫

2.1 作业代码与运行结果

(1)实现思路

自选商城为当当网,搜索接口是 http://search.dangdang.com/?key=书包&act=input。该页面由服务端直接渲染商品列表,每个商品是一个 <li> 节点,价格和名称分别位于 <p class="price"> 和 <p class="name"> 中。
image

流程:

  1. requests 请求搜索页(注意当当搜索页是 GB2312 编码,需手动指定 response.encoding);
  2. 用正则先按 <li ... id="商品编号">...</li> 切出每一个商品块;
  3. 在每个商品块内再分别用正则匹配价格 <span class="price_n">¥xx.xx</span> 和名称 <a title="...">;
  4. 清洗名称中的高亮 <font> 标签与 HTML 实体,把价格转成浮点数从小到大排序,实现"比价"功能。

(2)完整代码


import os
import re
import sys
import html as html_lib
import requests

# 防止 Windows GBK 控制台遇到生僻字/表情时抛 UnicodeEncodeError,
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
    sys.stdout.reconfigure(errors="replace")

# 搜索接口地址与关键词
SEARCH_URL = "http://search.dangdang.com/"
KEYWORD = "书包"

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    )
}

# 商品条目:<li ... id="商品编号" ...> ... </li>
ITEM_RE = re.compile(r'<li\s[^>]*id="(\d+)"[^>]*>(.*?)</li>', re.S)
# 商品价格:<span class="price_n">&yen;89.99</span>
PRICE_RE = re.compile(r'<span class="price_n">\s*(.*?)\s*</span>', re.S)
# 商品名称:<p class="name" ...><a title="商品名" ...>
NAME_RE = re.compile(r'<p class="name"[^>]*>\s*<a[^>]*title="([^"]*)"', re.S)


def get_search_page(keyword, page_index=1, timeout=30):

    params = {
        "key": keyword,
        "act": "input",
        "page_index": page_index,
    }
    try:
        response = requests.get(SEARCH_URL, params=params,
                                headers=HEADERS, timeout=timeout)
        response.raise_for_status()
        response.encoding = "gb2312"      # 当当网搜索页为 GB2312 编码
        return response.text
    except requests.RequestException as exc:
        print("网络请求失败:", exc)
        return None


def parse_products(html):
    """用正则表达式从搜索页源码中提取商品名称与价格。

    :param html: 搜索页 HTML 文本
    :return: [{'序号': 1, '价格': '¥89.99', '商品名': '...'}, ...]
    """
    products = []
    for index, match in enumerate(ITEM_RE.finditer(html), start=1):
        item_html = match.group(2)

        name_match = NAME_RE.search(item_html)
        price_match = PRICE_RE.search(item_html)
        if not (name_match and price_match):
            continue                       # 价格或名称缺失的条目跳过

        name = re.sub(r"<[^>]+>", "", name_match.group(1))
        name = html_lib.unescape(name).strip()
        price = html_lib.unescape(price_match.group(1)).strip()

        products.append({
            "序号": index,
            "价格": price,
            "商品名": name,
        })
    return products


def print_products(products, top=20):
    line = "-" * 78
    print(line)
    print("{:<6}{:<12}{}".format("序号", "价格", "商品名"))
    print(line)
    for item in products:
        # 商品名过长时截断,保证表格整齐
        name = item["商品名"]
        if len(name) > 40:
            name = name[:40] + "…"
        print("{:<6}{:<12}{}".format(item["序号"], item["价格"], name))
    print(line)
    print("共爬取到 {} 件商品,已按价格从低到高排序。".format(len(products)))


def save_to_file(products, path="dangdang_shubao.txt"):
    with open(path, "w", encoding="utf-8") as fp:
        for item in products:
            fp.write("{:<6}{:<12}{}\n".format(
                item["序号"], item["价格"], item["商品名"]))
        fp.write("共爬取到 {} 件商品,已按价格从低到高排序。\n".format(len(products)))
    print("完整结果已保存到文件:", os.path.abspath(path))


def main():
    html = get_search_page(KEYWORD)
    if html is None:
        return

    products = parse_products(html)

    # 把价格字符串转成浮点数后从小到大排序
    def to_price(item):
        """把 '¥89.99' 转换成 89.99,无法解析时返回无穷大。"""
        number = re.search(r"[\d.]+", item["价格"])
        return float(number.group()) if number else float("inf")

    products.sort(key=to_price)
    for order, item in enumerate(products, start=1):
        item["序号"] = order

    print("【当当网“{}”搜索结果比价】".format(KEYWORD))
    print_products(products)             
    save_to_file(products)               


if __name__ == "__main__":
    main()

(3)运行结果

程序共爬取到 60 件"书包"商品,并按价格从低到高排序输出,符合作业要求的"序号 / 价格 / 商品名"输出格式。
82a180f2d4497b668579e45d94d276de

2.2 作业心得

这个作业让我真正体会到了正则表达式的威力和局限。

在完成作业②时,我最初选择了京东作为目标,但发现京东反爬机制非常严格,requests 请求返回的是风险拦截页面而非真实数据,导致正则匹配全部失败。后来我改用当当网作为目标商城,并针对当当网的实际 HTML 结构编写正则表达式,终于成功提取到商品名称和价格。

威力在于:当当网的商品列表 HTML 规整且层级不深,用三段正则(切条目 → 取价格 → 取名称)就能干净利落地拿到数据,比一层层写 DOM 查找还直观。局限在于:正则对结构变化非常敏感,比如商品名称里混着 <font class="skcolor_ljg">书包</font> 这样的关键词高亮标签,如果不先做 re.sub(r"<[^>]+>", "", ...) 清洗,名称里就会带着一串 HTML 标签;再比如价格实体写的是 &yen; 而不是 ¥,必须用 html.unescape() 反转义。这些都是"拿到数据"和"拿到干净数据"之间的差距。

另外两个印象深刻的点:一是编码问题,当当搜索页是 GB2312,如果直接用默认编码解码,中文全是乱码,response.encoding = "gb2312" 一行代码解决了大问题;二是"比价"的本质其实就是把非结构化的价格文本转成可计算的数值再排序,re.search(r"[\d.]+", price) 这个小函数虽然只有两行,却是整个爬虫从"展示"走向"分析"的关键一步。


三、作业③:爬取网页中的所有图片并保存到本地

3.1 作业代码与运行结果

(1)实现思路

目标网页为福州大学新闻网"院系动态"栏目 https://news.fzu.edu.cn/yxfd.htm。页面中的图片以 <img src="..."> 标签引用,既有相对路径(如 images/logo.png)也有站点根路径(如 /__local/.../xxx.jpg)。
987000a838d2778760afe4ba64cf314f

流程:

  1. requests 获取网页源码;
  2. 用正则 r'<img[^>]+src=["\']([^"\']+)["\']' 提取所有 src 属性;
  3. 用 urllib.parse.urljoin 把相对地址补全为绝对地址,再按扩展名过滤出 .jpg / .jpeg / .png(过滤时先去掉 ?e=.png 这类查询参数),并用集合去重;
  4. 逐个下载图片,以 wb 二进制模式写入 ./images 文件夹(目录不存在时用 os.makedirs(..., exist_ok=True) 自动创建)。

(2)完整代码

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

# 目标网页与图片保存目录
PAGE_URL = "https://news.fzu.edu.cn/yxfd.htm"
SAVE_DIR = "images"

# 支持的图片格式
IMG_EXTS = (".jpg", ".jpeg", ".png")

HEADERS = {
    "User-Agent": (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    )
}

# 匹配 <img ... src="..."> 中的 src 属性
IMG_SRC_RE = re.compile(r'<img[^>]+src=["\']([^"\']+)["\']', re.I)


def get_html(url, timeout=30):
    """请求网页并返回 HTML 文本。

    :param url: 网页地址
    :param timeout: 超时时间(秒)
    :return: HTML 文本;失败返回 None
    """
    try:
        response = requests.get(url, headers=HEADERS, timeout=timeout)
        response.raise_for_status()
        response.encoding = response.apparent_encoding
        return response.text
    except requests.RequestException as exc:
        print("网页请求失败:", exc)
        return None


def collect_image_urls(html, base_url):
    """从 HTML 中提取所有 jpg/jpeg/png 图片的绝对地址(自动去重)。
    :param html: 网页 HTML 文本
    :param base_url: 网页地址,用于把相对路径补全为绝对路径
    :return: 去重后的图片绝对地址列表
    """
    urls = []
    seen = set()
    for src in IMG_SRC_RE.findall(html):
        absolute = urljoin(base_url, src)          
        path = urlparse(absolute).path.lower()     
        if path.endswith(IMG_EXTS) and absolute not in seen:
            seen.add(absolute)
            urls.append(absolute)
    return urls


def download_images(urls, save_dir=SAVE_DIR):
    """逐一下载图片并保存到本地目录。

    :param urls: 图片绝对地址列表
    :param save_dir: 保存目录
    :return: 成功保存的文件名列表
    """
    os.makedirs(save_dir, exist_ok=True)           # 目录不存在则自动创建
    saved = []

    for index, url in enumerate(urls, start=1):
        filename = os.path.basename(urlparse(url).path) or "image_{}.jpg".format(index)
        filepath = os.path.join(save_dir, filename)

        try:
            response = requests.get(url, headers=HEADERS, timeout=30)
            response.raise_for_status()
            with open(filepath, "wb") as fp:       
                fp.write(response.content)
            saved.append(filename)
            print("  [%2d] 已保存 %-45s (%d 字节)" % (index, filename, len(response.content)))
        except requests.RequestException as exc:
            print("  [%2d] 下载失败 %-45s %s" % (index, url, exc))

    return saved


def main():
    """主函数:抓取网页 → 提取图片地址 → 下载保存。"""
    print("正在请求网页:", PAGE_URL)
    html = get_html(PAGE_URL)
    if html is None:
        return

    urls = collect_image_urls(html, PAGE_URL)
    print("共发现 {} 张 JPEG/JPG/PNG 图片,开始下载……".format(len(urls)))

    saved = download_images(urls)
    print("下载完成,成功保存 {} 张图片到目录:{}".format(len(saved), os.path.abspath(SAVE_DIR)))


if __name__ == "__main__":
    main()

(3)运行结果

程序从页面中识别出 19 张 JPEG/JPG/PNG 图片,全部成功下载并保存到 images 文件夹中。

运行结果:

dd50dce964180bb1366862190b8c58f2

图片保存结果:

image

3.2 作业心得

这个作业收获主要有三点。

第一是相对路径的处理。网页里的图片地址五花八门:images/logo.png 是相对当前页面的路径,/__local/... 是相对站点根目录的路径,还有的图片地址后面带着 ?e=.png 这种查询参数。一开始我只用字符串拼接,结果拼出一堆 404;后来改用 urljoin(base_url, src) 自动补全,并在判断扩展名时先用 urlparse() 去掉查询参数,问题才彻底解决。

第二是图片必须以二进制方式保存。最开始我下意识地用 response.text 再 encode() 写文件,结果大图全部损坏;改用 response.content 配合 open(filepath, "wb") 之后,所有图片都能正常打开。这让我对"文本"和"二进制"两种资源在爬虫里的区别有了实感。

第三是去重和容错。同一个 logo 在页眉页脚出现多次,如果不去重就会重复下载;而下载过程中任何一张图失败都不应该影响其他图片,所以我把异常处理放到了循环内部,保证"单张失败,整体继续"。这种批量任务中的局部容错思想,以后做大规模采集时一定用得上。


四、总结

三个作业分别对应了定向爬虫最核心的三个点:

作业 技术组合 关键难点
①大学排名 requests + BeautifulSoup HTML 结构定位、Brotli 压缩
②商品比价 requests + re 正则提取、编码转换、数据清洗
③图片爬取 requests + re + 文件 IO 相对路径补全、二进制保存、去重容错

做完这三个作业,我对爬虫的理解发生了不小的变化。

以前我觉得爬虫就是"把网页上的东西抓下来",做完之后才明白,爬虫的本质是一场与目标网站的持续博弈。你写代码的同时,对方也在不断更新反爬机制、调整页面结构、更换编码格式。作业②里我一开始选京东,结果请求返回的是一个风险拦截页面——这就是最直观的一课:选对目标,比写对代码更重要。写爬虫之前,先打开浏览器按 Ctrl+U 看看源码结构、判断页面是静态渲染还是动态加载、检查有没有反爬特征,这些"准备工作"反而比敲代码更关键。

另一个深刻的感受是:爬虫代码写起来不难,难的是让它稳定、可持续地运行。异常处理、超时设置、请求间隔、编码适配、路径补全、去重容错——这些看似"边角料"的东西,恰恰决定了一个爬虫能不能在实际场景中真正跑起来。这大概也是"数据采集与融合技术"这门课想让我们体会的东西:技术只是工具,工程思维才是核心。

这次作业给我最大的收获,不是学会了 requests 和 re 的用法,而是养成了一个习惯:写爬虫之前先看源码,写完之后先想异常。这个习惯,比任何一段代码都更有价值。

数据采集这条路,我才刚刚起步。下一次,我会带着这三个作业踩过的坑,继续往前走。

posted @ 2026-10-13 16:21  Cactus061  阅读(3)  评论(0)    收藏  举报