数据采集第一次作业
数据采集第一次作业
实验环境:Windows 11(64 位)|Python 3.13.2|requests 2.32.5|beautifulsoup4 4.13.5|lxml 6.0.2|编辑工具 VS Code
本次作业三个题目分别练三件事:用 bs4 解析表格、用正则解析列表、批量下载文件。三份代码都按函数拆分,关键处写了注释说明「为什么这么写」而不是「这行在干嘛」。
作业①:爬取软科中国大学排名
1)、爬取软科大学排名实验
目标:爬取 http://www.shanghairanking.cn/rankings/bcur/2020,按「排名 / 学校名称 / 省市 / 学校类型 / 总分」打印。
实验思路
页面上的排名是一张标准表格,所以走 requests 下载 → BeautifulSoup 解析 → find_all("tr") 逐行取 <td> 的路线。三步里真正麻烦的是两件事:
一是编码。这个站点的响应头里没有声明 charset,requests 会按 ISO-8859-1 去猜,中文会全乱。解决办法是把 resp.encoding 手动设成 resp.apparent_encoding,让它按内容自动探测。
二是「学校名称」那一格的清洗。这一格的真实内容长这样:
<td class="align-left">
<img alt="清华大学" class="univ-logo" src="...">
<div class="univname">清华大学 Tsinghua University 双一流/985/211</div>
</td>
直接 get_text() 出来是 清华大学 Tsinghua University 双一流/985/211,和题目要求的「清华大学」差得远。一开始想用空格切分取第一段,但这是靠格式碰运气——万一以后出现带空格的中文校名就错了。后来发现中文校名同时写在校徽图片的 alt 属性里,取 alt 才是最干净的来源。
完整代码
# -*- coding: utf-8 -*-
r"""实践作业 · 作业①:定向爬取软科中国大学排名
目标网页:http://www.shanghairanking.cn/rankings/bcur/2020
运行方式:python 01_爬取大学排名.py
输出字段:排名 / 学校名称 / 省市 / 学校类型 / 总分
整体思路:
1. requests 请求页面,拿到 HTML
2. BeautifulSoup 解析,找出表格里的所有 <tr>
3. 跳过第一行表头,逐行取出 5 个 <td>
4. 其中「学校名称」那一格混了中英文和标签,需要单独处理
5. 按对齐的格式打印
说明:这个页面的 HTML 里服务端只渲染了前 30 条数据(页面自己写着「共 567 所学校」,
翻页是前端 JavaScript 做的),所以本程序抓取并打印的是第 1 页的 30 所高校。
"""
import re
import sys
import requests
from bs4 import BeautifulSoup
# ==================== 可配置项 ====================
PAGE_URL = "http://www.shanghairanking.cn/rankings/bcur/2020"
TIMEOUT = 30 # 请求超时时间(秒)
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"),
"Accept-Language": "zh-CN,zh;q=0.9",
}
# 每一列在 <tr> 里对应的 <td> 下标,写成常量方便日后页面改版时调整
COL_RANK = 0 # 排名
COL_NAME = 1 # 学校名称(混合了中英文与标签)
COL_PROVINCE = 2 # 省市
COL_TYPE = 3 # 学校类型
COL_SCORE = 4 # 总分
def fetch_page(url):
"""请求页面并返回 HTML 文本,失败时返回 None。"""
session = requests.Session()
session.headers.update(HEADERS)
# 不让 requests 自动使用系统代理:本机环境变量里的代理端口不固定,直连更稳
session.trust_env = False
try:
resp = session.get(url, timeout=TIMEOUT)
# 这个站点响应头里没有声明 charset,requests 会按 Latin-1 去猜,
# 中文就会乱码,所以必须手动指定成自动探测到的编码
resp.encoding = resp.apparent_encoding or "utf-8"
return resp.text
except requests.RequestException as err:
print("请求失败:%s" % err)
return None
def get_university_name(cell):
"""从「学校名称」单元格里提取中文校名。
这一格的内容长这样:<img alt="清华大学"> 清华大学 Tsinghua University 双一流/985/211
直接 get_text() 得到的是「清华大学 Tsinghua University 双一流/985/211」,
不符合输出要求。
中文校名同时也写在网站校徽图片的 alt 属性里,取 alt 最干净;
万一某一行没有图片,再退回到「取第一个空格前的部分」这种兜底做法。
"""
img = cell.find("img")
if img and img.get("alt"):
return img["alt"].strip()
text = cell.get_text(" ", strip=True)
return text.split(" ")[0] if text else ""
def parse_ranking(html):
"""解析 HTML,返回排名列表。
参数:
html -- 页面 HTML 文本
返回:
list,元素为 dict,含 rank / name / province / type / score 五个键
"""
soup = BeautifulSoup(html, "html.parser")
rows = []
for tr in soup.find_all("tr"):
tds = tr.find_all("td")
if len(tds) <= COL_SCORE:
continue # 表头行或被合并的行,列数不够,跳过
rank_text = tds[COL_RANK].get_text(strip=True)
# 排名列必须是纯数字,用这个条件把表头行排除掉
if not rank_text.isdigit():
continue
rows.append({
"rank": rank_text,
"name": get_university_name(tds[COL_NAME]),
"province": tds[COL_PROVINCE].get_text(strip=True),
"type": tds[COL_TYPE].get_text(strip=True),
"score": tds[COL_SCORE].get_text(strip=True),
})
return rows
def pad(text, width):
"""按显示宽度补齐空格。
中文在终端里占两个字符宽度,str.ljust 是按字符数算的,中英混排会错位,
所以这里自己按显示宽度算一遍,保证表格整齐。
"""
display = sum(2 if ord(ch) > 0x2E80 else 1 for ch in text)
return text + " " * max(0, width - display)
def main():
"""程序入口:请求页面 -> 解析表格 -> 打印结果。"""
print("目标网页:%s" % PAGE_URL)
print()
html = fetch_page(PAGE_URL)
if not html:
print("页面获取失败,程序退出。")
sys.exit(1)
rows = parse_ranking(html)
if not rows:
print("没有解析到排名数据,可能是页面结构变了。")
sys.exit(1)
# 表头与数据行用同一套列宽,保证上下对齐
widths = (8, 26, 10, 12) # 依次对应 排名 / 学校名称 / 省市 / 学校类型
header = (pad("排名", widths[0]) + pad("学校名称", widths[1])
+ pad("省市", widths[2]) + pad("学校类型", widths[3]) + "总分")
print("共解析到 %d 所高校(页面 HTML 中服务端渲染的部分)" % len(rows))
print("=" * 68)
print(header)
print("-" * 68)
for row in rows:
print(pad(row["rank"], widths[0]) + pad(row["name"], widths[1])
+ pad(row["province"], widths[2]) + pad(row["type"], widths[3])
+ row["score"])
print("-" * 68)
print("打印完毕,共 %d 条。" % len(rows))
if __name__ == "__main__":
main()
运行结果

30 所高校全部打印出来,四列对齐,第一条是清华大学(北京 / 综合 / 852.5)。
另外说明一点:页面自己写着「共 567 所学校」,但 HTML 里服务端只渲染了前 30 条,翻页是前端 JavaScript 做的。所以按题目「爬取给定网址(一个页面)」的要求,程序抓到的就是这 30 条。
2)、心得体会
第一是不要用格式去冒险。我一开始用 text.split(" ")[0] 取校名,能跑通,但心里没底——这相当于赌"所有中文校名都不含空格"。后来发现 alt 属性里存着原始中文名,这个来源是语义上正确的,不是碰巧对的。同样一份数据,能找到一个稳定的来源,就别用格式技巧。第二是编码问题必须显式处理。第一次跑出来全是乱码,我第一反应是"网站反爬了",其实是响应头没给 charset。这让我意识到:乱码、空页面、数据缺失,这些现象的第一嫌疑人应该是自己的请求参数,而不是对方的反爬策略。
作业②:商城商品比价爬虫
1)、商品比价爬虫实验
目标:用 requests + re 爬取某个商城「书包」搜索页的商品名称和价格,按「序号 / 价格 / 商品名」输出。
选商城的经过
题目允许自选商城,但我先实测了一遍,因为不同商城的反爬强度差得很远:
| 商城 | 实测结果 | 能否用 requests |
|---|---|---|
| 淘宝 | 返回 34 KB 的 HTML,内容全是前端骨架屏,商品数据不在里面 | 否 |
| 京东 | 302 重定向,响应体 0 字节 | 否 |
| 当当 | 响应 245 KB,商品名称和价格完整写在 HTML 里,每页 60 件 | 是 |
淘宝的页面是前端渲染的,requests 拿到的只是一个空壳;京东直接跳转登录。最后选了当当——服务端渲染、结构清晰、每页正好 60 件,和题目要求的数量也对得上。
正则的三层结构
页面的商品结构是这样的(简化后):
<li class="line1" id="商品编号">
<p class="price"><span class="price_n">¥89.99</span></p>
<p class="name" name="title">
<a title="书包女新款初中生高中学生大容量轻便减负护脊简约学院风双肩包">...</a>
</p>
</li>
所以正则分三层写,一层比一层细:
ITEM_RE = re.compile(r'<li[^>]*class="line\d+"[^>]*>.*?</li>', re.S) # 先切出商品块
NAME_RE = re.compile(r'<p class="name"[^>]*>\s*<a[^>]*title="([^"]*)"') # 再取名称
PRICE_BLOCK_RE = re.compile(r'<p class="price"[^>]*>(.*?)</p>', re.S) # 价格块
PRICE_RE = re.compile(r"[¥¥]\s*[\d.,]+") # 从块里挑金额
先切块再取字段,比用一个超长正则一次性匹配「价格+名称」要稳——万一某个商品缺价格,只影响它自己,不会让后面的匹配全部错位。
完整代码
# -*- coding: utf-8 -*-
r"""实践作业 · 作业②:商城商品比价定向爬虫
商城选择:当当网( https://www.dangdang.com )
关键词:书包
运行方式:python 02_商品比价.py
为什么选当当:
动手前实测过几个商城,淘宝的搜索结果页返回的是前端骨架屏,商品数据不在 HTML 里;
京东会 302 跳转到登录页;当当的搜索结果是服务端渲染的,商品名称和价格都完整写在
HTML 中,用 requests + 正则就能拿到,适合这道题。
整体思路:
1. requests 请求搜索结果页(页面是 GBK 编码,要手动指定)
2. 用正则把页面切成一个个商品块 <li class="line1">...</li>
3. 在每个块里再取商品名称(<a> 的 title 属性)和价格(<span class="price_n">)
4. 按「序号 / 价格 / 商品名」的格式打印
"""
import re
import sys
import requests
# ==================== 可配置项 ====================
KEYWORD = "书包" # 搜索关键词
PAGE_INDEX = 1 # 要抓第几页(每页 60 件)
PAGE_SIZE = 60 # 当当搜索页每页固定 60 件商品
TIMEOUT = 30 # 请求超时时间(秒)
SEARCH_URL = "https://search.dangdang.com/" # 搜索接口地址
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"),
"Accept-Language": "zh-CN,zh;q=0.9",
}
# ==================== 三个正则 ====================
# 第一层:切出一个完整的商品块。class 可能是 line1、line2…… 所以用 line\d+
ITEM_RE = re.compile(r'<li[^>]*class="line\d+"[^>]*>.*?</li>', re.S)
# 第二层之一:商品名称。
# 不能直接取 <a> 里的文本,因为关键词会被 <font class="skcolor_ljg"> 包起来,
# 取文本会夹杂标签;title 属性里存的是干净的完整名称
NAME_RE = re.compile(r'<p class="name"[^>]*>\s*<a[^>]*title="([^"]*)"', re.S)
# 第二层之二:价格所在的整块,再从里面挑金额
PRICE_BLOCK_RE = re.compile(r'<p class="price"[^>]*>(.*?)</p>', re.S)
PRICE_RE = re.compile(r"[¥¥]\s*[\d.,]+")
# 页面里的 HTML 实体,打印前需要还原成真实字符
ENTITIES = {"¥": "¥", " ": " ", "&": "&",
""": '"', "<": "<", ">": ">"}
def unescape(text):
"""把常见的 HTML 实体还原成真实字符(主要是 ¥ 要变成 ¥)。"""
for entity, real in ENTITIES.items():
text = text.replace(entity, real)
return text
def build_search_url(keyword, page_index):
"""拼出搜索页地址。
两个要点:
1. 中文关键词必须做 URL 编码,直接拼进 URL 会出错;
2. 翻页参数是 page_index,不是 page —— 用 page=2 时服务端仍然返回第 1 页。
"""
encoded = requests.utils.quote(keyword) # 「书包」-> %E4%B9%A6%E5%8C%85
return "%s?key=%s&page=1&page_index=%d" % (SEARCH_URL, encoded, page_index)
def fetch_page(url):
"""请求搜索页并返回 HTML 文本,失败时返回 None。"""
session = requests.Session()
session.headers.update(HEADERS)
# 不让 requests 自动读取系统代理:本机环境变量里的代理端口不固定,直连更稳
session.trust_env = False
try:
resp = session.get(url, timeout=TIMEOUT)
# 这个页面是 GBK(响应头里写 gb2312),不手动指定会中文乱码
resp.encoding = "gbk"
return resp.text
except requests.RequestException as err:
print("请求失败:%s" % err)
return None
def parse_items(html):
"""从页面 HTML 中解析出所有商品。
参数:
html -- 搜索页 HTML 文本
返回:
list,元素为 dict,含 name(商品名称)和 price(价格字符串)
"""
items = []
for block in ITEM_RE.findall(html):
name_match = NAME_RE.search(block)
price_match = PRICE_BLOCK_RE.search(block)
if not name_match or not price_match:
continue # 缺名称或缺价格(比如广告位)就跳过
# 名称:取 title 属性并还原实体
name = unescape(name_match.group(1)).strip()
# 价格:先把价格块里的标签去掉,再从中挑出 ¥ 金额
raw = re.sub(r"<[^>]+>", " ", price_match.group(1))
amount = PRICE_RE.search(unescape(raw))
price = amount.group(0).replace(" ", "") if amount else ""
if not name or not price:
continue
# 价格里只留数字和点,方便排序统计,也符合题目示例中「65.00」的写法
price = price.replace("¥", "").replace("¥", "").replace(",", "")
items.append({"name": name, "price": price})
return items
def pad(text, width):
"""按显示宽度补齐空格。
中文在终端里占两个字符宽度,直接用 str.ljust 会因为按字符数计算而错位,
所以自己算一遍显示宽度,让「商品名」这一列能对齐。
"""
display = sum(2 if ord(ch) > 0x2E80 else 1 for ch in text)
return text + " " * max(0, width - display)
def main():
"""程序入口:请求搜索页 -> 解析 -> 打印。"""
url = build_search_url(KEYWORD, PAGE_INDEX)
print("商城:当当网")
print("关键词:%s" % KEYWORD)
print("搜索页:%s" % url)
print()
html = fetch_page(url)
if not html:
print("页面获取失败,程序退出。")
sys.exit(1)
items = parse_items(html)[:PAGE_SIZE]
if not items:
print("没有解析到商品,可能是页面结构变了。")
sys.exit(1)
print("共获取到 %d 件商品:" % len(items))
print("=" * 74)
print(pad("序号", 6) + pad("价格", 12) + "商品名")
print("-" * 74)
for index, item in enumerate(items, 1):
print(pad(str(index), 6) + pad(item["price"], 12) + item["name"])
print("-" * 74)
print("本页共 %d 件商品。" % len(items))
if __name__ == "__main__":
main()
运行结果


60 件商品全部拿到,价格从 26.50 元到 318.60 元不等。
第 8~10 条爬出来是《仙逆》小说,不是书包。这不是程序的 bug——当当搜索页本身混了推广位,页面里就是这些内容。定向爬虫的职责是「忠实取下页面内容」,判断相关性是另一回事。
2)、心得体会
第一,商品名称不能直接取标签文本。当当会把命中的关键词用 <font class="skcolor_ljg"> 单独框起来做高亮,如果取 <a> 里的文字,得到的是夹着标签的一串;而 title 属性里存的是完整原始名称,取它最省事。这和作业①取 alt 是同一个道理:同一个信息在 HTML 里往往存了两份,挑那份没被加工过的。第二,正则要分层。我第一版想用一个正则直接匹配「价格 + 名称」,写出来又长又难调,而且一个商品结构有偏差就会连累后面所有匹配。改成分层之后,逻辑一眼能看懂,出问题也好定位是哪一层没匹配上。
作业③:爬取网页中的所有图片
1)、网页图片爬取实验
目标:爬取 https://news.fzu.edu.cn/yxfd.htm 页面中所有 JPEG / JPG / PNG 图片,保存到本地文件夹。
实验思路
思路本身不复杂:requests 拿页面 → BeautifulSoup 找所有 <img> → 判断后缀 → 下载保存。但实际写下来,有三个地方如果不注意就会白干:
第一,src 是相对路径。页面里既有 images/logo.png 这种相对路径,也有 /__local/... 这种根路径。直接拿去请求会拼出错误的地址,必须用 urljoin(base_url, src) 统一转成绝对地址。
第二,有的地址带查询串。页面里有三个图片地址长这样:xxx_1F7.png?e=.png。如果用 endswith(".png") 判断,因为结尾是 =png 而不是 .png,这三个会被直接漏掉。所以判断前必须先按 ? 切开,只看路径部分:
path = url.split("?")[0].lower()
return path.endswith((".jpg", ".jpeg", ".png"))
完整代码
# -*- coding: utf-8 -*-
r"""实践作业 · 作业③:爬取给定网页中的所有图片并保存到本地
目标网页:https://news.fzu.edu.cn/yxfd.htm (福州大学新闻网 - 院系风采)
运行方式:python 03_下载网页图片.py
整体思路:
1. 用 requests 请求页面,拿到 HTML
2. 用 BeautifulSoup 找出页面里所有 <img> 标签
3. 逐个判断 src 是否以 jpg / jpeg / png 结尾(判断前先切掉 ? 后面的查询串)
4. src 可能是相对路径,用 urljoin 拼成完整的绝对地址
5. 用二进制方式写入本地文件夹,文件名沿用服务器上的原文件名
注意:题目要求「爬取所有 JPEG、JPG 或 PNG 格式图片」,
所以本程序不做内容筛选,页面里出现的图片会全部下载
(其中包含若干个站点图标,属于题目字面要求范围内)。
"""
import os
import sys
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
# ==================== 可配置项 ====================
PAGE_URL = "https://news.fzu.edu.cn/yxfd.htm" # 目标网页地址
SAVE_DIR_NAME = "images" # 图片保存目录名(建在本脚本同级目录下)
ALLOWED_EXT = (".jpg", ".jpeg", ".png") # 题目要求的三类后缀
TIMEOUT = 30 # 单次请求超时时间,单位秒
# 请求头:很多站点不设 User-Agent 会直接返回 403,Referer 则用于通过防盗链校验
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"),
"Referer": PAGE_URL,
}
def fetch_html(url, session):
"""请求网页并返回解码后的 HTML 文本。
参数:
url -- 要请求的网页地址
session -- 复用的 requests.Session,可以少建几次 TCP 连接
返回:
str,页面 HTML;请求失败时返回 None
"""
try:
resp = session.get(url, timeout=TIMEOUT)
# 站点响应头里不一定声明字符集,这里让 requests 根据内容自动判断编码,
# 否则可能出现中文乱码
resp.encoding = resp.apparent_encoding or "utf-8"
return resp.text
except requests.RequestException as err:
print("请求网页失败:%s" % err)
return None
def is_image_url(url):
"""判断一个地址是否指向题目要求的三种图片格式。
这里有个坑:页面里有形如 xxx.png?e=.png 的地址,带查询串。
如果直接 endswith(".png") 会因为最后是 "=png" 而判断失败,
所以要先按 ? 切开,只检查路径部分。
"""
path = url.split("?")[0].lower() # 去掉查询串并统一小写
return path.endswith(ALLOWED_EXT)
def extract_image_urls(html, base_url):
"""从 HTML 中提取所有图片的完整地址。
参数:
html -- 页面 HTML 文本
base_url -- 页面本身的地址,用于把相对路径拼成绝对路径
返回:
list,元素为 (绝对地址, 原文件名) 的元组;已去重
"""
soup = BeautifulSoup(html, "html.parser")
found = []
seen = set() # 用于去重,页面上可能有重复引用的图
for img in soup.find_all("img"):
src = (img.get("src") or "").strip()
if not src:
continue # 个别 img 没有 src,跳过
if src.startswith("data:"):
continue # base64 内嵌图片,不是文件,跳过
# src 可能是 "images/logo.png" 这种相对路径,也可能是 "/__local/..." 这种根路径,
# urljoin 会按标准规则统一拼成绝对地址
absolute = urljoin(base_url, src)
if not is_image_url(absolute):
continue
if absolute in seen:
continue
seen.add(absolute)
# 文件名取地址最后一段(同样要先去掉查询串)
filename = os.path.basename(absolute.split("?")[0])
found.append((absolute, filename))
return found
def save_image(url, filename, save_dir, session):
"""下载单张图片并保存到本地。
参数:
url -- 图片绝对地址
filename -- 保存时使用的文件名
save_dir -- 保存目录
session -- 复用的 requests.Session
返回:
True 表示保存成功,False 表示失败
"""
try:
resp = session.get(url, timeout=TIMEOUT)
if resp.status_code != 200:
print(" 跳过(HTTP %s):%s" % (resp.status_code, filename))
return False
# 有的站点会用 200 返回一个错误页,这里再用 Content-Type 兜一层
if not resp.headers.get("Content-Type", "").startswith("image/"):
print(" 跳过(不是图片):%s" % filename)
return False
path = os.path.join(save_dir, filename)
# 图片是二进制数据,必须用 "wb" 写入,用文本模式会损坏文件
with open(path, "wb") as fp:
fp.write(resp.content)
print(" 已保存(%6.1f KB):%s" % (len(resp.content) / 1024, filename))
return True
except requests.RequestException as err:
print(" 下载失败:%s(%s)" % (filename, err))
return False
def main():
"""程序入口:抓页面 -> 找图片 -> 逐张下载 -> 输出统计。"""
# 保存目录建在脚本所在目录下,这样从任何位置运行结果都一致
base_dir = os.path.dirname(os.path.abspath(__file__))
save_dir = os.path.join(base_dir, SAVE_DIR_NAME)
os.makedirs(save_dir, exist_ok=True) # 已存在时不会报错
print("目标网页:%s" % PAGE_URL)
print("保存目录:%s" % save_dir)
print()
session = requests.Session()
session.headers.update(HEADERS)
# 关闭代理自动读取:本机环境变量里的代理端口不固定,直连更可靠
session.trust_env = False
html = fetch_html(PAGE_URL, session)
if not html:
print("页面获取失败,程序退出。")
sys.exit(1)
images = extract_image_urls(html, PAGE_URL)
print("页面中符合 JPEG/JPG/PNG 格式的图片共 %d 张:" % len(images))
for idx, (url, name) in enumerate(images, 1):
print(" %2d. %s" % (idx, name))
print()
print("开始下载……")
ok_count = 0
for url, name in images:
if save_image(url, name, save_dir, session):
ok_count += 1
print()
print("下载完成:成功 %d 张,失败 %d 张。" % (ok_count, len(images) - ok_count))
print("文件已保存到:%s" % save_dir)
if __name__ == "__main__":
main()
运行结果


页面里符合三种格式的图片共识别到 20 个地址,去重后 19 个唯一文件,全部下载成功、0 失败。

保存目录里能看到完整结果:其中 7 个是站点图标(logo.png、footer_logo.png、n_left_ico1.png 等),12 个是文章配图(文件名是 CMS 生成的哈希,如 EE9C07C6...F84E4.jpg),最大的两张分别有 8.7 MB 和 6.4 MB。
题目要求「爬取所有 JPEG、JPG 或 PNG 格式图片」,所以程序没有做内容筛选——页面里有什么就下什么,图标也一并保存了。如果只想留文章配图,在判断时额外要求路径包含 /__local/ 即可,代码里留了注释说明这一点。
2)、心得体会
?e=.png 这个坑印象最深。我第一版直接用 endswith 判断后缀,跑完发现只有 16 张,比预期少。查了半天才发现有三个地址带了查询串,xxx.png?e=.png 的结尾是 =png,不是 .png,所以被 endswith 判成了"不是图片"。这个坑很隐蔽——程序不报错,只是默默少下载几张,不看数量根本发现不了。去重是我没想到的。一开始我以为是页面重复渲染,后来数了一下 20 个地址里只有 19 个唯一文件,才明白是页头页脚共用了同一个 logo。如果不做去重,同一个文件名会被覆盖,其实结果一样,但下载次数多了一次,日志里也会显示 20 个。
浙公网安备 33010602011771号