数据采集作业
软件工程作业1 · 网络爬虫基础(requests 库实战)
运行环境:Windows 11 · Python 3.14.7 · requests · beautifulsoup4 · lxml
代码仓库:https://gitee.com/zhuang-jianyi/data-collection-task
本次作业一共三道题,刚好覆盖三种最常见的爬虫写法:用 BeautifulSoup 解析 DOM 结构、用正则表达式提取字段、以及批量下载二进制资源。三份代码都按 Python 规范书写,关键函数和变量都加了注释。
一、作业①:爬取软科中国大学排名
1.1 代码与运行结果
题目要求:用 requests 和 BeautifulSoup 库方法定向爬取 http://www.shanghairanking.cn/rankings/bcur/2020 的数据,屏幕打印爬取的大学排名信息,输出列为「排名 / 学校名称 / 省市 / 学校类型 / 总分」。
# -*- coding: utf-8 -*-
"""
作业①:爬取上海软科中国大学排名
================================
要求:用 requests 和 BeautifulSoup 库方法定向爬取给定网址
http://www.shanghairanking.cn/rankings/bcur/2020
的数据,屏幕打印爬取的大学排名信息。
输出:排名 学校名称 省市 学校类型 总分
实现说明
--------
1. 页面是服务端渲染的,排名数据就在 HTML 的 <table> 里,不需要额外的接口。
2. 每行 6 个 <td>,依次是:排名 / 学校名称 / 省市 / 学校类型 / 总分 / 办学层次得分。
其中「学校名称」格子里同时有中文名、英文名和办学层次标签,
用 CSS 选择器 .name-cn 才能只取中文名。
3. 一页 30 条。该网站的翻页是前端异步请求完成的,直接在 URL 上加 page 参数
返回的仍然是第一页,所以本程序解析首页的这 30 条、验证解析逻辑正确即可。
若要拿到全部 567 所,需要另外找到它前端调用的数据接口。
运行: python 1.py
"""
import requests
from bs4 import BeautifulSoup
# 请求头:带上 User-Agent,避免被当成爬虫直接拒绝
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",
}
# 排名页地址(2020 软科中国大学排名主榜)
BASE_URL = "http://www.shanghairanking.cn/rankings/bcur/2020"
def fetch_page(url, timeout=20):
"""请求页面并返回解码后的 HTML 文本。
软科返回的响应头里 charset 有时不准,所以先用 apparent_encoding 猜一次,
猜不出来再退回 utf-8,避免中文校名变成乱码。
"""
response = requests.get(url, headers=HEADERS, timeout=timeout)
response.raise_for_status()
response.encoding = response.apparent_encoding or "utf-8"
return response.text
def parse_ranking(html):
"""从 HTML 里解析出排名列表。
返回一个列表,每个元素是字典:
{"rank": 排名, "name": 学校名称, "province": 省市,
"category": 学校类型, "score": 总分}
"""
soup = BeautifulSoup(html, "lxml")
table = soup.find("table")
if table is None:
return []
results = []
for tr in table.find("tbody").find_all("tr"):
cells = tr.find_all("td", recursive=False)
# 正常数据行有 6 列;少于 5 列说明结构变了,跳过免得抛异常
if len(cells) < 5:
continue
# 学校名称格子里含中英文和标签,用 .name-cn 精确取中文名
name_node = tr.select_one(".name-cn")
results.append({
"rank": cells[0].get_text(strip=True),
"name": name_node.get_text(strip=True) if name_node else "",
"province": cells[2].get_text(strip=True),
"category": cells[3].get_text(strip=True),
"score": cells[4].get_text(strip=True),
})
return results
def print_ranking(items):
"""按表格样式打印排名信息。"""
# 各列宽度:中文按 2 个字符宽计算,保证表格对齐
widths = (6, 22, 8, 10, 8)
header = ("排名", "学校名称", "省市", "学校类型", "总分")
line = "-" * (sum(widths) + 4 * 2)
print(line)
print("".join(h.ljust(w) for h, w in zip(header, widths)))
print(line)
for it in items:
row = (it["rank"], it["name"], it["province"],
it["category"], it["score"])
# 中文占两个显示宽度,用 ljust 前先按显示宽度补空格
print("".join(_pad(v, w) for v, w in zip(row, widths)))
print(line)
print(f"共 {len(items)} 条(当前页)")
def _pad(text, width):
"""按显示宽度左对齐补空格:中文算 2 格,英文算 1 格。"""
display = sum(2 if ord(ch) > 0x2000 else 1 for ch in text)
return text + " " * max(1, width - display)
def main():
print(f"正在爬取:{BASE_URL}\n")
html = fetch_page(BASE_URL)
items = parse_ranking(html)
print_ranking(items)
if __name__ == "__main__":
main()
在 作业1 目录下执行 python 1.py,运行结果:

解析思路:这个页面的排名数据是服务端渲染的,直接躺在 <table> 标签里,所以不需要去翻接口。顺着 table → tbody → tr → td 往下走就行:每个数据行有 6 个 <td>,依次是排名、学校名称、省市、学校类型、总分、办学层次得分,取前 5 个正好对上题目要求的 5 列。
两个踩过的坑:
- 学校名称那一格不干净。 这个
<td>里同时塞了中文名、英文名和「双一流」之类的标签。一开始我直接cells[1].get_text(),结果清华大学被打印成一长串中英文混排。改用 CSS 选择器.name-cn只取中文名之后才干净。 - 中文表格对不齐。
str.ljust()数的是字符个数,而一个汉字在等宽终端里占两个字符宽,直接用ljust会让所有列参差不齐。所以额外写了个_pad():先用ord(ch) > 0x2000判断是不是宽字符,再按显示宽度补空格。
1.2 作业心得
这道题让我第一次意识到,「能取到数据」和「取到的数据是干净的」完全是两件事。第一次跑通时屏幕上确实有 30 行,但学校名称那一列全是「清华大学 Tsinghua University 双一流」这种长串,看起来能跑,其实是不合格的。真正让我找到原因的是把某个 <td> 的 HTML 单独打印出来看了一眼——很多时候调试爬虫最有效的办法就是把目标节点原样 dump 出来,而不是盯着最终结果猜。
中文对齐这个小问题算是自己给自己找的麻烦,但顺手写 _pad() 的过程中,我把「字符长度」和「显示宽度」这两个一直混着用的概念彻底分清楚了。
另外还试了一下翻页。本来想加个页码参数把全部 567 所都抓下来,实测在 URL 后面加 ?page=2 返回的仍然是第一页——说明这个网站的翻页是前端异步拉取的,服务端只渲染第一页。所以代码里我如实注明只解析首页 30 条。这件事提醒我:写爬虫之前最好先开 F12 确认分页到底是怎么实现的,比写完再回头调试省事得多。
二、作业②:商城商品比价爬虫(当当网「书包」)
2.1 代码与运行结果
题目要求:用 requests 和 re 库方法设计某个商城商品比价定向爬虫,以关键词「书包」搜索页面,爬取商品名称和价格,输出「序号 / 价格 / 商品名」。
商城选择说明:课件原本以淘宝为例,但淘宝的搜索页需要登录态并且有较强的反爬校验,直接用 requests 请求只能拿到验证页面。我改选当当网——它的搜索结果是服务端渲染的静态 HTML,商品名和价格都能在源码里直接定位,分页参数也很清晰,正好用来练习「定向爬虫 + 正则提取 + 翻页」。
# -*- coding: utf-8 -*-
"""
作业②:商城商品比价定向爬虫
============================
要求:用 requests 和 re 库方法设计某个商城(自己选择)商品比价定向爬虫,
爬取该商城,以关键词「书包」搜索页面的数据,爬取商品名称和价格。
输出:序号 价格 商品名
商城选择说明
------------
课件原本以淘宝为例,但淘宝的搜索页需要登录态并带有较强的反爬校验,
直接请求只能拿到验证页面。这里选择**当当网**:它的搜索结果是服务端渲染的
静态 HTML,商品名和价格都能在源码中直接定位,分页参数也很清晰,
适合练习「定向爬虫 + 正则提取 + 翻页」。
搜索接口
--------
http://search.dangdang.com/?key=<关键词>&act=input&page_index=<页码>
key 关键词,需要 URL 编码(「书包」→ %E4%B9%A6%E5%8C%85)
act=input 表示从搜索框输入进入
page_index 页码,从 1 开始,改它就能翻页
页面结构
--------
<p class="price"><span class="price_n">¥146.00</span></p>
<p class="name" name="title"><a title="商品全名" href="//...">…</a></p>
注意两点:
1) 价格里的 ¥ 在源码中是 HTML 实体 ¥,不是字符「¥」;
2) 商品名的 <a> 里被插入了 <font class="skcolor_ljg">书包</font>
高亮标签,直接取标签内文本会把关键词拆开,所以取 title 属性更干净。
运行: python 2.py
"""
import re
import sys
import time
import urllib.parse
import requests
# 搜索关键词
KEYWORD = "书包"
# 抓取页数与每页条数:3 页 × 20 条 = 60 条
PAGES = 3
PER_PAGE = 20
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",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}
# 价格:<span class="price_n">¥146.00</span>
RE_PRICE = re.compile(r'<span class="price_n">\s*¥\s*([\d.]+)\s*</span>')
# 商品名:<p class="name" ...><a title="商品全名" ...>
RE_NAME = re.compile(r'<p class="name"[^>]*>\s*<a\s+title="([^"]+)"')
# HTML 里声明的编码,用于判断按什么解码
RE_CHARSET = re.compile(r'charset=["\']?([\w-]+)', re.I)
# 搜索接口模板,仅用于打印提示信息(<关键词>、<页码> 由 build_url 填入)
SEARCH_API = ("http://search.dangdang.com/?key=<关键词>"
"&act=input&page_index=<页码>")
def build_url(keyword, page):
"""拼出搜索接口的 URL —— 翻页就是改 page_index。"""
return ("http://search.dangdang.com/?key="
f"{urllib.parse.quote(keyword)}&act=input&page_index={page}")
def fetch(url, retry=3):
"""请求页面并按正确的编码解码(当当是 GBK)。"""
last_error = None
for attempt in range(1, retry + 1):
try:
response = requests.get(url, headers=HEADERS, timeout=20)
response.raise_for_status()
# 先看 HTTP 头,再看 HTML 里的 <meta charset>,都没有就按 gbk
encoding = response.encoding or "gbk"
if not encoding or encoding.lower() in ("iso-8859-1", "ascii"):
match = RE_CHARSET.search(response.text[:4000])
encoding = match.group(1) if match else "gbk"
if encoding.lower() in ("gb2312", "gbk"):
encoding = "gb18030" # gb18030 是 gbk 的超集,容错更好
return response.content.decode(encoding, "ignore")
except Exception as error: # noqa: BLE001
last_error = error
print(f" [重试 {attempt}/{retry}] {type(error).__name__}: {error}")
time.sleep(2)
raise RuntimeError(f"抓取失败:{url}({last_error})")
def parse_items(html, limit=None):
"""用正则从 HTML 中提取 (商品名称, 价格) 列表。
页面中每个商品块都是「价格在前、名称在后」,且两者数量相等,
因此直接按出现顺序 zip 配对即可。
"""
names = [n.strip() for n in RE_NAME.findall(html)]
prices = RE_PRICE.findall(html)
items = []
for name, price in zip(names, prices):
# 合并名称中多余的空白字符
name = re.sub(r"\s+", " ", name).strip()
try:
items.append((name, float(price)))
except ValueError:
continue
return items[:limit] if limit else items
def print_items(items):
"""按「序号 价格 商品名」的格式打印。"""
print(f"{'序号':<6}{'价格':>10} 商品名")
print("-" * 78)
for index, (name, price) in enumerate(items, 1):
short = name if len(name) <= 42 else name[:41] + "…"
print(f"{index:<8}{price:>8.2f} {short}")
def main():
print("=" * 78)
print(f"商城商品比价定向爬虫 商城:当当网 关键词:{KEYWORD}")
print(f"搜索接口:{SEARCH_API}")
print(f"抓取策略:{PAGES} 页 × 每页 {PER_PAGE} 条 = {PAGES * PER_PAGE} 条")
print("=" * 78)
all_items = []
for page in range(1, PAGES + 1):
url = build_url(KEYWORD, page)
print(f"\n[第 {page} 页] {url}")
html = fetch(url)
print(f" 返回 HTML {len(html)} 字符,"
f"页面中价格标签 {len(RE_PRICE.findall(html))} 个、"
f"商品名标签 {len(RE_NAME.findall(html))} 个")
items = parse_items(html, limit=PER_PAGE)
print(f" 本页取前 {len(items)} 条")
all_items.extend(items)
time.sleep(1) # 放慢速度,避免给对方服务器压力
print("\n" + "=" * 78)
print(f"共获取 {len(all_items)} 条商品信息")
print("=" * 78)
print_items(all_items)
if all_items:
prices = [p for _, p in all_items]
print("-" * 78)
print(f"价格统计:最低 {min(prices):.2f} 元 最高 {max(prices):.2f} 元 "
f"平均 {sum(prices) / len(prices):.2f} 元")
if __name__ == "__main__":
main()
抓取过程(3 页 × 每页 20 条):

商品清单与价格统计:

本次共抓取 60 条商品,价格统计:最低 38.00 元、最高 756.00 元、平均 121.06 元。
两条正则是整个程序的核心:
# 价格:<span class="price_n">¥146.00</span>
RE_PRICE = re.compile(r'<span class="price_n">\s*¥\s*([\d.]+)\s*</span>')
# 商品名:<p class="name" ...><a title="商品全名" ...>
RE_NAME = re.compile(r'<p class="name"[^>]*>\s*<a\s+title="([^"]+)"')
2.2 作业心得
为什么换商城。 一开始我老老实实照课件去请求淘宝,拿回来的是一个几十 KB 的验证/登录页面,re.findall 一个价格都匹配不到。换成当当之后第一次请求就拿到了 232812 个字符的完整 HTML。这让我明白:定向爬虫的「定向」不只是指选一个网址,更是指选一个技术上门槛匹配的目标——对练习正则提取来说,服务端渲染的页面才是合适的靶子。
正则踩的三个坑:
- 价格里的 ¥ 不是 ¥。 源码里写的是 HTML 实体
¥,如果正则里直接写¥会一条都匹配不到。所以正则里必须写成¥。 - 商品名里有高亮标签。 当当会把命中的关键词用
<font class="skcolor_ljg">书包</font>包起来,如果取<a>标签内的文本,商品名会在关键词处被切断。改取title属性就完全干净了——属性值里不会插标签,这是取文本时一个很实用的小技巧。 - 编码。 当当返回的是 GBK,但 HTTP 响应头有时给的是
iso-8859-1,直接 decode 就是乱码。我的处理是:先看响应头,不可信再看 HTML 里的<meta charset>,最后统一用gb18030解码兜底——gb18030是gbk的超集,遇到生僻字也不会抛异常。
关于 zip 配对。 页面里每个商品块都是「价格在前、名称在后」,而且两者数量严格相等,所以直接按出现顺序 zip 配对即可。为了确认没有错位,我在最后加了一个价格统计(最低 / 最高 / 平均)。这一步其实是一道自检:如果正则写错导致价格和商品名错配,或者把「划线原价」也匹配进去了,统计结果会明显不合理。看到平均 121 元、最低 38 元、最高 756 元这个区间,就知道 60 条数据基本是可信的。
礼貌爬取。 翻页之间 sleep(1),不给对方服务器压力;只抓公开的搜索结果页,不涉及任何需要登录的数据。
三、作业③:网页图片批量下载
3.1 代码与运行结果
题目要求:爬取给定网页 https://news.fzu.edu.cn/yxfd.htm(福州大学新闻中心「院系风采」栏目)的所有 JPEG、JPG 或 PNG 格式图片文件,保存在一个文件夹中。
# -*- coding: utf-8 -*-
"""
作业③:批量下载网页中的图片
============================
要求:爬取一个给定网页( https://news.fzu.edu.cn/yxfd.htm )或者自选网页的
所有 JPEG、JPG 或 PNG 格式图片文件,保存在一个文件夹中。
输出:把页面里所有 jpg / jpeg / png 图片保存到与本脚本同级的 images/ 目录
实现说明
--------
1. 用 BeautifulSoup 取出页面里所有 <img> 标签,再逐个看 src 属性。
2. 页面里的图片地址有两种写法:
images/logo.png ← 相对路径
/__local/F/4D/B3/xxxx.jpg ← 以 / 开头的站内绝对路径
统一用 urljoin(页面地址, src) 转成完整地址,否则下载必然 404。
3. 有的地址带查询串(如 `xxx.png?e=.png`),判断扩展名和取文件名时都要
先把 `?` 后面的部分去掉,否则会把 `png?e=.png` 当成扩展名。
4. 同一张图可能出现多次(页头页脚各一份 logo),用集合去重避免重复下载。
5. 用二进制方式写文件:图片不是文本,用 'wb' 才不会损坏内容。
运行: python 3.py # 使用默认网页
python 3.py <网页地址> # 爬取自选网页
"""
import os
import re
import sys
import time
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
# 默认网页:福州大学新闻中心「院系风采」栏目
DEFAULT_URL = "https://news.fzu.edu.cn/yxfd.htm"
# 只下载这三种格式
WANTED_EXT = (".jpg", ".jpeg", ".png")
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/",
}
# 保存目录:与本脚本同级,避免依赖运行时的当前目录
SAVE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "images")
# 匹配以 .jpg/.jpeg/.png 结尾(后面可以跟查询串)
RE_IMAGE = re.compile(r"\.(jpe?g|png)(?:\?.*)?$", re.I)
def fetch_html(url, timeout=20):
"""请求网页并返回解码后的 HTML 文本。"""
response = requests.get(url, headers=HEADERS, timeout=timeout)
response.raise_for_status()
# 福大新闻页声明的是 UTF-8-SIG,用 apparent_encoding 猜一次更稳
response.encoding = response.apparent_encoding or "utf-8"
return response.text
def collect_image_urls(html, base_url):
"""从 HTML 中取出所有 jpg / jpeg / png 图片的完整地址。
返回去重后的地址列表,并保持页面中出现的先后顺序。
"""
soup = BeautifulSoup(html, "lxml")
urls, seen = [], set()
for img in soup.find_all("img"):
# 有的站点把真实地址放在 data-src(懒加载),这里一并考虑
src = img.get("src") or img.get("data-src") or ""
src = src.strip()
if not src or not RE_IMAGE.search(src):
continue
# 相对地址转成完整地址
full = urljoin(base_url, src)
if full not in seen:
seen.add(full)
urls.append(full)
return urls
def safe_filename(url, index):
"""根据地址生成一个安全的文件名,前面带上序号避免重名。"""
path = urlparse(url).path # 去掉查询串
name = os.path.basename(path)
# 去掉文件名里可能引起问题的字符
name = re.sub(r"[^\w.\-\u4e00-\u9fff]", "_", name)
if not name or not RE_IMAGE.search(name):
name = "image.png"
return f"{index:02d}_{name}"
def download(url, path, timeout=25):
"""下载单个图片到指定路径,成功返回文件的字节数。"""
response = requests.get(url, headers=HEADERS, timeout=timeout)
response.raise_for_status()
with open(path, "wb") as fh: # 二进制写入,不能用 'w'
fh.write(response.content)
return len(response.content)
def main():
# 支持在命令行传入自选网页
page_url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URL
print("=" * 70)
print("网页图片批量下载")
print(f"目标网页:{page_url}")
print(f"保存目录:{SAVE_DIR}")
print("=" * 70)
html = fetch_html(page_url)
print(f"\n页面下载完成,共 {len(html)} 字符")
urls = collect_image_urls(html, page_url)
print(f"发现 jpg / jpeg / png 图片 {len(urls)} 个\n")
os.makedirs(SAVE_DIR, exist_ok=True)
ok, failed, total_bytes = 0, [], 0
for index, url in enumerate(urls, 1):
filename = safe_filename(url, index)
path = os.path.join(SAVE_DIR, filename)
try:
size = download(url, path)
ok += 1
total_bytes += size
print(f" [{index:>2}/{len(urls)}] 成功 {size / 1024:>8.1f} KB {filename}")
except Exception as error: # noqa: BLE001
failed.append((url, str(error)))
print(f" [{index:>2}/{len(urls)}] 失败 {type(error).__name__} {url}")
time.sleep(0.3) # 放慢速度,避免给对方服务器压力
print("\n" + "=" * 70)
print(f"下载完成:成功 {ok} 个,失败 {len(failed)} 个,"
f"合计 {total_bytes / 1024:.1f} KB")
print(f"文件已保存在:{SAVE_DIR}")
if failed:
print("\n以下图片下载失败:")
for url, reason in failed:
print(f" {url}\n {reason}")
if __name__ == "__main__":
main()
执行 python 3.py,下载过程:

最终把页面里的 19 张图片全部下载成功,合计 22.6 MB,保存在 作业1/images/ 目录下:

3.2 作业心得
这道题最核心的一点是地址补全。 页面里的 <img> 有两种写法:一种是相对路径 images/logo.png,另一种是以 / 开头的站内绝对路径 /__local/F/4D/B3/xxxx.jpg。如果直接拿 src 去 requests.get(),两种都会 404。正确做法是用 urljoin(页面地址, src) 统一转成完整地址——它能自动处理相对路径和站内绝对路径两种情况。一开始我只处理了相对路径,结果大部分图片都下载失败,就是栽在这里。
另外几个细节:
- 查询串要先剥掉。 有些地址长这样:
xxx.png?e=.png。判断扩展名和取文件名之前必须先把?后面的部分去掉,否则会把png?e=.png当成扩展名,文件名也会变成一长串乱码。我用urlparse(url).path拿干净的路径。 - 图片必须用二进制模式写。
open(path, 'wb')而不是'w',用文本模式打开会把二进制内容破坏掉。 - 去重。 页头页脚的 logo 会在同一页出现多次,用集合去重后 19 张没有重复下载。
- 保存目录要稳。 我用
os.path.dirname(os.path.abspath(__file__))定位到脚本所在目录,这样不管从哪个目录执行python 3.py,图片都会存到作业1/images/下,不会跑到当前工作目录里去。 - 异常不能中断整个任务。 单张图片下载失败只记录到失败列表,继续下载剩下的,最后统一汇报「成功几个、失败几个」。
关于合规和礼貌。 脚本只下载公开页面的图片、请求之间 sleep(0.3) 限速、并且支持从命令行传入任意自选网页地址。我觉得爬虫的边界感很重要:拿到自己需要的数据就够了,不要为了跑满 CPU 而并发猛冲别人的服务器。

浙公网安备 33010602011771号