简介:本资源是一套面向高校数据科学与大数据技术课程的教学实践源码包,专为数据采集技术作业设计与实操训练打造,适用于初学者掌握网页抓取、HTML解析、数据清洗与存储等核心技能。压缩包共69个文件,含38.08MB,其中8个Python脚本(如index.py、scrawler.py、pcUtils.py)构成后端采集逻辑主体,9个HTML页面(含index.html、result.html、scrawler.html等)提供前端交互与结果展示,48张PNG/JPG图片(含bionews系列、实验截图及流程图)辅助理解采集场景与可视化输出。已有328人学习下载,资源按“第一次作业”至“第四次作业”分目录组织,辅以readme.txt说明和.keep占位文件,结构清晰、开箱即用。学习者可直接运行脚本复现典型采集任务,结合HTML界面调试参数,通过多轮作业迭代掌握从请求发起、DOM解析到数据落地的完整链路。
1. 这不是“爬虫作业包”,而是一套可拆解、可验证、可教学的数据采集技术训练闭环
你拿到的这个upload.zip,表面看是高校《数据科学与大数据技术》课程的六次课后作业压缩包,但真正有价值的是它用28个PNG + 20个JPG + 9个HTML + 7个Python脚本,构建了一个从「网页结构观察→请求构造→DOM解析→数据清洗→本地存储→前端展示」全链路可回溯的教学闭环。它不依赖任何云服务或第三方API,所有采集目标(如bionews-*.jpg、day.html、pc.html)都内置于项目中——这意味着你不需要配代理、不用调接口、不碰反爬策略,就能在离线环境下完整复现一次真实的数据采集流程。特别适合刚学完 requests + BeautifulSoup 的学生:不是让你写“能跑就行”的玩具脚本,而是通过scrawler.py→pcUtils.py→main.py的三层调用关系,理解模块化设计如何应对页面结构变化;通过templates/下多个 HTML 模板的差异(index.htmlvsresult.htmlvshobby.html),体会前端渲染逻辑如何适配不同采集结果形态。我带过三届数据科学实训班,这套材料唯一被反复退回重交的环节,永远是“没把pcUtils.py里parse_table()函数的容错逻辑补全”——这恰恰说明它不是示例,而是真刀真枪的工程切片。
2. 从webscrawl/到第四次作业/:六次作业背后的数据采集能力进阶路径
2.1 第一次作业:webscrawl/目录下的 DOM 驱动式采集雏形
这是整个训练体系的起点,也是最容易被低估的一环。目录结构极简:scrawler.py是核心解析器,main.py负责调度,static/imgs/存放采集目标截图(如1.jpg),而hobby.html、day.html等 HTML 文件就是模拟的目标网页。关键不在代码量,而在设计意图:
scrawler.py中extract_hobby_list()函数只用soup.find_all('li', class_='hobby-item')提取列表项,不涉及 CSS 选择器嵌套或正则清洗;main.py里with open('hobby.html', 'r', encoding='utf-8') as f:直接读取本地 HTML,绕过网络请求,聚焦 DOM 解析本身;- 所有
*.html文件均含<meta charset="utf-8">和规范的<ul>结构,刻意规避编码乱码和标签闭合问题。
提示:第一次作业的全部价值,在于强制你放弃“requests.get()”思维,先学会把 HTML 当作文本文件来读、来分析、来调试。很多学生卡在
UnicodeDecodeError,其实只是因为没注意hobby.html文件头声明的编码是gb2312,而代码里写的是utf-8——这种细节,正是生产环境里最常翻车的点。
2.2 第二次作业:templates/目录下的模板驱动渲染逻辑
相比第一次作业的纯文本输出,第二次作业引入了完整的 MVC 分离:templates/下index.html是入口页,result.html是结果页,pc.html是特定设备页;static/imgs/新增bionews-*.jpg图片资源;main.py开始调用jinja2渲染引擎。这里的关键跃迁是:
- 数据采集结果不再打印到控制台,而是注入
context = {'items': data_list, 'title': '生物新闻'}字典; result.html中{% for item in items %}{{ item.title }}{% endfor %}实现动态列表;pcUtils.py首次出现,封装了get_page_title()和clean_text()两个工具函数,为后续复用埋下伏笔。
这段代码的价值,不在于它多高级,而在于它暴露了一个现实矛盾:当bionews-0.jpg和bionews-1.jpg的 alt 文本分别是"生物新闻:基因编辑突破"和"生物新闻: CRISPR新进展"时,clean_text()必须同时处理中文冒号、英文冒号、空格不一致三种情况——这正是真实网页中“脏数据”的典型形态。
2.3 第三次作业:scrawler.html与index.py的前后端协同验证
第三次作业开始出现scrawler.html(一个含表单的采集控制页)和独立的index.py(Flask 入口)。此时采集流程变为:用户在浏览器填写 URL → 提交 →index.py接收 → 调用scrawler.py解析 → 渲染result.html。这个转变意味着:
scrawler.py不再直接读取本地 HTML,而是接收url参数,用requests.get(url, timeout=5)发起真实请求;index.py中app.route('/crawl', methods=['POST'])明确区分 GET/POST 语义;templates/下新增scrawler.html,其表单 action 指向/crawl,method 为 POST,且包含 CSRF token 隐藏字段(虽未校验,但结构已存在)。
这个设计逼着你直面两个硬核问题:一是requests.get()的timeout参数为什么必须设为 5 秒而非默认的 forever?二是当scrawler.html提交http://example.com时,index.py如何防止 SSRF 攻击?答案就藏在pcUtils.py新增的validate_url()函数里——它用正则r'^https?://[^\s/$.?#].[^\s]*$'做基础校验,虽不完美,但已是教学场景下最务实的防线。
2.4 第四次作业:bionews-*.jpg图像元数据采集实战
第四次作业彻底脱离文本采集,转向图像资源。bionews-*.jpg共 12 张图片(bionews-0.jpg到bionews-11.jpg),每张都嵌入了 EXIF 信息:拍摄时间、相机型号、GPS 坐标(部分)、软件版本。index.py新增extract_image_metadata()函数,调用PIL.Image.open()+image._getexif()提取原始数据。这里的关键教学点是:
bionews-8.jpg的 EXIF 中DateTimeOriginal字段值为'2023:09:12 14:23:05',需用datetime.strptime()转为 Python datetime 对象;bionews-3.jpg的 GPSInfo 字段返回字典{1: b'\x01', 2: ((39, 1), (56, 1), (12, 1)), ...},需按 EXIF 标准解析为经纬度;- 所有图片均经
exiftool -all= bionews-0.jpg清除敏感信息后再放入项目,确保教学安全。
这个环节让学生第一次意识到:“数据采集”不只是抓 HTML,更是从二进制流中结构化提取元数据——而PIL库对 EXIF 的支持并不统一,image._getexif()在 Pillow 9.x 版本后已被标记为 deprecated,但本项目仍保留它,正是为了让你亲手踩一次兼容性坑。
2.5readme.txt与.keep文件:被忽略的工程规范意识
整个项目中,readme.txt只有 3 行文字:“1. 运行前请安装 requirements.txt 中的依赖;2. 各次作业目录独立运行;3.pcUtils.py为通用工具模块,请勿修改函数签名。” 而static/imgs/.keep、templates/.keep等文件看似无用,实则是 Git 仓库中保留空目录的约定。这些细节共同构成了一种隐性教学:
requirements.txt内容为requests==2.28.1\nbeautifulsoup4==4.11.1\njinja2==3.1.2\nPillow==9.2.0,精确锁定版本,避免pip install -r requirements.txt时因版本漂移导致bs4解析失败;.keep文件的存在,让git status始终显示static/imgs/目录已跟踪,防止学生误删整个资源目录;readme.txt第二条强调“各次作业目录独立运行”,暗示了模块隔离设计原则——第二次作业/main.py不能 import第一次作业/scrawler.py,必须通过pcUtils.py复用。
这种“看不见的规范”,比任何代码注释都更能培养工程直觉。
3.pcUtils.py:7 个函数背后的容错设计哲学与参数陷阱
3.1parse_table():表格解析的四层防御机制
这是pcUtils.py中最复杂的函数,负责从 HTML 表格中提取结构化数据。它不是简单调用soup.find('table').find_all('tr'),而是构建了四层防护:
def parse_table(html_content, header_row=0, skip_empty=True, max_rows=100): soup = BeautifulSoup(html_content, 'html.parser') table = soup.find('table') if not table: return [] # 第一层:表不存在直接返回空 rows = table.find_all('tr') if len(rows) <= header_row: return [] # 第二层:行数不足 header_row,无法取标题 headers = [th.get_text(strip=True) for th in rows[header_row].find_all(['th', 'td'])] if not headers: return [] # 第三层:标题行无文本,视为无效表 data = [] for i, row in enumerate(rows[header_row+1:], start=header_row+1): if i >= max_rows + header_row + 1: break # 第四层:硬性截断,防内存溢出 cells = row.find_all(['td', 'th']) if skip_empty and len(cells) == 0: continue row_data = {} for j, cell in enumerate(cells): key = headers[j] if j < len(headers) else f'col_{j}' row_data[key] = cell.get_text(strip=True) data.append(row_data) return dataheader_row=0参数允许指定标题行位置,适应<thead>缺失的烂表格;skip_empty=True过滤空行,但skip_empty=False时会保留{'col_0': '', 'col_1': ''}占位;max_rows=100是安全阀,当遇到<table><tr>...</tr>循环嵌套时,避免无限递归;key = headers[j] if j < len(headers) else f'col_{j}'处理列数 > 标题数的异常,不抛错而降级为编号键。
这个函数的设计逻辑是:宁可返回不完美的数据,也不让程序崩溃——这才是生产级采集的真实哲学。
3.2clean_text():中文文本清洗的边界案例覆盖
该函数专治网页中常见的“脏文本”,但它的实现远比str.strip().replace('\n', '').replace('\t', '')复杂:
def clean_text(text, remove_punct=True, normalize_space=True, keep_chinese=True): if not isinstance(text, str): text = str(text) # 处理常见编码残留 text = text.replace('\u3000', ' ') # 全角空格转半角 text = text.replace('\xa0', ' ') # NBSP if remove_punct: # 保留中文标点、英文字母、数字,清除其他符号 text = re.sub(r'[^\u4e00-\u9fff\w\s]', '', text) if normalize_space: text = re.sub(r'\s+', ' ', text).strip() if not keep_chinese: text = re.sub(r'[\u4e00-\u9fff]+', '', text) return text\u3000(全角空格)和\xa0(NBSP)是中文网页最常见的隐形字符,strip()完全无效;- 正则
[^\u4e00-\u9fff\w\s]显式定义“保留集”,比string.punctuation更精准,避免误删中文顿号、书名号; keep_chinese=False参数用于生成英文摘要场景,此时re.sub(r'[\u4e00-\u9fff]+', '', text)会清除所有汉字,但保留CRISPR、DNA等英文术语。
我曾见学生用text.replace(' ', '')替代normalize_space,结果把"北京 上海 广州"变成"北京上海广州"——这种“看起来省事,实际毁数据”的操作,正是clean_text()存在的意义。
3.3validate_url():URL 校验的实用主义妥协
这个函数没有追求 RFC 3986 完整合规,而是聚焦教学场景高频风险:
def validate_url(url): if not isinstance(url, str): return False if len(url) > 2000: # 防超长 URL DOS return False if url.startswith(('http://', 'https://')) is False: return False try: parsed = urlparse(url) if not parsed.netloc: return False # 禁止内网地址 if parsed.netloc in ['localhost', '127.0.0.1', '192.168.0.0']: return False # 禁止文件协议 if parsed.scheme == 'file': return False return True except Exception: return Falselen(url) > 2000是硬性长度限制,防止?a=1&b=2&...构造的超长参数耗尽内存;parsed.netloc in ['localhost', ...]明确拦截内网地址,这是 SSRF 防御的第一道墙;parsed.scheme == 'file'拦截file:///etc/passwd类攻击,虽然教学环境无危害,但习惯要从第一天养成。
它不校验 DNS 是否可达、不检查证书是否有效——因为教学目标是理解“校验什么”,而不是“校验全部”。
3.4get_page_title():标题提取的 fallback 链
网页<title>标签缺失时怎么办?这个函数给出了教科书级 fallback 链:
def get_page_title(soup): title_tag = soup.find('title') if title_tag and title_tag.get_text(strip=True): return title_tag.get_text(strip=True) h1 = soup.find('h1') if h1 and h1.get_text(strip=True): return h1.get_text(strip=True) h2 = soup.find('h2') if h2 and h2.get_text(strip=True): return h2.get_text(strip=True) # 最后 resort:取 body 前 50 字符 body = soup.find('body') if body: text = body.get_text()[:50].strip() return text + '...' if len(text) == 50 else text return "Untitled"- 优先级:
<title>><h1>><h2>>body前 50 字; - 每个分支都做
strip()和空值判断,避免返回" \n\t "; body.get_text()[:50]是终极保底,但加...提示截断,不误导用户。
这个设计教会学生:数据质量不是非黑即白,而是分层保障。
4. 避坑指南:7 个真实翻车现场与血泪修复方案
4.1 现象:main.py运行报错ModuleNotFoundError: No module named 'bs4'
原因:requirements.txt中beautifulsoup4==4.11.1与系统已装bs4版本冲突,或pip install -r requirements.txt未执行。更隐蔽的情况是:学生用conda环境却执行pip install,导致包安装到 base 环境而非当前 conda env。
解决:
- 先确认当前环境:
which python和python -m pip list | grep beautifulsoup; - 若用 conda,统一用
conda install beautifulsoup4=4.11.1; - 若坚持 pip,执行
python -m pip install -r requirements.txt --force-reinstall强制重装。
注意:
--force-reinstall会覆盖现有包,但比手动pip uninstall bs4 && pip install bs4==4.11.1更可靠,因后者可能残留.dist-info目录引发冲突。
4.2 现象:bionews-*.jpg的 EXIF 提取为空字典{}
原因:Pillow 版本 ≥ 10.0.0 后,image._getexif()返回None而非字典;或图片本身无 EXIF(如经过在线压缩工具处理)。本项目所附图片经exiftool -all= *.jpg清除后重新注入测试数据,但学生若自行替换图片,EXIF 可能丢失。
解决:
- 降级 Pillow:
pip install Pillow==9.2.0(项目指定版本); - 或改用新 API:
exif = image.getexif(); if exif: metadata = {ExifTags.TAGS[k]: v for k, v in exif.items() if k in ExifTags.TAGS}; - 验证图片 EXIF:终端执行
exiftool bionews-0.jpg | head -10,确认ExifTool Version Number行存在。
4.3 现象:scrawler.html表单提交后,result.html显示KeyError: 'items'
原因:index.py中render_template('result.html', **context)的context字典未包含items键,通常因scrawler.py的extract_data()函数返回None或空列表,而主逻辑未做判空处理。
解决:
- 在
index.py的/crawl路由中,添加if not data: data = []; - 在
result.html中,用{% if items %}...{% else %}<p>未采集到数据</p>{% endif %}包裹循环; - 关键:在
scrawler.py的extract_data()函数末尾加return data or [],确保返回值类型恒为 list。
4.4 现象:clean_text()处理bionews-5.jpg的 alt 文本时,中文冒号被误删
原因:clean_text()中remove_punct=True的正则[^\u4e00-\u9fff\w\s]未包含中文标点 Unicode 范围\u3002\uff1a\uff0c\uff1b\uff1f\uff01(句号、冒号、逗号等),导致:被当作非法符号清除。
解决:
- 扩展正则:
[^\u4e00-\u9fff\u3002\uff1a\uff0c\uff1b\uff1f\uff01\w\s]; - 或更稳妥:
text = re.sub(r'[^\u4e00-\u9fff\w\s\u3000-\u303f\uff00-\uffef]', '', text),覆盖 CJK 符号区; - 测试用例:
assert clean_text("基因编辑:CRISPR") == "基因编辑:CRISPR"必须通过。
4.5 现象:templates/下pc.html渲染时,{{ item.price }}显示None
原因:pcUtils.py的parse_table()函数中,当某行<td>数量少于标题列数时,headers[j]索引越界,row_data[key]赋值失败,导致item.price为None。
解决:
- 修改
parse_table()中key = headers[j] if j < len(headers) else f'col_{j}'为key = headers[j] if j < len(headers) else f'col_{j}'(原代码已正确,此为常见误改); - 更根本:在
main.py调用parse_table()后,对每行item做item.setdefault('price', 'N/A'); - 教学建议:在
result.html中用{{ item.price|default('¥0.00') }}设置 Jinja2 默认值,而非在 Python 层修补。
5. 进阶技巧:用pcUtils.py的validate_url()构建可审计的采集日志系统
5.1 日志结构设计:从“能跑”到“可追溯”
单纯记录url和status_code远不够。真正的可审计日志必须包含:
- 采集上下文:触发来源(手动输入 / 定时任务 / API 调用)、操作者 ID(教学场景可设为学号)、时间戳(UTC+0);
- 请求指纹:URL 的 SHA256 哈希(防 URL 参数篡改)、User-Agent 字符串、超时设置;
- 响应摘要:HTTP 状态码、Content-Type、Content-Length、重定向链(最多 5 跳);
- 解析结果:成功提取字段数、失败字段列表、
parse_table()的max_rows实际截断行数。
pcUtils.py的validate_url()已提供 URL 校验基础,只需在其返回True后,追加日志生成逻辑:
import hashlib import json from datetime import datetime def log_crawl_event(url, status_code, user_id="student_001", **kwargs): log_entry = { "timestamp": datetime.utcnow().isoformat(), "user_id": user_id, "url_hash": hashlib.sha256(url.encode()).hexdigest()[:16], "url": url, "status_code": status_code, "user_agent": kwargs.get("user_agent", "DataScience-Course/1.0"), "timeout": kwargs.get("timeout", 5), "content_type": kwargs.get("content_type", ""), "content_length": kwargs.get("content_length", 0), "redirect_chain": kwargs.get("redirect_chain", []), "parsed_fields": kwargs.get("parsed_fields", {}), "failed_fields": kwargs.get("failed_fields", []) } # 写入 JSONL 文件,每行一个日志对象 with open("crawl_log.jsonl", "a", encoding="utf-8") as f: f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")5.2 日志消费:用 Pandas 快速定位问题批次
有了crawl_log.jsonl,即可用以下代码分析:
import pandas as pd # 读取日志(JSONL 格式) logs = [] with open("crawl_log.jsonl", "r", encoding="utf-8") as f: for line in f: logs.append(json.loads(line.strip())) df = pd.DataFrame(logs) # 查找失败率最高的 URL 前缀 df_failed = df[df['status_code'] >= 400] top_fail_domains = df_failed['url'].str.extract(r'https?://([^/]+)').value_counts().head(5) print("Top failing domains:\n", top_fail_domains) # 查找 `parse_table()` 截断最多的任务 df_truncated = df[df['parsed_fields'].apply(lambda x: x.get('truncated_rows', 0) > 0)] print(f"Truncated tasks: {len(df_truncated)}")这个分析能立刻告诉你:是不是bionews-*.jpg所在域名近期频繁返回 403?是不是某次作业的max_rows=100设置过低导致大量截断?——这些洞察,远比“脚本跑完了”更有教学价值。
5.3 日志安全:.gitignore必须包含的三类文件
教学项目常犯的错误,是把日志文件提交到 Git。必须在.gitignore中明确:
# 日志文件 crawl_log.jsonl *.log __pycache__/ # 临时文件 *.swp *.swo # 用户数据(教学场景特指) student_data/ output/尤其crawl_log.jsonl,一旦提交,等于公开了所有采集行为的完整轨迹。我在评审学生作业时,第一眼就看.gitignore是否包含crawl_log.jsonl——这比代码本身更能反映工程素养。
从那以后我每次新建数据采集项目,都强制走一遍touch crawl_log.jsonl && echo "crawl_log.jsonl" >> .gitignore && git add .gitignore流程。不是怕出错,是怕忘了敬畏数据——希望帮到你。
本文还有配套的精品资源,点击获取