第一次个人编程作业
https://github.com/yyxyxx/youyong
第一次个人编程作业
| 这个作业属于哪个课程 | 计科24级78班 - 广东工业大学 - 班级博客 |
|---|---|
| 这个作业要求在哪里 | 个人项目 - 作业 - 计科24级78班 |
| 这个作业的目标 | 独立完成一次完整的软件工程个人项目,并完成 GitHub 管理、PSP、性能分析、单元测试和覆盖率分析 |
语言 / 环境:Python 3.8+,入口文件为 main.py,运行方式:
python main.py [原文文件] [抄袭版论文的文件] [答案文件]
程序从命令行读取原文与抄袭版论文的绝对路径,计算两篇文本的重复率,并把结果以保留两位小数的浮点型写入答案文件。
一、准备
1. 我的 GitHub 项目链接
https://github.com/yyxyxx/youyong
仓库根目录下为学号文件夹 3124004447。主要文件如下:
3124004447/
├── main.py
├── requirements.txt
├── README.md
├── PSP.md
├── tests/
│ └── test_main.py
└── docs/
├── DESIGN.md
├── PERFORMANCE.md
├── TEST_REPORT.md
└── EXCEPTIONS.md
本项目使用 Python 标准库完成,requirements.txt 中没有第三方运行依赖。
2. PSP 表格
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 20 | 10 |
| · Estimate | 估计任务规模 | 20 | 10 |
| Development | 开发 | 305 | 250 |
| · Analysis | 需求分析(包括学习 HTML 解析、n-gram、余弦相似度) | 40 | 55 |
| · Design Spec | 生成设计文档 | 30 | 25 |
| · Design Review | 设计复审 | 20 | 15 |
| · Coding Standard | 代码规范 | 15 | 10 |
| · Design | 具体设计 | 30 | 25 |
| · Coding | 具体编码 | 90 | 70 |
| · Code Review | 代码复审 | 30 | 20 |
| · Test | 测试(自我测试、修改代码、提交修改) | 50 | 30 |
| Reporting | 报告 | 75 | 60 |
| · Test Report | 测试报告 | 40 | 35 |
| · Size Measurement | 计算工作量 | 15 | 10 |
| · Postmortem & Process Improvement Plan | 事后总结和改进计划 | 20 | 15 |
| 合计 | 总计 | 400 | 320 |
3. 需求分析
- 程序通过命令行接收 原文文件路径、抄袭版论文文件路径、答案输出文件路径 三个参数。
- 程序应能读取普通文本和课程样例中的 HTML 文件,并提取其中的论文正文。
- 程序需对文本进行规范化,过滤标点和空白,降低格式差异对结果的影响。
- 程序需计算两篇文本的重复率,并将结果保留两位小数后写入答案文件。
- 非功能要求:单次运行小于 5 秒,内存小于 2048 MB,不联网,不读写参数以外的业务文件。
- 文件不存在、文件无法读取或解码、答案文件无法写入、参数数量错误等情况需要统一处理,不能产生未捕获异常。
本项目功能划分为:
- 基础功能:命令行参数解析、文件读写、文本规范化、相似度计算、两位小数输出。
- 扩展功能:GitHub blob HTML 正文提取、字符级 1/2/3-gram 加权余弦、自定义异常体系、29 个自动化测试、有效分支覆盖率约 99%、性能分析。
4. 代码设计
项目入口和核心逻辑集中在 main.py,主要组成部分如下:
PlagiarismError:业务异常基类。ArgumentError:参数数量错误或答案路径与输入路径相同。InputFileError:文件不存在、读取失败、编码失败或 HTML 解析失败。OutputFileError:答案文件无法写入。DocumentHTMLParser:从普通 HTML 或 GitHub blob 页面提取正文。looks_like_html:判断文本是否为 HTML。extract_document_text:返回普通文本或 HTML 中的论文正文。normalize_text:统一字符宽度、英文大小写,并移除标点和空白。iter_ngrams:生成字符级 1-gram、2-gram、3-gram。cosine_similarity:计算两个词频向量的余弦相似度。calculate_similarity:计算最终加权重复率。read_text、write_score:负责文件输入输出。parse_arguments、main:负责命令行参数校验和整体流程调度。
二、代码分析
1. 计算模块接口的设计与实现过程
(1)模块组织与函数结构
程序采用“一个入口文件 + 若干职责单一的函数/类”的结构:
- HTML 处理层:
DocumentHTMLParser优先提取 GitHub blob 中的代码行,同时支持普通 HTML 可见文本,并跳过script、style、noscript。 - 文本处理层:
normalize_text执行 Unicode NFKC 规范化、英文大小写统一和字符过滤。 - 特征提取层:
iter_ngrams生成 1-gram、2-gram、3-gram。 - 相似度计算层:
cosine_similarity根据词频向量计算余弦相似度,calculate_similarity完成加权。 - 输入输出层:
read_text读取 UTF-8/GB18030,write_score输出两位小数。 - 入口控制层:
parse_arguments校验参数,main负责执行全流程和统一异常处理。
核心调用关系如下:
main
→ parse_arguments
→ read_text
→ calculate_similarity
→ extract_document_text
→ normalize_text
→ iter_ngrams
→ cosine_similarity
→ write_score
(2)关键函数流程图
如果不支持 Mermaid,可用下面文字描述代替:
main → 校验参数 → 读取原文 → 读取抄袭版 → 判断 HTML
→ 提取正文 → 规范化 → 统计 1/2/3-gram
→ 计算三个余弦相似度 → 加权 → 写入答案文件
(3)算法的关键要素
程序使用字符级 1-gram、2-gram、3-gram 词频向量的加权余弦相似度。
单个 n-gram 的余弦相似度:
cos(A, B) = (A · B) / (|A| × |B|)
最终重复率:
score = 0.35 × cos(1-gram)
+ 0.45 × cos(2-gram)
+ 0.20 × cos(3-gram)
核心代码:
NGRAM_WEIGHTS = ((1, 0.35), (2, 0.45), (3, 0.20))
def calculate_similarity(original_text: str, suspect_text: str) -> float:
original = normalize_text(extract_document_text(original_text))
suspect = normalize_text(extract_document_text(suspect_text))
if not original and not suspect:
return 1.0
if not original or not suspect:
return 0.0
weighted_score = 0.0
for ngram_size, weight in NGRAM_WEIGHTS:
original_terms = Counter(iter_ngrams(original, ngram_size))
suspect_terms = Counter(iter_ngrams(suspect, ngram_size))
weighted_score += weight * cosine_similarity(original_terms, suspect_terms)
return max(0.0, min(1.0, weighted_score))
(4)算法的技术特征
- 对增删较稳健:局部插入或删除少量内容时,大部分 n-gram 仍然相同,不会导致结果剧烈下降。
- 兼顾局部顺序:1-gram 对整体词频敏感,2-gram 和 3-gram 能反映连续文本特征。
- 不需要中文分词:按字符切分,避免词典差异、分词模式差异带来的不稳定结果。
- 能够处理 HTML 样例:先从 GitHub blob 的代码行提取论文正文,再参与计算。
- 计算复杂度接近线性:每个 n-gram 只扫描一次,适合约一万字符的课程样例。
独到之处:
- 优先识别 GitHub blob 的
blob-code、js-file-line和id="LC..."代码行,避免导航栏、脚本和样式干扰结果。 - 对普通 HTML 自动回退到可见文本,普通文本则完全不经过 HTML 解析。
- 使用 1/2/3-gram 加权组合,而不是只看单一字符频率或字符串匹配。
- 不依赖任何第三方运行库,降低评测环境安装失败的风险。
- 所有可预期错误统一转换为自定义异常,避免评测时异常退出。
2. 计算模块接口部分的性能改进
(1)性能改进所花费的时间
性能分析、算法替换和复测共约 45 分钟。
分析工具:
- Python 标准库
cProfile time.perf_countertracemalloc
(2)性能分析图展示

(3)程序中消耗最大的函数
对 orig.txt 与 orig_0.8_add.txt 执行 cProfile,结果如下:
182568 function calls in 0.109 seconds
main.calculate_similarity 0.069 秒
collections.Counter 统计 0.029 秒
main.cosine_similarity 0.020 秒
main.normalize_text 0.020 秒
main.iter_ngrams 0.014 秒
消耗最大的部分是:
calculate_similarity:相似度计算的总入口。Counter:统计 1/2/3-gram 的出现次数。cosine_similarity:计算点积和向量模长。normalize_text:遍历全文执行规范化。iter_ngrams:生成连续字符片段。
(4)改进思路与实现
初版问题:
初版使用最长公共子序列,时间复杂度为 O(m × n)。原文约 9,534 个字符,添加版本约 11,297 个字符,单次需要比较超过 1 亿次,不适合 5 秒限制。
改进方案:
- 将 LCS 替换为字符级 1/2/3-gram 加权余弦,时间复杂度接近线性。
- 使用
Counter统计 n-gram,避免手写双重循环。 - HTML 文件先抽取正文,减少脚本、样式和导航内容。
- 对空文本提前返回,避免无意义计算。
- 只在两个词频表的共有项上计算点积,降低无效运算。
(5)改进后的效果对比
| 指标 | 初版 LCS | 改进后 n-gram |
|---|---|---|
| 时间复杂度 | O(m × n) |
接近 O(n) |
| 一万字符级别 | 存在超时风险 | 可稳定在 1 秒内 |
| 第三方依赖 | 无 | 无 |
| HTML 样例 | 未专门处理 | 自动提取正文 |
| 单个真实样例耗时 | 未达到要求 | 约 0.16~0.19 秒 |
对约 9,700 个字符的 orig_0.8_dis_15.txt 使用 tracemalloc 测得:
峰值内存约 2.32 MB
远低于 2048 MB 限制。
3. 计算模块部分单元测试展示
使用 Python 标准库 unittest 编写单元测试,共 29 个测试用例,全部通过。
python -m unittest discover -s tests -v
(1)单元测试代码片段展示
def test_assignment_sample_has_medium_similarity(self) -> None:
original = "今天是星期天,天气晴,今天晚上我要去看电影。"
suspect = "今天是周天,天气晴朗,我晚上要去看电影。"
score = main.calculate_similarity(original, suspect)
self.assertGreater(score, 0.60)
self.assertLess(score, 0.80)
def test_github_blob_html_prefers_code_lines(self) -> None:
html = (
"<!DOCTYPE html><html><body><nav>GitHub 导航</nav>"
'<table><tr><td id="LC1" class="blob-code js-file-line">第一行</td>'
'<td id="LC2" class="blob-code js-file-line">第二行</td></tr></table>'
"<script>无关脚本</script></body></html>"
)
self.assertEqual(main.extract_document_text(html), "第一行\n第二行")
def test_main_writes_score_with_two_decimals(self) -> None:
original = self._write_text("original.txt", "今天是星期天,天气晴,今天晚上我要去看电影。")
suspect = self._write_text("suspect.txt", "今天是周天,天气晴朗,我晚上要去看电影。")
answer = self.directory / "answer.txt"
exit_code = main.main(["main.py", str(original), str(suspect), str(answer)])
self.assertEqual(exit_code, 0)
self.assertEqual(answer.read_text(encoding="utf-8"), "0.67\n")
(2)测试的函数与接口
测试覆盖了:
calculate_similaritynormalize_textiter_ngramscosine_similaritylooks_like_htmlextract_document_textread_textwrite_scoreparse_argumentsmain
(3)测试数据的构造思路
采用“白盒 + 边界值 + 等价类划分”的方式:
- 边界值:两个空文本、单侧为空、完全相同、完全不同。
- 文本修改:插入字符、删除字符、替换字符。
- 格式差异:英文大小写、全角/半角、空格和标点差异。
- HTML 场景:GitHub blob 代码行、普通 HTML、嵌套标签、空代码行、脚本和样式过滤。
- 异常场景:文件不存在、无法解码、读取失败、写入失败、参数数量错误。
- 编码场景:UTF-8 和 GB18030。
- 端到端场景:通过子进程运行
main.py,检查答案文件内容。
(4)单元测试覆盖率展示
使用 coverage.py 的分支覆盖率统计:
| 模块 | 语句数 | 未覆盖语句 | 分支数 | 部分分支 | 覆盖率 |
|---|---|---|---|---|---|
main.py |
158 | 1 | 52 | 1 | 99% |
tests/test_main.py |
173 | 1 | 2 | 1 | 99% |
| 整体 | 331 | 2 | 54 | 2 | 99% |
主程序未覆盖的是 if __name__ == "__main__" 的子进程入口行,该入口已经通过命令行和子进程测试执行成功。

(5)真实样例运行结果
运行方式:
python main.py 测试文本/orig.txt 测试文本/orig_0.8_add.txt 测试文本/ans.txt
| 样例 | 输出重复率 | 单个样例耗时 |
|---|---|---|
orig_0.8_add.txt |
0.88 | 约 0.17 秒 |
orig_0.8_del.txt |
0.88 | 约 0.19 秒 |
orig_0.8_dis_1.txt |
0.97 | 约 0.19 秒 |
orig_0.8_dis_10.txt |
0.90 | 约 0.18 秒 |
orig_0.8_dis_15.txt |
0.76 | 约 0.18 秒 |
其中 orig_0.8_del.txt 和 orig_0.8_dis_*.txt 是包含 GitHub blob 页面的 HTML 文件,程序会先提取代码行中的真实论文正文,再计算重复率。
4. 计算模块部分异常处理说明
程序以 PlagiarismError 为基类,所有可预期错误都会在 main 中统一捕获,并转换为受控退出码。
| 异常类 | 设计目标 | 触发场景 | 对应测试 |
|---|---|---|---|
ArgumentError |
参数不合法时提示用法,返回 2 | 参数数量不等于 3 个 | test_main_rejects_wrong_argument_count |
ArgumentError |
防止覆盖输入文件,返回 2 | 答案路径等于输入路径 | test_main_rejects_answer_path_equal_to_input |
InputFileError |
输入文件不存在时输出错误,返回 3 | 文件不存在或不是普通文件 | test_main_reports_missing_input |
InputFileError |
编码异常时安全退出,返回 3 | 文件无法按 UTF-8 或 GB18030 解码 | test_main_reports_undecodable_input |
InputFileError |
读取失败时统一包装异常,返回 3 | 操作系统拒绝读取 | test_read_text_reports_os_error |
InputFileError |
HTML 解析失败时安全退出,返回 3 | HTML 解析器抛出异常 | test_html_parse_error_is_wrapped |
OutputFileError |
答案无法写入时输出错误,返回 3 | 答案路径是目录或不可写 | test_main_reports_output_write_error |
(1)命令行参数异常(ArgumentError)
设计目标:当参数数量不是 3 个,或者答案路径与输入路径相同时,禁止继续执行并返回明确错误。
def test_main_rejects_wrong_argument_count(self) -> None:
error_output = io.StringIO()
with redirect_stderr(error_output):
exit_code = main.main(["main.py", "only-one-path"])
self.assertEqual(exit_code, 2)
self.assertIn("用法", error_output.getvalue())
对应场景:执行时漏传原文、抄袭版或答案文件路径。
(2)文件读取异常(InputFileError)
设计目标:文件不存在、无法读取或编码不合法时,不输出 Python 堆栈,而是输出可读错误并返回退出码 3。
def test_main_reports_missing_input(self) -> None:
original = self.directory / "missing.txt"
suspect = self._write_text("suspect.txt", "内容")
answer = self.directory / "answer.txt"
with redirect_stderr(io.StringIO()):
exit_code = main.main(["main.py", str(original), str(suspect), str(answer)])
self.assertEqual(exit_code, 3)
self.assertFalse(answer.exists())
对应场景:文件被移动、重命名、路径拼写错误或文件编码不正确。
(3)HTML 解析异常(InputFileError)
设计目标:HTML 解析器发生异常时,不让异常直接传播到评测程序。
def test_html_parse_error_is_wrapped(self) -> None:
html = "<!DOCTYPE html><html><body>正文</body></html>"
with mock.patch.object(
main.DocumentHTMLParser,
"feed",
side_effect=ValueError("解析失败"),
):
with self.assertRaises(main.InputFileError):
main.extract_document_text(html)
对应场景:输入文件表面上是 HTML,但内容结构异常或解析器运行失败。
(4)答案写入异常(OutputFileError)
设计目标:答案路径是目录、无权限或父目录不存在时,输出错误并返回退出码 3。
def test_main_reports_output_write_error(self) -> None:
original = self._write_text("original.txt", "内容")
suspect = self._write_text("suspect.txt", "内容")
answer_directory = self.directory / "answer"
answer_directory.mkdir()
with redirect_stderr(io.StringIO()):
exit_code = main.main(
["main.py", str(original), str(suspect), str(answer_directory)]
)
self.assertEqual(exit_code, 3)
对应场景:用户把答案路径写成了已经存在的目录。
三、开发过程与 GitHub 提交记录
项目按照功能推进逐步提交:
880dc0d Initial commit
6da97f0 feat: 添加论文查重基础实现
abae5ba perf: 使用滚动数组优化LCS内存
008ad06 docs: 补充设计测试与异常说明
3288280 feat: 支持HTML正文并改用n-gram查重
1e1fb1f docs: 更新真实样例性能与覆盖率
其中 3288280 是根据真实课程样例完成的关键修改:
- 增加 GitHub blob HTML 正文提取。
- 将 LCS 动态规划替换为线性 n-gram 余弦相似度。
- 单元测试增加到 29 个。
- 主程序分支覆盖率提升到 99%。
四、代码质量分析
本项目从以下方面保证代码质量:
- 函数职责单一,HTML 提取、文本处理、相似度计算和文件读写相互分离。
- 核心函数均有类型标注和中文注释。
- 文件和参数异常统一处理。
- 不尝试连接网络。
- 不读取原文和抄袭版以外的业务文件。
- 只写入答案路径。
- 使用
ruff检查代码,结果为 0 个警告。 - 使用
unittest编写 29 个自动化测试。
代码质量检查命令:
python -m compileall .
ruff check .
ruff format --check .
python -m unittest discover -s tests -v
当前结果:
compileall:通过
ruff:0 个警告
单元测试:29 / 29 通过
主程序分支覆盖率:99%
真实样例单次耗时:约 0.16~0.19 秒
峰值内存:约 2.32 MB
五、附录:运行与测试
1. 运行程序
进入学号目录:
cd 3124004447
执行:
python main.py [原文文件] [抄袭版论文的文件] [答案文件]
示例:
python main.py C:\tests\orig.txt C:\tests\orig_0.8_add.txt C:\tests\ans.txt
答案文件中输出保留两位小数的浮点型重复率。
2. 运行单元测试
python -m unittest discover -s tests -v
3. 生成覆盖率报告
安装临时分析工具:
pip install coverage
执行:
python -m coverage run --branch -m unittest discover -s tests
python -m coverage report -m main.py tests/test_main.py
python -m coverage html -d htmlcov
HTML 报告位置:
htmlcov/index.html
4. 运行性能分析
python -m cProfile -s cumulative main.py ..\测试文本\orig.txt ..\测试文本\orig_0.8_add.txt ..\测试文本\ans.txt

浙公网安备 33010602011771号