数据采集第一次作业
数据采集第一次作业
本次作业使用 Python 完成大学排名数据采集、商城商品信息采集和网页图片下载。主要练习
requests网络请求、BeautifulSoup页面解析、re正则表达式、URL 地址处理以及文件保存等内容。编写代码前,先使用浏览器开发者工具(F12)检查目标网页结构,再根据实际标签和属性设计数据提取规则。
| 项目 | 说明 |
|---|---|
| 编程语言 | Python 3.11.8 |
| 开发工具 | PyCharm、浏览器开发者工具(F12) |
| 主要依赖 | requests、beautifulsoup4 |
| 安装命令 | python -m pip install -r requirements.txt |
| 代码仓库 | 2026data-collection-practice |
运行说明:
task1_universities.py、task2_schoolbags.py、task3_images.py和common.py需要放在同一个文件夹中。
一、作业①:爬取大学排名
1.1 作业代码与运行展示
目标网址: https://www.shanghairanking.cn/rankings/bcur/2020
使用题目指定的 requests 和 BeautifulSoup,输出排名、学校名称、省市、学校类型和总分。
网页结构检查
按 F12 打开开发者工具后,在 Elements 面板中可以看到排名区域由 <table>、<tbody>、<tr> 和 <td> 等标签组成。由于该网页的数据可能通过 JavaScript 动态加载,我又检查了网页使用的官方数据接口。接口返回的 rankings 列表中,ranking、univNameCn、province、univCategory 和 score 分别对应排名、学校名称、省市、学校类型和总分。
图1 软科排名网页的表格结构
图2 软科排名网页使用的官方数据接口
程序流程: 发送网页请求 → 使用 BeautifulSoup 解析表格 → 如果静态 HTML 中没有排名数据,则请求同一网站的官方接口 → 提取五个字段 → 屏幕打印 → 保存 CSV。学校名称单独读取中文名称节点,避免把英文校名和“985”等标签一起输出。
公共工具代码(common.py)
"""公共网络、文本输出和 CSV 保存工具。"""
import csv
import re
from pathlib import Path
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE_DIR = Path(__file__).resolve().parent
OUTPUT_DIR = BASE_DIR / "output"
def make_session():
"""建立会话:只对临时网络故障重试,不绕过验证码。"""
session = requests.Session()
session.headers.update({"User-Agent": "Mozilla/5.0", "Accept-Language": "zh-CN,zh;q=0.9"})
retry = Retry(total=2, backoff_factor=1, status_forcelist=[500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retry))
session.mount("http://", HTTPAdapter(max_retries=retry))
return session
def fetch_html(session, url, default_encoding="utf-8"):
"""请求 HTML;优先使用页面声明的编码,避免中文乱码。"""
response = session.get(url, timeout=(10, 30))
response.raise_for_status()
match = re.search(rb'charset\s*=\s*[\"\x27]?([\w-]+)', response.content[:8192], re.I)
encoding = match.group(1).decode("ascii") if match else default_encoding
if encoding.lower() in ("gb2312", "gbk"):
encoding = "gb18030" # 兼容中文站点中超出 GB2312 的字符。
return response.content.decode(encoding, errors="replace"), response.url
def save_csv(path, headers, rows):
"""使用 UTF-8 BOM 保存 CSV,方便 Windows Excel 显示中文。"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(headers)
writer.writerows(rows)
def print_table(headers, rows):
"""按制表符打印作业要求的字段。"""
print("\t".join(headers))
for row in rows:
print("\t".join(map(str, row)))
作业①完整代码(task1_universities.py)
"""作业①:requests + BeautifulSoup 爬取 2020 年大学排名。"""
import argparse
import sys
import requests
from bs4 import BeautifulSoup
from common import OUTPUT_DIR, fetch_html, make_session, print_table, save_csv
URL = "https://www.shanghairanking.cn/rankings/bcur/2020"
API_URL = "https://www.shanghairanking.cn/api/pub/v1/bcur"
HEADERS = ["排名", "学校名称", "省市", "学校类型", "总分"]
def parse_universities(html):
"""只读取排名表格,避免把英文名称和办学标签混入学校名。"""
soup = BeautifulSoup(html, "html.parser")
result = []
seen = set()
for row in soup.select("table tbody tr"):
cells = row.find_all("td", recursive=False)
if len(cells) < 5:
continue
rank = cells[0].get_text(strip=True)
name_node = cells[1].select_one(".name-cn")
if name_node is None:
name_node = cells[1].find("a")
name = name_node.get_text(strip=True) if name_node else cells[1].get_text(" ", strip=True).split()[0]
province = cells[2].get_text(strip=True)
school_type = cells[3].get_text(strip=True)
score = cells[4].get_text(strip=True)
if not rank.isdigit() or not name:
continue
try:
float(score)
except ValueError:
continue
key = (rank, name)
if key not in seen:
result.append((rank, name, province, school_type, score))
seen.add(key)
return result
def parse_api_data(payload):
"""解析软科网页当前使用的官方数据接口。"""
rankings = payload.get("data", {}).get("rankings", [])
result = []
for item in rankings:
rank = str(item.get("ranking", "")).strip()
name = str(item.get("univNameCn", "")).strip()
province = str(item.get("province", "")).strip()
school_type = str(item.get("univCategory", "")).strip()
score_value = item.get("score")
if not rank or not name or score_value in (None, ""):
continue
result.append((rank, name, province, school_type, str(score_value)))
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=URL, help="排名页面地址")
parser.add_argument("--limit", type=int, default=30, help="显示条数,默认30;填写0显示全部")
args = parser.parse_args()
try:
with make_session() as session:
html, final_url = fetch_html(session, args.url)
rows = parse_universities(html)
source = final_url
# 部分网络环境中只返回网页框架,排名由 JavaScript 动态加载。
# 此时请求同一网站的官方数据接口作为兼容方案。
if not rows:
response = session.get(
API_URL,
params={"bcur_type": 11, "year": 2020},
headers={
"Accept": "application/json, text/plain, */*",
"Referer": URL,
},
timeout=(10, 30),
)
response.raise_for_status()
rows = parse_api_data(response.json())
source = f"{args.url}(排名由该网页使用的官方接口加载)"
if not rows:
raise ValueError("网页和官方接口均未返回可用的排名数据。")
if args.limit < 0:
raise ValueError("--limit 不能小于0。")
if args.limit:
rows = rows[:args.limit]
print("数据来源:", source)
print_table(HEADERS, rows)
path = OUTPUT_DIR / "大学排名_2020.csv"
save_csv(path, HEADERS, rows)
print(f"\n共提取 {len(rows)} 所学校,CSV:{path}")
return 0
except (requests.RequestException, ValueError, OSError, LookupError) as error:
print(f"作业①失败:{error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
运行命令
python task1_universities.py
默认显示前30所学校;如需显示接口返回的全部学校,可运行:
python task1_universities.py --limit 0
运行结果
图3 大学排名爬取结果(一)
图4 大学排名爬取结果(二)
图5 大学排名数据保存结果
1.2 作业心得
大学排名页面适合通过 HTML 结构进行解析。BeautifulSoup 将网页整理成标签树,选择表格行后再读取单元格,比直接对整个网页匹配文字更容易理解。提取时需要区分中文校名、英文校名和办学标签,不能简单地把学校名称单元格中的所有文字拼接起来。
本次实践还让我理解了“浏览器显示的内容”和“requests 得到的静态 HTML”可能不同。网页中的排名数据可能由 JavaScript 从接口加载,因此程序先尝试解析 HTML,失败后再访问网页实际使用的官方接口。判断程序是否正确,除了检查是否打印结果,还应核对字段顺序、首条记录、数据数量和网页内容。网络错误与解析错误也需要分别处理,失败时应给出原因,而不是输出预先写好的数据。
二、作业②:爬取书包商品名称与价格
2.1 作业代码与运行展示
自选商城为当当网,搜索关键词为“书包”。使用题目指定的 requests 和 re,不在商品解析过程中使用 BeautifulSoup。默认抓取第1页,并将价格从低到高排列,实现当前页面内的商品价格比较。
目标网址: https://search.dangdang.com/?key=%E4%B9%A6%E5%8C%85&act=input&page_index=1
程序通过 urlencode 构造关键词和页码参数,避免手写中文编码。
网页结构检查
按 F12 打开开发者工具,在 Elements 面板中使用元素选择工具定位商品。检查后发现,每件商品主要位于一个 <li> 标签中;商品名称通常保存在 <a> 标签的 title 属性中;现价位于 class="price_n" 或 class="search_now_price" 的价格标签中。因此,程序先用正则表达式划分每一个商品块,再从同一商品块中匹配名称和价格。
图6 当当网商品名称与价格的页面结构
程序流程: 获取搜索页 → 按商品 li 标签划分块 → 每块提取标题和现价 → 去重 → 按价格排序 → 打印与保存。先分块再提取能够减少商品名称与其他商品价格错误配对。当前页面的商品 id 可以是纯数字,代码也兼容带 p 前缀的形式;价格匹配兼容 price_n 和 search_now_price。
作业②完整代码(task2_schoolbags.py)
"""作业②:仅用 requests + re 提取当当网书包名称与价格。"""
import argparse
import html as html_lib
import re
import sys
from decimal import Decimal
from urllib.parse import urlencode
import requests
from common import OUTPUT_DIR, fetch_html, make_session, print_table, save_csv
SEARCH_URL = "https://search.dangdang.com/"
HEADERS = ["序号", "价格", "商品名"]
def plain_text(text):
"""去掉 HTML 标签、还原实体,合并多余空白。"""
return re.sub(r"\s+", " ", html_lib.unescape(re.sub(r"<[^>]+>", "", text))).strip()
def parse_products(page):
"""先切分商品块,再在每块中匹配名称和现价,避免错位配对。"""
blocks = re.findall(
r'<li\b(?=[^>]*\bid\s*=\s*[\"\x27]p?\d+[\"\x27])[^>]*>(.*?)</li\s*>',
page, flags=re.I | re.S,
)
if not blocks:
blocks = re.findall(
r'<li\b(?=[^>]*\bclass\s*=\s*[\"\x27][^\"\x27]*\bddt-pit\b)[^>]*>(.*?)</li\s*>',
page, flags=re.I | re.S,
)
products = []
seen = set()
for block in blocks:
# 商品标题通常位于名称段落的 a 标签 title 属性中。
name_area = re.search(r'<p\b[^>]*class\s*=\s*[\"\x27][^\"\x27]*\bname\b[^\"\x27]*[\"\x27][^>]*>(.*?)</p>', block, re.I | re.S)
area = name_area.group(1) if name_area else block
title = re.search(r'<a\b[^>]*\btitle\s*=\s*([\"\x27])(.*?)\1', area, re.I | re.S)
if title:
name = plain_text(title.group(2))
else:
link = re.search(r'<a\b[^>]*>(.*?)</a>', area, re.I | re.S)
name = plain_text(link.group(1)) if link else ""
price_node = re.search(
r'<(?:span|em|b)\b[^>]*class\s*=\s*[\"\x27][^\"\x27]*\b(?:search_now_price|price_n)\b[^\"\x27]*[\"\x27][^>]*>(.*?)</(?:span|em|b)>',
block, re.I | re.S,
)
if not name or price_node is None:
continue
price_match = re.search(r'\d[\d,]*(?:\.\d+)?', plain_text(price_node.group(1)))
if price_match is None:
continue
price = Decimal(price_match.group().replace(",", ""))
key = (name, price)
if key not in seen:
products.append((name, price))
seen.add(key)
return products
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--page", type=int, default=1, help="搜索结果页码,默认第1页")
parser.add_argument("--url", help="可选:指定用于提取的商品搜索页")
args = parser.parse_args()
if args.page < 1:
parser.error("页码必须大于等于1")
url = args.url or SEARCH_URL + "?" + urlencode({"key": "书包", "act": "input", "page_index": args.page})
try:
with make_session() as session:
page, final_url = fetch_html(session, url, "gb18030")
products = parse_products(page)
if not products:
raise ValueError("未提取到商品。请检查是否出现验证码、访问限制或商品页面结构变化。")
print("商城:当当网;关键词:书包;数据来源:", final_url)
# 比价为页面内不同商品的现价比较,并非同款跨商家比较。
products.sort(key=lambda item: item[1])
rows = [(i, f"{price:.2f}", name) for i, (name, price) in enumerate(products, 1)]
print_table(HEADERS, rows)
path = OUTPUT_DIR / "当当网书包商品.csv"
save_csv(path, HEADERS, rows)
print(f"\n提取 {len(rows)} 项;按价格从低到高排列;CSV:{path}")
print("价格为页面展示现价,不含运费、优惠券及规格差异。")
return 0
except (requests.RequestException, ValueError, OSError, LookupError) as error:
print(f"作业②失败:{error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
运行命令
python task2_schoolbags.py
真实联网验证成功提取60项商品,输出文件为 output/当当网书包商品.csv,商品与价格可能随运行时间变化。
运行结果
图7 书包商品名称与价格爬取结果(一)
图8 书包商品名称与价格爬取结果(二)
本任务比较的是当前搜索页内不同商品的展示价格;不同型号、容量和规格不能简单认定为同一商品,展示价格也不代表含运费与优惠后的最终成交价。
2.2 作业心得
正则表达式能够按照文本模式提取商品字段,但必须先了解网页结构。若分别在整个页面上查找所有标题和价格,再按顺序组合,就可能因为广告、无价格商品或额外价格标签导致错位。因此,程序先确定商品块,再在同一块中提取名称和现价。
中文商城页面还需要注意编码。网页声明 GB2312 时,程序使用兼容范围更大的 GB18030 解码,并对 HTML 实体进行还原。价格转换为 Decimal 后再排序,能够避免把“100”当作小于“20”的字符串排序问题。正则表达式依赖页面结构,网站改版后要检查真实 HTML、更新匹配规则;未提取到商品不能直接解释为商城没有书包。
三、作业③:下载网页图片
3.1 作业代码与运行展示
目标网址: 福州大学新闻网“影像福大”页面:https://news.fzu.edu.cn/yxfd.htm
程序将网页直接引用的 JPG、JPEG 和 PNG 图片保存到同一文件夹,同时记录图片来源与下载状态。
网页结构检查
按 F12 打开开发者工具后,在 Elements 面板中使用元素选择工具定位页面图片。检查后发现,图片主要通过 <img> 标签展示,图片相对地址保存在 src 属性中,部分资源还可能使用懒加载属性或 srcset。程序需要获取这些属性,通过 urljoin 将相对地址补全为绝对地址,并筛选扩展名为 .jpg、.jpeg 或 .png 的文件。
图9 福州大学新闻网图片标签结构
程序流程: 获取 HTML → 收集图片引用 → 转为完整 URL → 按 URL 去重 → 逐张下载 → 验证文件类型 → 保存下载清单。图片引用包括 img、懒加载属性、srcset、图片链接和页面内 CSS 的 url()。不递归进入其他新闻页,也不执行 JavaScript 或扫描外部样式表。
作业③完整代码(task3_images.py)
"""作业③:下载给定网页中引用的 JPG、JPEG、PNG 图片。"""
import argparse
import csv
import re
import sys
import time
from pathlib import Path
from urllib.parse import urldefrag, urljoin, urlsplit
import requests
from bs4 import BeautifulSoup
from common import OUTPUT_DIR, fetch_html, make_session
URL = "https://news.fzu.edu.cn/yxfd.htm"
def extract_image_urls(page, page_url):
"""解析图片标签、懒加载、srcset、图片链接以及页面内 CSS。"""
soup = BeautifulSoup(page, "html.parser")
base = soup.find("base", href=True)
base_url = urljoin(page_url, base["href"]) if base else page_url
candidates = []
for tag in soup.find_all(["img", "source", "input", "a"]):
for attr in ("src", "data-src", "data-original", "data-lazy-src", "href"):
value = tag.get(attr)
if value:
candidates.append(value)
for attr in ("srcset", "data-srcset"):
for item in tag.get(attr, "").split(","):
parts = item.strip().split()
if parts:
candidates.append(parts[0])
css_parts = [tag.get("style", "") for tag in soup.find_all(style=True)]
css_parts += [tag.get_text() for tag in soup.find_all("style")]
for css in css_parts:
candidates += [m.group(2) for m in re.finditer(r'url\(\s*([\"\x27]?)(.*?)\1\s*\)', css, re.I)]
result = []
seen = set()
for candidate in candidates:
url = urldefrag(urljoin(base_url, candidate.strip()))[0]
parts = urlsplit(url)
if parts.scheme not in ("http", "https"):
continue
if Path(parts.path).suffix.lower() not in (".jpg", ".jpeg", ".png"):
continue
if url not in seen:
seen.add(url)
result.append(url)
return result
def download_image(session, url, folder, index, referer):
"""流式下载到临时文件,验证格式后改名,失败不留下半张图片。"""
part = folder / f"{index:03d}.part"
try:
with session.get(url, headers={"Referer": referer}, timeout=(10, 30), stream=True) as response:
response.raise_for_status()
with part.open("wb") as file:
for chunk in response.iter_content(65536):
if chunk:
file.write(chunk)
with part.open("rb") as file:
signature = file.read(8)
if signature.startswith(b"\x89PNG\r\n\x1a\n"):
suffix = ".png"
elif signature.startswith(b"\xff\xd8\xff"):
suffix = ".jpg"
else:
raise ValueError("响应并非 JPEG 或 PNG 图片(可能返回了错误网页)")
path = folder / f"{index:03d}{suffix}"
part.replace(path)
return path
finally:
part.unlink(missing_ok=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--url", default=URL, help="目标网页,不递归进入其他新闻页面")
parser.add_argument("--output", type=Path, default=OUTPUT_DIR / "网页图片", help="保存文件夹")
args = parser.parse_args()
try:
args.output.mkdir(parents=True, exist_ok=True)
with make_session() as session:
page, final_url = fetch_html(session, args.url)
urls = extract_image_urls(page, final_url)
if not urls:
raise ValueError("未找到扩展名为 JPG/JPEG/PNG 的图片引用,请检查页面是否正常。")
print(f"找到 {len(urls)} 个去重后的图片地址;保存位置:{args.output.resolve()}")
success = 0
# 逐条写清单;某张下载失败时仍继续下载后面的图片。
with (args.output / "图片下载清单.csv").open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.writer(file)
writer.writerow(["序号", "图片URL", "文件名", "状态", "说明"])
for index, url in enumerate(urls, 1):
try:
path = download_image(session, url, args.output, index, final_url)
success += 1
writer.writerow([index, url, path.name, "成功", ""])
print(f"[{index}/{len(urls)}] 成功:{path.name}")
except (requests.RequestException, ValueError, OSError) as error:
writer.writerow([index, url, "", "失败", str(error)])
print(f"[{index}/{len(urls)}] 失败:{error}")
file.flush()
time.sleep(0.5)
print(f"下载结束:成功 {success} 张,失败 {len(urls) - success} 张。")
return 0 if success == len(urls) else 1
except (requests.RequestException, ValueError, OSError, LookupError) as error:
print(f"作业③失败:{error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
运行命令
python task3_images.py
真实联网验证发现19个去重后的图片地址,成功下载19张,失败0张。结果位于 output/网页图片,包含页面装饰图和内容图。
运行结果
图10 网页图片下载过程
图11 网页图片下载完成情况
图12 下载后的图片文件夹
关于部分图片缩略图显示为空白的说明: 网页中除了新闻内容图片,还会引用透明背景的 PNG 图片、颜色接近白色的装饰图片、尺寸较小的页面组件或占位资源。这些文件在资源管理器的白色背景中预览时,可能看起来像一张空白图片,但并不代表下载失败。本程序会检查 PNG 或 JPEG 文件头;只要控制台和下载清单显示“成功”,且文件能够正常打开,就说明该图片文件已经被正确下载。
图片使用序号命名,防止不同网址的同名图片互相覆盖。下载时先写入临时文件,检查 PNG 或 JPEG 文件头后再保存;某张图片失败会记录原因并继续处理下一张,最后显示成功与失败数量。有失败时程序返回非零退出码。
3.2 作业心得
网页中的图片地址可能是相对路径,也可能以双斜杠开头。直接把这些地址交给 requests 容易失败,需要使用 urljoin 结合页面地址补全。图片网址包含查询参数时,也不能直接用整个 URL 判断扩展名,因此程序先解析 URL,再检查路径部分。
下载任务不仅要“保存文件”,还要确认保存的是图片。有些错误页面也可能返回 HTTP 200,所以程序额外检查文件头,避免把 HTML 错误信息保存成 JPG。URL 去重减少重复请求,临时文件减少不完整文件残留,下载清单则方便追踪来源与排查失败。三个任务共同体现了请求、解析、验证和保存的完整流程。离线测试覆盖了字段提取、编码、去重、路径补全与异常响应,共10项通过;离线测试使用构造的小页面,不能替代真实联网验证。
浙公网安备 33010602011771号