数据采集第一次作业
本次实验分别抓取软科大学排名、当当网书包搜索结果和福州大学新闻网图片。三个程序独立运行,分别练习 HTML 表格定位、正则表达式提取和二进制文件保存。
先在代码目录安装依赖:
python -m pip install -r requirements.txt
一 大学排名定向爬取
1 代码和运行结果
目标网页:2020 年软科中国大学排名。需要输出排名、学校名称、省市、学校类型和总分。
网页中一行学校记录对应一个 tr,前五个 td 就是所需字段。学校名称这一格还包含英文名及“985”“211”等标签,所以用 .name-cn 单独提取中文名,不能直接把整格文字当作校名。
页面显示主榜共有 567 所学校,但 requests 取得的 HTML 表格只有前 30 所。先用 BeautifulSoup 抓取并核对首屏,再从同页 state.js 地址定位配套的 payload.js,补齐分页数据。这个地址中的构建版本由当前页面读取,不写死在程序中。parse_payload() 只解析数据里的变量与字面量,不执行下载的 JavaScript。补齐后,把前 30 条与 HTML 表格逐项比较,并检查总数与网页公布数量一致。
完整代码:university_ranking.py
"""作业一:使用 requests 和 BeautifulSoup 抓取 2020 年软科主榜。"""
import csv
import json
import re
import unicodedata
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
URL = "https://www.shanghairanking.cn/rankings/bcur/2020"
ROOT = Path(__file__).resolve().parent
def parse_table(html):
"""提取首屏表格;只取中文校名,避免混入英文名和学校标签。"""
soup = BeautifulSoup(html, "html.parser")
rows = []
for tr in soup.select("tbody tr"):
cells = tr.find_all("td", recursive=False)
if len(cells) < 5:
raise ValueError("排名表格列数不足")
name = cells[1].select_one(".name-cn")
if name is None:
raise ValueError("未找到中文学校名称")
rows.append((int(cells[0].get_text(strip=True)),
name.get_text(strip=True),
cells[2].get_text(strip=True),
cells[3].get_text(strip=True),
f"{float(cells[4].get_text(strip=True)):.1f}"))
if not rows:
raise ValueError("未找到排名表格,请检查网页是否变化")
return rows
def parse_payload(source):
"""还原本站 Nuxt 数据中的变量引用;只解码字面量,不执行 JS。"""
parameters = re.search(r"function\(([^)]*)\)\{return", source)
if parameters is None or "univData:[" not in source:
raise ValueError("页面配套数据格式已变化")
arguments = source.rsplit("}(", 1)[1].rsplit(")))", 1)[0]
names = parameters.group(1).split(",")
values = json.loads("[" + arguments + "]")
if len(names) != len(values):
raise ValueError("数据变量与实际值数量不一致")
mapping = dict(zip(names, values))
rows = []
fields = ("ranking", "univNameCn", "province", "univCategory", "score")
for block in re.findall(r"\{univUp:.*?(?=,indData:)", source):
row = []
for field in fields:
match = re.search(field + r':("(?:\\.|[^"\\])*"|[^,}]+)', block)
if match is None:
raise ValueError(f"学校记录缺少字段:{field}")
token = match.group(1)
row.append(mapping[token] if token in mapping else json.loads(token))
rows.append((int(row[0]), row[1], row[2], row[3], f"{float(row[4]):.1f}"))
if not rows:
raise ValueError("配套数据中没有学校记录")
return sorted(rows, key=lambda row: row[0])
def pad(text, width):
"""按中英文显示宽度补空格,使终端中的表格尽量对齐。"""
text = str(text)
used = sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)
return text + " " * max(0, width - used)
def main():
for folder in ("sources", "results"):
(ROOT / folder).mkdir(exist_ok=True)
with requests.Session() as session:
session.headers.update({"User-Agent": "Mozilla/5.0", "Referer": URL})
response = session.get(URL, timeout=30)
response.raise_for_status()
response.encoding = "utf-8"
(ROOT / "sources/ranking.html").write_bytes(response.content)
visible = parse_table(response.text)
soup = BeautifulSoup(response.text, "html.parser")
# 页面分页由前端完成,不能假定追加 page=2 就能取得第二页。
state = soup.find("script", src=re.compile(r"/state\.js$"))
if state is None:
raise ValueError("找不到当前页面的数据地址")
payload_url = urljoin(response.url, state["src"].replace("state.js", "payload.js"))
payload = session.get(payload_url, timeout=30)
payload.raise_for_status()
payload.encoding = "utf-8"
(ROOT / "sources/ranking_payload.js").write_bytes(payload.content)
rows = parse_payload(payload.text)
if rows[:len(visible)] != visible:
raise ValueError("HTML 表格与配套数据不一致,停止输出")
count = re.search(r"共\s*(\d+)\s*所学校", soup.get_text(" ", strip=True))
if count is None or len(rows) != int(count.group(1)):
raise ValueError("抓取数量与网页公布数量不一致")
if len({row[1] for row in rows}) != len(rows):
raise ValueError("出现重复学校")
headers = ["排名", "学校名称", "省市", "学校类型", "总分"]
with (ROOT / "results/ranking.csv").open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(headers)
writer.writerows(rows)
metadata = {"time": datetime.now().astimezone().isoformat(), "url": URL,
"payload_url": payload_url, "visible_count": len(visible), "count": len(rows)}
(ROOT / "results/ranking_meta.json").write_text(
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
print("2020 年软科中国大学排名(主榜)")
print("抓取时间:", metadata["time"])
print(f"首屏 {len(visible)} 条已核对,主榜共 {len(rows)} 所学校。")
for row in [headers] + rows:
print(" ".join(pad(value, width) for value, width in zip(row, [6, 40, 10, 10, 8])))
print(f"打印完成,共 {len(rows)} 条;已保存 results/ranking.csv。")
if __name__ == "__main__":
try:
main()
except (requests.RequestException, ValueError, OSError) as error:
raise SystemExit(f"抓取失败:{error}")
运行命令:
python university_ranking.py
本次共输出 567 所大学。前三名为清华大学、北京大学和浙江大学,总分分别为 852.5、746.7 和 649.2。最后一条为排名 567 的首钢工学院,总分 55.9。输出中的排名取自原始数据,不用循环序号代替,因此能保留榜单中的并列排名。

图 1 根据本次标准输出排版,展示前 15 条、末尾 3 条和总数,中间部分省略。程序实际打印了全部学校;完整日志见 results/university_ranking.txt,表格见 results/ranking.csv。
2 作业心得
这部分需要区分“网页一共展示了多少数据”和“一次 HTTP 请求返回了多少表格行”。如果只遍历 tbody tr,程序虽然能打印正确的前 30 名,但并没有拿到完整榜单。检查首屏行数和页面总数,比看到清华大学出现在第一行就结束验证更可靠。
实测中,配套数据请求还出现过 HTTP 403。保持同一数据地址,补上来源页 Referer 后,请求成功。这个结果说明这里除了 URL,还要留意请求头;但不能把它理解成所有 403 都能用同一种方法解决。程序用 raise_for_status() 在异常时停止,避免把错误页继续当作排名数据解析。
BeautifulSoup 适合按 HTML 层级定位数据,网页脚本里的压缩数据则需要另外处理。两部分结合后,再做字段与数量核对,才能确认补齐的数据属于同一年、同一个主榜。后续网站若改变表格类名或数据结构,程序会报错,需要重新检查页面,而不是沿用旧结果。
二 商城书包商品比价
1 代码和运行结果
本题选择当当网的书包搜索页面,采集搜索结果的第一页。程序使用 requests 获取网页,使用 re 提取商品名称和页面现价,再按价格从低到高输出。
处理时先定位 id="component_59" 的商品列表,再分出每个 li 商品块。在同一个商品块内提取名称和 price_n 现价,避免把两个独立列表按下标拼接后发生错配。商品名取自标题属性,使用 html.unescape() 还原字符实体;中文响应按 GB18030 解码,兼容页面声明的 GB2312。
完整代码:shop_compare.py
"""作业二:使用 requests 和 re 比较当当网书包搜索结果的页面价格。"""
import csv
import html
import json
import re
from datetime import datetime
from decimal import Decimal
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent
URL = "https://search.dangdang.com/"
KEYWORD = "书包"
def parse_products(text):
"""先划分商品块,再在同一块中提取名称与现价,防止错配。"""
area = re.search(r'<ul\b[^>]*\bid="component_59"[^>]*>(.*?)</ul>', text, re.S)
if area is None:
raise ValueError("未找到商品列表,可能返回了验证页面")
blocks = re.findall(r'<li\b[^>]*\bid="(\d+)"[^>]*>(.*?)</li>', area.group(1), re.S)
products, seen = [], set()
for product_id, block in blocks:
name = re.search(r'<p\b[^>]*class="name"[^>]*>\s*<a\b[^>]*title="(.*?)"', block, re.S)
price = re.search(r'<span\b[^>]*class="price_n"[^>]*>(.*?)</span>', block, re.S)
if name is None or price is None:
raise ValueError(f"商品 {product_id} 缺少名称或价格")
amount = re.search(r"\d+(?:,\d{3})*(?:\.\d+)?", html.unescape(price.group(1)))
if amount is None:
raise ValueError(f"商品 {product_id} 价格格式异常")
if product_id not in seen:
seen.add(product_id)
products.append({"id": product_id,
"name": html.unescape(name.group(1)).strip(),
"price": f"{Decimal(amount.group().replace(',', '')):.2f}"})
if not products:
raise ValueError("没有解析到商品")
return products
def main():
for folder in ("sources", "results"):
(ROOT / folder).mkdir(exist_ok=True)
# 用 params 编码中文关键词,避免手动拼接中文 URL。
response = requests.get(URL, params={"key": KEYWORD, "act": "input", "page_index": 1},
headers={"User-Agent": "Mozilla/5.0"}, timeout=30)
response.raise_for_status()
response.encoding = "gb18030" # 兼容网页声明的 GB2312。
(ROOT / "sources/shop.html").write_bytes(response.content)
if not re.search(r"<title>\s*书包-当当网\s*</title>", response.text):
raise ValueError("返回页面不是书包搜索结果")
products = parse_products(response.text)
products.sort(key=lambda item: Decimal(item["price"]))
result = {"time": datetime.now().astimezone().isoformat(), "url": response.url,
"keyword": KEYWORD, "page": 1, "count": len(products), "products": products}
(ROOT / "results/products.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
with (ROOT / "results/products.csv").open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(["序号", "价格(元)", "商品名", "商品编号"])
writer.writerows((i, item["price"], item["name"], item["id"])
for i, item in enumerate(products, 1))
print("当当网:书包搜索第一页")
print("抓取时间:", result["time"])
print(f"共 {len(products)} 件商品,按页面现价从低到高排列。")
print("序号 价格(元) 商品名")
for i, item in enumerate(products, 1):
print(f"{i:>3} {item['price']:>8} {item['name']}")
print("已保存 results/products.csv;价格不含运费及结算优惠。")
if __name__ == "__main__":
try:
main()
except (requests.RequestException, ValueError, OSError) as error:
raise SystemExit(f"抓取失败:{error}")
运行命令:
python shop_compare.py
本次取得 60 件商品,按页面现价排序后的价格范围为 36.00~678.30 元。这只是当次搜索第一页的范围,不代表整个商城的最低价和最高价。搜索结果包含不同品牌、款式以及部分帆布包,程序保留商城返回的商品名称,不把它们当作完全相同的商品。

图 2 根据本次标准输出排版,展示价格较低的前 12 件商品;完整的 60 条输出在 results/shop_compare.txt,结果表格在 results/products.csv。价格取搜索页显示值,不包含运费、优惠券及结算时可能发生的变化。
2 作业心得
用正则表达式抓价格时,范围不能太大。整页中还可能出现划线原价、评价数量和广告内容,直接搜索所有数字容易得到不相关的值。先确定商品列表,再缩小到单个商品块,能让每个名称和价格保持对应关系。遇到缺少价格的商品,程序直接指出商品编号,不静默跳过。
价格还需要从字符串转换为数值后再排序。比如按字符串比较时,"100.00" 会排在 "65.00" 前面;Decimal 能按金额大小比较,并统一保留两位小数。本题的比价结果只能作为不同商品页面价格的排列,不能仅凭价格判断哪件商品更值得购买。
正则表达式在这次页面结构固定的情况下比较直接,但它依赖 component_59、name 和 price_n 这些标记。网站改版或返回验证页时,匹配就可能失效,所以“没有匹配结果”应当被当作需要检查的问题,而不是“商城没有书包”。
三 网页图片批量下载
1 代码和运行结果
目标网页:福州大学新闻网影像福大栏目。本题下载当前页 HTML 直接引用的 JPG、JPEG 和 PNG 图片,包括新闻缩略图、标志和图标;不进入新闻详情页,不递归抓取外部 CSS,也不运行网页脚本。
程序用 BeautifulSoup 提取图片地址,也处理常见懒加载属性、srcset、图片链接及页内样式。地址先用 urljoin() 补全,再按 URL 去重。判断格式时只检查 URL 的路径部分,所以 xxx.png?e=.png 不会因为查询参数而漏掉。保存采用二进制写入,并检查 PNG/JPEG 文件头,避免将错误页面误存成图片。
完整代码:download_images.py
"""作业三:下载影像福大当前页 HTML 引用的 JPG、JPEG 和 PNG 图片。"""
import hashlib
import json
import re
import time
from datetime import datetime
from pathlib import Path
from urllib.parse import urldefrag, urljoin, urlsplit
import requests
from bs4 import BeautifulSoup
ROOT = Path(__file__).resolve().parent
URL = "https://news.fzu.edu.cn/yxfd.htm"
def find_images(html, page_url):
"""提取常见图片属性及页内样式,补全地址并按首次出现顺序去重。"""
soup = BeautifulSoup(html, "html.parser")
candidates = []
for tag in soup.find_all(True):
if tag.name in ("img", "source", "input"):
for attribute in ("src", "data-src", "data-original"):
if tag.get(attribute):
candidates.append(tag[attribute])
for part in tag.get("srcset", "").split(","):
if part.strip():
candidates.append(part.strip().split()[0])
if tag.name == "a" and tag.get("href"):
candidates.append(tag["href"])
style = tag.get("style", "")
if tag.name == "style":
style += tag.get_text()
candidates.extend(re.findall(r"url\(\s*['\"]?([^'\")]+)", style))
urls, seen = [], set()
for candidate in candidates:
url = urldefrag(urljoin(page_url, candidate.strip()))[0]
parts = urlsplit(url)
# 后缀只看 path,保留用于访问原图的查询参数。
if parts.scheme in ("http", "https") and Path(parts.path).suffix.lower() in (".jpg", ".jpeg", ".png"):
if url not in seen:
seen.add(url)
urls.append(url)
return urls
def is_image(content):
"""检查 PNG/JPEG 文件头,避免把 HTTP 200 的错误页面存成图片。"""
return content.startswith(b"\x89PNG\r\n\x1a\n") or content.startswith(b"\xff\xd8\xff")
def main():
for folder in ("sources", "results", "images"):
(ROOT / folder).mkdir(exist_ok=True)
records = []
with requests.Session() as session:
session.headers.update({"User-Agent": "Mozilla/5.0", "Referer": URL})
response = session.get(URL, timeout=30)
response.raise_for_status()
response.encoding = "utf-8"
(ROOT / "sources/fzu.html").write_bytes(response.content)
urls = find_images(response.text, response.url)
if not urls:
raise ValueError("没有找到符合格式要求的图片")
print("影像福大:当前页图片下载")
print(f"发现 {len(urls)} 个不重复的 JPG/JPEG/PNG 地址。")
for index, url in enumerate(urls, 1):
suffix = Path(urlsplit(url).path).suffix.lower()
filename = f"{index:02d}_{hashlib.sha256(url.encode()).hexdigest()[:10]}{suffix}"
record = {"url": url, "file": "images/" + filename}
try:
image = session.get(url, timeout=30)
image.raise_for_status()
if not is_image(image.content):
raise ValueError("响应不是 PNG/JPEG 图片")
(ROOT / record["file"]).write_bytes(image.content)
record.update(status="ok", size=len(image.content),
sha256=hashlib.sha256(image.content).hexdigest())
print(f"[{index:02d}/{len(urls)}] 已保存 {filename} {len(image.content)} 字节")
except (requests.RequestException, ValueError, OSError) as error:
record.update(status="failed", error=str(error))
print(f"[{index:02d}/{len(urls)}] 下载失败:{url} {error}")
records.append(record)
time.sleep(0.3) # 顺序下载,控制请求频率。
failed = sum(record["status"] == "failed" for record in records)
report = {"time": datetime.now().astimezone().isoformat(), "url": URL,
"found": len(urls), "success": len(urls) - failed, "failed": failed,
"images": records}
(ROOT / "results/images_manifest.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"下载结束:成功 {len(urls) - failed} 张,失败 {failed} 张。")
print("文件保存在 images,来源清单保存在 results/images_manifest.json。")
if failed:
raise SystemExit(1)
if __name__ == "__main__":
try:
main()
except (requests.RequestException, ValueError, OSError) as error:
raise SystemExit(f"抓取失败:{error}")
运行命令:
python download_images.py
本次页面中共有 21 个 img 标签,其中有一张 GIF 加载动画不属于要求的格式,页脚标志还重复出现了一次。去重后得到 19 个目标地址,实际下载成功 19 张,失败 0 张,包含 4 张 JPEG 图片和 15 张 PNG 图片。

图 3 为本次下载日志,文件保存在同一 images 文件夹内。文件名由序号和地址摘要组成,可避免不同路径下的同名图片互相覆盖。

图 4 为下载文件的本地预览。缩略图保持原始比例,较小的图标在预览中也较小。所有 19 个文件均经过图片解码检查。results/images_manifest.json 记录每张图片的来源、文件名、字节数和 SHA-256 值,便于对照检查。
2 作业心得
图片下载首先要处理地址。页面里的 images/logo.png 是相对路径,不能直接作为请求地址;而以 /__local/ 开头的路径需要从网站根目录拼接。用 urljoin() 统一处理,比手动拼字符串更不容易出错。
“找到多少个标签”也不等于“应保存多少个文件”。本次页脚标志出现了两次,直接逐标签下载会重复保存;GIF 虽然也是图片,却不在题目指定的三种格式里。因此,先筛选再去重,最后比较发现数量、成功数量和失败数量,能更清楚地判断下载是否完成。
HTTP 请求成功也不一定意味着取得了图片。文件头检查能排除普通 HTML 错误页,再通过图片解码检查确认文件可读。程序还把单张图片的异常放在循环内处理,一张失败不会阻止其他图片下载,但最终会给出失败数量并以非零状态结束,避免把部分完成误报成全部成功。

浙公网安备 33010602011771号