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

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

作业①②③:大学排名定向爬虫 / 当当网商品比价爬虫 / 网页图片批量下载
开发环境:Windows 10 + Python 3.12(requests 2.32 / beautifulsoup4)


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

要求:用 requests 和 BeautifulSoup 库方法定向爬取 http://www.shanghairanking.cn/rankings/bcur/2020 的数据,屏幕打印大学排名信息(排名、学校名称、省市、学校类型、总分)。

1.1 代码与运行结果

# -*- coding: utf-8 -*-
"""
作业①:定向爬取软科 2020 中国大学排名
库:requests + BeautifulSoup
输出:排名 / 学校名称 / 省市 / 学校类型 / 总分
"""
import json

import requests
from bs4 import BeautifulSoup

URL = 'http://www.shanghairanking.cn/rankings/bcur/2020'    # 软科大学排名页面
API_URL = 'https://www.shanghairanking.cn/api/pub/v1/bcur'  # 软科公开数据接口
HEADERS = {
    # 伪装浏览器 UA,避免被服务器拒绝请求
    '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):
    """请求页面,返回 UTF-8 解码后的 HTML 文本"""
    resp = requests.get(url, headers=HEADERS, timeout=15)
    resp.raise_for_status()          # 状态码非 2xx 时抛出异常
    resp.encoding = 'utf-8'          # 页面实际编码为 utf-8,手动指定避免乱码
    return resp.text


def parse_ranking(html):
    """用 BeautifulSoup 解析排名表格,返回 [(排名, 学校名称, 省市, 学校类型, 总分), ...]"""
    soup = BeautifulSoup(html, 'html.parser')
    rows = soup.select('tbody tr')   # 表格中每一行 tr 对应一所大学
    ranking = []                     # 存放解析结果的列表
    for tr in rows:
        tds = tr.find_all('td')      # 每行共 6 列,取前 5 列
        if len(tds) < 5:
            continue
        rank = tds[0].get_text(strip=True)                    # 排名
        name = tds[1].select_one('span.name-cn')              # 学校名称(中文名)
        name = name.get_text(strip=True) if name else ''
        province = tds[2].get_text(strip=True)                # 省市
        category = tds[3].get_text(strip=True)                # 学校类型
        score = tds[4].get_text(strip=True)                   # 总分
        ranking.append((rank, name, province, category, score))
    return ranking


def fetch_full_ranking():
    """静态数据不完整时,调用软科公开数据接口获取完整榜单"""
    resp = requests.get(API_URL,
                        params={'bcur_type': 11, 'year': 2020},  # 11 = 主榜
                        headers=HEADERS, timeout=15)
    resp.raise_for_status()
    result = []
    for item in resp.json()['data']['rankings']:    # 遍历接口返回的每所大学
        score = item['score']                       # 总分可能为空
        result.append((str(item['ranking']),        # 排名
                       item['univNameCn'],          # 学校名称
                       item['province'],            # 省市
                       item['univCategory'],        # 学校类型
                       str(score) if score else '-'))
    return result


def display_width(text):
    """计算字符串显示宽度:中文等全角字符按 2 个单位计"""
    return sum(2 if ord(ch) > 127 else 1 for ch in text)


def pad(text, width):
    """按显示宽度右侧补空格,实现表格中英文对齐"""
    return text + ' ' * max(width - display_width(text), 1)


def print_ranking(ranking):
    """按题目要求格式打印大学排名信息"""
    headers = ('排名', '学校名称', '省市', '学校类型', '总分')
    widths = (6, 14, 8, 10, 8)
    print(pad(headers[0], widths[0]), pad(headers[1], widths[1]),
          pad(headers[2], widths[2]), pad(headers[3], widths[3]), headers[4])
    for rank, name, province, category, score in ranking:
        print(pad(rank, widths[0]), pad(name, widths[1]),
              pad(province, widths[2]), pad(category, widths[3]), score)


def main():
    """主流程:优先解析静态页面,不完整时用公开接口补全"""
    ranking = parse_ranking(get_html(URL))
    print(f'[BeautifulSoup 解析静态表格:{len(ranking)} 条]')
    if len(ranking) < 100:          # 静态部分远少于完整榜单,需接口补全
        ranking = fetch_full_ranking()
        print(f'[调用公开数据接口补全:共 {len(ranking)} 条]\n')
    print_ranking(ranking)


if __name__ == '__main__':
    main()

运行结果(共输出完整榜单 567 条,此处截取前 10 名):

[BeautifulSoup 解析静态表格:30 条]
[调用公开数据接口补全:共 567 条]

排名   学校名称       省市     学校类型   总分
1      清华大学       北京     综合       852.5
2      北京大学       北京     综合       746.7
3      浙江大学       浙江     综合       649.2
4      上海交通大学   上海     综合       625.9
5      南京大学       江苏     综合       566.1
6      复旦大学       上海     综合       556.7
7      中国科学技术大学  安徽     理工       526.4
8      华中科技大学   湖北     综合       497.7
9      武汉大学       湖北     综合       488
10     中山大学       广东     综合       457.2

屏幕截图 2026-10-20 222516

1.2 作业心得

  1. 页面结构分析是第一步。 用浏览器 F12 查看软科页面源码,发现排名数据位于 <tbody> 的每行 <tr> 中,共 6 列,取前 5 列即可得到题目要求的五个字段。学校名称没有单独的标签包裹,而是嵌在 span.name-cn 中,需要用 select() 精确定位。
  2. 遇到了页面改版带来的新问题。 软科近年改版后,服务端只静态渲染前 30 名,其余 500 多所学校是滚动时懒加载的,纯 BeautifulSoup 只能拿到 30 条。我对比了网络请求,找到了软科的公开数据接口,在静态数据不完整时用该接口补全了完整榜单 567 条。这个过程让我理解到:现代网站大量采用前后端分离 + 懒加载,"所见 HTML"未必等于"requests 所得的 HTML",爬虫要动态调整策略。
  3. 编码与对齐细节。 页面返回的编码需手动指定为 utf-8,否则中文乱码;打印时直接用 str.ljust() 会因为中文占两个显示宽度而错位,我编写了按显示宽度补空格的 pad() 函数实现表格对齐。

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

要求:用 requests 和 re 库方法设计某商城商品比价定向爬虫,爬取以关键词"书包"搜索页面的商品名称和价格。

2.1 代码与运行结果

# -*- coding: utf-8 -*-
"""
作业②:当当网"书包"商品比价定向爬虫
库:requests + re(正则表达式)
输出:序号 / 价格 / 商品名
"""
import re
import requests
from urllib.parse import quote

SEARCH_URL = 'http://search.dangdang.com/?key={}&act=input'   # 当当搜索页 URL 模板
HEADERS = {
    # 伪装浏览器 UA,避免被服务器拒绝请求
    '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(keyword):
    """按关键词构造搜索 URL,返回 gb2312 解码后的页面文本"""
    # 当当搜索参数使用 GBK 编码,"书包" -> %CA%E9%B0%FC
    url = SEARCH_URL.format(quote(keyword.encode('gbk')))
    resp = requests.get(url, headers=HEADERS, timeout=15)
    resp.raise_for_status()          # 状态码非 2xx 时抛出异常
    resp.encoding = 'gb2312'         # 当当搜索页编码为 gb2312,手动指定避免乱码
    return resp.text


def parse_goods(html):
    """用正则表达式提取商品价格与名称,返回 [(价格, 商品名), ...] 列表"""
    goods = []
    # 截取搜索结果商品列表区域(<ul class="bigimg cloth_shoplist">)
    area_match = re.search(r'<ul class="bigimg cloth_shoplist".*?</ul>', html, re.S)
    area = area_match.group(0) if area_match else html
    # 每个商品对应一个 <li ddt-pit=...> 块,先按块分割再在块内分别提取
    for block in re.split(r'<li ddt-pit=', area)[1:]:
        # 商品图片链接 <a title="商品名" ... class="pic"> 上的 title 即商品名
        name_match = re.search(r'title="([^"]+)"[^>]*class="pic"', block)
        # 价格形如 <span class="price_n">&yen;89.99</span>
        price_match = re.search(
            r'<span class="price_n">(?:&yen;|¥)\s*([\d.]+)', block)
        if name_match and price_match:
            name = name_match.group(1).strip()
            # 过滤搜索结果中混入的无关推广商品(如图书广告),
            # 仅保留名称含包类关键词的商品;"书包(?!含)"用于排除
            # "随书包含xxx"这类广告文案造成的误匹配
            if re.search(r'书包(?!含)|背包|双肩包|挎包', name):
                goods.append((price_match.group(1), name))
    return goods


def display_width(text):
    """计算字符串显示宽度:中文等全角字符按 2 个单位计"""
    return sum(2 if ord(ch) > 127 else 1 for ch in text)


def pad(text, width):
    """按显示宽度右侧补空格,实现表格中英文对齐"""
    return text + ' ' * max(width - display_width(text), 1)


def print_goods(goods):
    """按题目要求格式打印商品比价信息"""
    print('序号    价格        商品名')
    for i, (price, name) in enumerate(goods, 1):
        # 商品名过长时截断,保证输出整齐
        short_name = name if display_width(name) <= 50 else \
            name[:46] + '...'
        print(f'{i:<4d}   {pad(price, 10)}  {short_name}')


def main():
    """主流程:构造请求 -> 正则解析 -> 打印比价结果"""
    html = get_html('书包')
    goods = parse_goods(html)
    print(f'共爬取到 {len(goods)} 件商品:\n')
    print_goods(goods)


if __name__ == '__main__':
    main()

运行结果(共 57 件商品,此处截取前 10 条):

共爬取到 57 件商品:

序号    价格        商品名
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      88.00       新款库洛米女孩三到六减负超轻便一年级儿童小学生大容量护脊书包...
9      119.00      书包男女生 小学生书包男孩女孩一二三四五六年级初中生大容量轻便休闲双肩包...
10     239.00      瑞牌小学生书包男生减负护脊轻便儿童书包四到六年级女生高颜值书包新款1-6年级耐磨背包...

屏幕截图 2026-10-20 222423

2.2 作业心得

  1. 中文字符的 GBK 编码是第一个坑。 当当搜索参数"书包"必须以 GBK 编码放入 URL(%CA%E9%B0%FC),如果直接用 UTF-8 编码请求,返回的将是空结果页。我用 keyword.encode('gbk') 配合 urllib.parse.quote 解决;页面正文也是 gb2312 编码,需手动设置 resp.encoding。
  2. 正则的分块提取策略。 商品名和价格分散在同一个 <li> 块的不同标签中,如果对整个页面直接 findall,两组列表的下标可能对不齐(页面中还有其它含 title 或价格的标签)。我先用 re.split(r'<li ddt-pit=', ...) 把每个商品切成独立的块,再在块内分别用 re.search 提取标题和价格,保证了名称与价格一一对应。
  3. 数据清洗意识。 最初版本爬到了"仙逆1"等图书——它们是当当插入搜索结果流中的推广商品(ddt-pit=8 起连续几个广告位),与"书包"关键词完全无关。我先尝试把解析范围限定在搜索结果列表区域,发现广告本身就混在该区域内,无法靠位置区分;最终改为按标题相关性过滤(保留名称含"书包/背包/双肩包/挎包"的商品),并针对"随书包含xxx"这类广告文案用负向断言 (?!含) 排除误匹配,过滤后得到 57 件真实书包商品。这让我体会到:定向爬虫不仅要"爬得到",还要"爬得准",电商页面的内嵌广告必须靠数据清洗来剔除。

三、作业③:爬取网页内所有 JPEG/JPG/PNG 图片

要求:爬取给定网页 https://news.fzu.edu.cn/yxfd.htm(福州大学新闻网"研学风采"栏目)内所有 JPEG、JPG 或 PNG 格式图片,保存到一个文件夹中。

3.1 代码与运行结果

# -*- coding: utf-8 -*-
"""
作业③:爬取网页内所有 JPEG/JPG/PNG 图片并保存到本地文件夹
网页:https://news.fzu.edu.cn/yxfd.htm(福州大学新闻网"研学风采"栏目)
库:requests + BeautifulSoup
"""
import os
import re
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

PAGE_URL = 'https://news.fzu.edu.cn/yxfd.htm'   # 目标网页
SAVE_DIR = 'images'                             # 图片保存文件夹
HEADERS = {
    # 伪装浏览器 UA,避免被服务器拒绝请求
    '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_soup(url):
    """请求网页,返回 BeautifulSoup 对象"""
    resp = requests.get(url, headers=HEADERS, timeout=15)
    resp.raise_for_status()          # 状态码非 2xx 时抛出异常
    resp.encoding = 'utf-8'          # 目标网页编码为 utf-8
    return BeautifulSoup(resp.text, 'html.parser')


def find_images(soup):
    """提取页面内所有 jpg/jpeg/png 图片的绝对 URL(自动去重、保持顺序)"""
    urls = []
    for img in soup.find_all('img'):         # 遍历所有 <img> 标签
        src = img.get('src')                 # 图片相对/绝对地址
        if not src:
            continue
        full_url = urljoin(PAGE_URL, src)    # 相对路径拼接为绝对 URL
        # 去掉查询参数后判断扩展名是否为 jpg/jpeg/png(如 ...1F7?e=.png)
        if re.search(r'\.(jpe?g|png)(\?|$)', full_url, re.I) \
                and full_url not in urls:
            urls.append(full_url)
    return urls


def url_to_filename(url):
    """根据图片 URL 生成本地文件名,路径无扩展名时从查询参数补全"""
    path = url.split('?')[0]
    filename = path.rstrip('/').split('/')[-1]      # 取 URL 路径最后一段
    if not re.search(r'\.(jpe?g|png)$', filename, re.I):
        # 形如 ".../1F7?e=.png" 的地址:从查询参数 e=.png 中取扩展名
        match = re.search(r'[?&]e=(\.[a-z]+)', url, re.I)
        ext = match.group(1) if match else '.png'
        filename += ext
    return filename


def download_images(urls):
    """逐张下载图片到 SAVE_DIR 文件夹"""
    os.makedirs(SAVE_DIR, exist_ok=True)    # 文件夹不存在则自动创建
    for i, url in enumerate(urls, 1):
        try:
            resp = requests.get(url, headers=HEADERS, timeout=15)
            resp.raise_for_status()
            filename = url_to_filename(url)
            filepath = os.path.join(SAVE_DIR, filename)
            with open(filepath, 'wb') as f:         # 二进制方式写入图片
                f.write(resp.content)
            print(f'{i:2d}. 已下载 {filename}  ({len(resp.content) / 1024:.1f} KB)')
        except requests.RequestException as err:
            print(f'{i:2d}. 下载失败 {url} -> {err}')


def main():
    """主流程:解析页面 -> 提取图片链接 -> 批量下载"""
    soup = get_soup(PAGE_URL)
    urls = find_images(soup)
    print(f'共发现 {len(urls)} 张 JPEG/JPG/PNG 图片,开始下载...\n')
    download_images(urls)
    print(f'\n全部图片已保存至文件夹 {SAVE_DIR}/')


if __name__ == '__main__':
    main()

运行结果:

共发现 19 张 JPEG/JPG/PNG 图片,开始下载...

 1. 已下载 top_search.png  (0.5 KB)
 2. 已下载 logo.png  (26.6 KB)
 3. 已下载 footer_logo.png  (24.8 KB)
 4. 已下载 icon_bnt_back2.png  (0.6 KB)
 5. 已下载 bnt_sub_nav2.png  (0.4 KB)
 6. 已下载 n_left_ico1.png  (0.2 KB)
 7. 已下载 n_left_ico2.png  (0.2 KB)
 8. 已下载 EE9C07C6486DEFAA9EAF683C37C_48D55A5D_F84E4.jpg  (993.2 KB)
 9. 已下载 A81DCC70A190C301F32A2CD9710_57503CF4_12A9BA.png  (1194.4 KB)
10. 已下载 4EF56A35EF9583D950FE3F8A85C_8FCE4F24_2C08E.jpg  (176.1 KB)
11. 已下载 FD18078151A13ACF11663A5200C_DEA986BA_635B33.png  (6358.8 KB)
12. 已下载 72D850F90F27AD5F420C5659716_ED088B7C_141463.jpg  (1285.1 KB)
13. 已下载 38496C338E7D3F26F02248A99ED_6621F65A_8B82F1.png  (8928.7 KB)
14. 已下载 5B42E35E337E9167CA9E61E1DC4_A87D02A0_D13E9.png  (837.0 KB)
15. 已下载 1F3D77933484E42FBCAF0681489_B9E72BFA_288DAE.png  (2595.4 KB)
16. 已下载 BD5C51D961F315A61C47FD0AEC9_70BA2916_3A880.jpg  (234.1 KB)
17. 已下载 10F0677E72A0B0DCCF5F798C6DB_865A6721_1F7.png  (0.5 KB)
18. 已下载 0B2EC59649CA13BAC85941A8B12_D497950A_19B.png  (0.4 KB)
19. 已下载 9D5EF45533E69056CF06F1A0115_9201CE3A_198.png  (0.4 KB)

全部图片已保存至文件夹 images/

屏幕截图 2026-10-20 222326

屏幕截图 2026-10-20 222201

3.2 作业心得

  1. 相对路径与绝对路径的拼接。 页面中 <img> 的 src 大多是相对路径(如 /__local/.../xxx.jpg、images/logo.png),直接访问会 404。我用 urllib.parse.urljoin(页面URL, src) 自动拼接出完整 URL,这是处理图片、CSS 等静态资源链接的通用方法。
  2. 文件名与扩展名的鲁棒性。 福大站点的部分图片地址形如 .../1F7?e=.png——URL 路径本身没有扩展名,真正的格式藏在查询参数里。我编写的 url_to_filename() 会先取路径末段,无扩展名时再从查询参数中补全,保证每张图片都以正确的格式保存。图片必须用 'wb' 二进制模式写入,否则文件会损坏。
  3. 异常处理保证批量下载的完整性。 批量下载时任何一张图失败(超时、404)都不应中断整个流程,我用 try/except requests.RequestException 捕获单张失败并继续,最后 19 张图片全部成功保存到 images 文件夹。
  4. 一点延伸思考。 用 BeautifulSoup 的 find_all('img') 配合 re 过滤扩展名,比纯正则解析整个 HTML 更可靠,两者结合各司其职——这也回应了三种解析思路(BS4 / re / xpath)在不同场景下的取舍。
posted @ 2026-10-20 22:26  Ron_g  阅读(6)  评论(0)    收藏  举报