简介:基于启发式特征的钓鱼网站检测系统是一份可以直接运行的 Python 项目源码,面向网络安全学习者、Web 开发者及对反欺诈技术感兴趣的初学者,完整呈现了从启发式特征提取到机器学习模型构建的检测链路。压缩包内共 2 个 py 文件,总大小仅 7KB,代码精简、注释直观,方便快速阅读核心实现,也适合在此基础上二次扩展。已有 180 人学习下载。项目在特征工程层面覆盖域名关键词、URL 长度、HTML 标签结构、TF-IDF 文本相似度等多个判断维度,并结合 requests、BeautifulSoup、scikit-learn 等库演示数据采集、特征处理、模型训练与预测评估流程;同时还涉及朴素贝叶斯、决策树、随机森林等算法的选型对比,以及数据更新与模型优化思路。通过学习这一项目,读者可以掌握钓鱼网站自动识别的基本原理与 Python 实现要点,为后续构建更完整、更可靠的反钓鱼安全工具打下扎实基础。
1. 钓鱼网站检测:启发式特征这条路为什么值得走
钓鱼网站检测最常见的做法是维护黑名单,把已知的恶意域名存进一张表里,来一个请求匹配一次。这样做见效快,但钓鱼者换个域名、改个路径,黑名单还没更新,用户就已经中招了。基于启发式特征的检测系统思路不一样:它不看“这个域名以前干没干过坏事”,而是分析“这个页面长没长坏人的样子”。域名结构、URL 长度、HTML 结构、文本相似度,这些特征从页面本身提取出来,交给机器学习模型打分,未知域名也能直接判。这套系统适合两类人:一类是安全方向的学生,想拿真实项目练手;另一类是刚接触网站安全的产品或运维,需要低成本搭一个检测模块,跑通从特征提取到模型预测的完整链路。
2. 启发式检测原理与数据集准备:先搞清楚在检测什么,再动手抓数据
2.1 启发式特征和黑名单、白名单的本质差别
黑名单是“我认识坏人”,启发式是“我认得坏人的习惯”。黑名单系统的逻辑是精确匹配:如果 URL 命中已知钓鱼库,直接拦截。优点是误报率极低,缺点是钓鱼域名存活时间短、更新太快,黑名单永远有滞后性。白名单正好反过来,只放行经过验证的域名,但互联网上的合法站点数量太大,白名单维护成本不现实。
启发式检测走的是另一条路。它把钓鱼网站和正常网站当成两类样本,提取能区分两者的统计特征。好比警察认人,不看名字,看作案手法。这类系统的核心假设是:钓鱼网站在设计上必须模仿正常登录页面、必须引导用户输入信息,所以它的域名结构、URL 特征、页面元素分布一定会留下痕迹。不需要知道这个站点曾经干过什么,只要特征分布像钓鱼,就按钓鱼处理。
了解这一点对后续开发很重要,因为特征工程的方向就来自这个假设。你不是在找“某个具体网站”,而是在找“一类可疑行为的共同模式”。
2.2 数据集收集:爬虫抓取与公开数据源搭配
要训练模型,先得有标注好的两类数据:钓鱼网站样本和正常网站样本。钓鱼样本可以使用 PhishTank 这类公开平台的导出数据,里面有大量经过人工确认的钓鱼 URL。正常样本可以从 Alexa 排名靠前的站点里抽样,或者用常见目录站点列表。注意,这里说的是训练数据来源,实际抓取时要控制并发、设置延时,不要对目标站点造成压力。
写爬虫时我的做法是用 requests 抓 HTML,配合 User-Agent 伪装成浏览器,避免被简单的反爬策略挡掉。
import requests from fake_useragent import UserAgent headers = { "User-Agent": UserAgent().random, "Accept-Language": "zh-CN,zh;q=0.9" } def fetch_html(url, timeout=10): try: resp = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True) if resp.status_code == 200: return resp.text except requests.exceptions.RequestException as e: print(f"[抓取失败] {url} -> {e}") return ""逻辑说明:这段代码的作用是稳健抓取页面 HTML。fake_useragent库每次请求随机生成一个浏览器 UA,降低被封概率。timeout=10表示超过 10 秒就放弃这次请求,防止个别慢站点拖垮整个数据收集流程。allow_redirects=True默认跟随重定向,因为很多钓鱼站点会做跳转,不跟随可能只拿到一个空壳页面。
参数说明:如果你要在大规模数据上跑,timeout 可以适当放宽到 15 或 20,但并发数一定要控制住,常见做法是ThreadPoolExecutor配合max_workers=5左右,别一口气开几十个线程,不然很容易触发目标站点的防护。
2.3 数据标注与目录结构:每个样本都要能追溯
抓回来的原始数据不能直接丢给模型。我的习惯是建一个标准化目录,每个 URL 对应一个文件,文件名用 URL 的 MD5 哈希做标识,避免 URL 里的特殊字符破坏文件系统结构。标注信息统一放在一个 CSV 里,字段包括url、label、html_path,label 用 0 表示正常、1 表示钓鱼。
project_root/ ├── data/ │ ├── raw/ │ │ ├── phishing/ # 存放抓取的钓鱼页面 HTML │ │ ├── legitimate/ # 存放正常页面 HTML │ ├── processed/ │ │ ├── features.csv # 特征矩阵,每行一个样本 │ ├── labels.csv # url 与 label 的对应关系 ├── src/ │ ├── feature_extraction.py │ ├── train_model.py │ ├── predict.py标注这一步最容易被新手跳过,但恰恰是最重要的。最好把每个样本的原始 URL 和 HTML 文件都保留下来,模型训练完做错误分析时,你要能回到原始页面去看模型为什么判错。如果只留特征向量不留原始样本,发现问题时想排查都没有依据。
3. 特征工程实战:把 URL 和 HTML 变成模型能直接吃的向量
3.1 域名结构特征:长度、子域名数量、敏感关键词
钓鱼网站特征在域名层面是最明显的。正常站点域名通常简洁、有品牌辨识度,钓鱼域名为了模仿或蹭热度,往往会加一堆无关字符。这里我提取三个核心特征:域名总长度、子域名数量、是否包含敏感关键词。
from urllib.parse import urlparse import re SENSITIVE_KEYWORDS = ["login", "signin", "verify", "account", "secure", "webscr"] def extract_domain_features(url): parsed = urlparse(url) domain = parsed.netloc # 去掉端口号 if ":" in domain: domain = domain.split(":")[0] parts = domain.split(".") # 子域名数量:去掉顶级域和二级域之后剩下的部分 subdomain_parts = parts[:-2] if len(parts) > 2 else [] # 域名总长度 domain_length = len(domain) # 子域名数量 subdomain_count = len(subdomain_parts) # 敏感词命中次数 domain_lower = domain.lower() keyword_hits = sum(1 for kw in SENSITIVE_KEYWORDS if kw in domain_lower) return { "domain_length": domain_length, "subdomain_count": subdomain_count, "keyword_hits": keyword_hits }逻辑说明:这里用urlparse拆出 netloc(域名+端口部分),再做三层处理。第一层去掉端口号,第二层按.分割域名,第三层判断子域名数量——把最后两段当作主域名(比如example.com),剩下的都是子域名。关键词列表覆盖了钓鱼页面最常用的几个英文单词。
参数说明:subdomain_parts[:-2]这段逻辑对不同域名后缀的适用性有差异。像xxx.co.uk这种二级国家顶级域,最后的co.uk其实应该算顶级域,严格处理需要引入公共后缀列表。如果只是做演示项目,按最后两段切分已经够用,但你要知道这是在简化。
3.2 URL 结构特征:路径深度、特殊字符、是否使用 IP 地址
钓鱼网站喜欢在 URL 路径里做文章。有的用超长路径模仿正规站点,有的在路径里塞大量数字,还有直接使用 IP 地址代替域名的。这些特征在正常站点里很少出现。
def extract_url_features(url): parsed = urlparse(url) path = parsed.path query = parsed.query # 路径深度:按 / 切分后去掉空元素的数量 path_segments = [seg for seg in path.split("/") if seg] path_depth = len(path_segments) # 特殊字符数量 special_chars = sum(1 for c in path + query if c in "@?#%_=-") # 是否使用 IP 地址作为域名 netloc = parsed.netloc is_ip = bool(re.match(r"^\d{1,3}(\.\d{1,3}){3}(:\d+)?$", netloc)) # URL 总长度 url_length = len(url) return { "path_depth": path_depth, "special_chars": special_chars, "is_ip": int(is_ip), "url_length": url_length }逻辑说明:path_depth统计路径里有多少个有效层级,比如example.com/a/b/c.html深度是 3。special_chars统计路径和查询参数里的特殊字符,钓鱼 URL 往往在这些位置堆砌额外参数。is_ip用正则判断 netloc 是否是 IPv4 地址格式,如果域名位置出现纯 IP,这本身就是一个强信号。
这里有个细节值得注意:url_length单独用意义不大,但和path_depth、special_chars组合使用效果很好。因为正常站点的短路径 URL 长度通常也比较短,钓鱼站点要模仿真实链接结构,只能靠加长后面的路径来“凑数”。
3.3 HTML 内容特征:表单数量、外部链接占比、标题文本
页面内容层面的特征更能反映本质。钓鱼页面几乎必然包含表单,而且表单的action属性常常指向另一个域名。正常网站不会把登录表单提交到外域。另外,钓鱼页面的外部链接占比通常很高,因为它是临时搭建的壳子,大量资源直接引用原站点。
from bs4 import BeautifulSoup def extract_html_features(html, base_domain): if not html: return { "form_count": 0, "external_link_ratio": 0.0, "title_length": 0, "has_password_input": 0 } soup = BeautifulSoup(html, "html.parser") # 表单数量 form_count = len(soup.find_all("form")) # 外部链接占比 all_links = [a.get("href", "") for a in soup.find_all("a")] external_links = [ link for link in all_links if link.startswith("http") and base_domain not in link ] link_ratio = len(external_links) / len(all_links) if all_links else 0.0 # 标题长度 title_tag = soup.find("title") title_length = len(title_tag.get_text(strip=True)) if title_tag else 0 # 是否存在密码输入框 password_input = len(soup.find_all("input", {"type": "password"})) return { "form_count": form_count, "external_link_ratio": round(link_ratio, 4), "title_length": title_length, "has_password_input": password_input }逻辑说明:form_count直接统计页面里有多少个<form>标签,钓鱼页面通常至少有一个。external_link_ratio是外部链接除以总链接数的比例,0 表示全是站内链接,1 表示所有链接都指向外部。has_password_input用来判断页面是否包含密码输入框,这是登录页面的核心组件。
3.4 合并特征矩阵:pandas 串联所有特征
把三组特征合并成一行特征向量,是所有样本统一格式的关键一步。我用 pandas 做拼接,最后输出成一个 CSV 文件。
import pandas as pd from urllib.parse import urlparse def build_feature_matrix(urls, htmls): rows = [] for url, html in zip(urls, htmls): parsed = urlparse(url) base_domain = parsed.netloc.replace(":", "") row = {} row.update(extract_domain_features(url)) row.update(extract_url_features(url)) row.update(extract_html_features(html, base_domain)) # 最终标记 rows.append({**row, "url": url}) df = pd.DataFrame(rows) return df # 假设你已经有了 urls 和 htmls 两个列表 # df = build_feature_matrix(urls, htmls) # df.to_csv("data/processed/features.csv", index=False)逻辑说明:row.update()把三组特征的字典逐个合并到同一行数据里。zip保证了 URL 和 HTML 是按顺序一一对应的。最后用pd.DataFrame把列表转成表格结构,直接落盘。
参数说明:特征合并的顺序会影响最终列名顺序,但对模型没有影响。不过为了后续调试方便,建议每次运行都保持同样的特征顺序,不要随机调换。
4. 模型训练与评估:随机森林为主、朴素贝叶斯为辅的选择逻辑
4.1 数据集划分:训练集、验证集、测试集三层拆分布局
数据量不大时,最常见的错误是只分训练集和测试集,调参时反复看测试集结果,最后模型把测试集“背”下来了。正确做法是拆成三份:训练集用来训练,验证集用来调参,测试集只在最终评估时用一次。
from sklearn.model_selection import train_test_split # 假设 df 是上一步生成的完整特征矩阵 X = df.drop(columns=["url", "label"]) y = df["label"] # 先拆出训练集和临时集 X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # 再把临时集拆成验证集和测试集 X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, random_state=42, stratify=y_temp ) print(f"训练集: {X_train.shape}, 验证集: {X_val.shape}, 测试集: {X_test.shape}")逻辑说明:test_size=0.3先拿出 30% 的数据,再把 30% 里的 50% 分成验证集和测试集,所以最终比例是训练集 70%、验证集 15%、测试集 15%。stratify=y让切分时保持正负样本比例不变,避免某个子集里恰好全是钓鱼样本或全是正常样本。
4.2 随机森林训练:核心参数与调参思路
选择随机森林而不是线性模型,是因为启发式特征里存在大量非线性关系。比如is_ip=1这个特征单独看已经很强,但和form_count组合起来才更可靠——钓鱼 IP 域名加登录表单,基本可以断定是恶意页面。随机森林能自动捕捉这种特征组合关系。
from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report model = RandomForestClassifier( n_estimators=200, max_depth=10, min_samples_split=5, min_samples_leaf=2, class_weight="balanced", random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) # 验证集评估 y_pred = model.predict(X_val) print(classification_report(y_val, y_pred))逻辑说明:n_estimators=200表示随机森林里包含 200 棵决策树,树越多模型越稳定,但超过一定数量后收益递减。max_depth=10限制树的深度,防止单棵树过度拟合。min_samples_split=5表示节点分裂时至少要有 5 个样本,这个参数能有效控制过拟合。class_weight="balanced"自动调整正负样本权重,钓鱼和正常样本数量不均衡时,这个参数能让模型更关注少数类。
参数说明:第一次跑的时候先用默认参数看基线效果,不要一上来就调参。基线模型如果在验证集上有 0.85 以上的 F1,后面只需要微调。如果低于 0.80,问题大概率出在特征工程上,调参解决不了根本问题。
4.3 评估指标:准确率不能代表一切
钓鱼检测这类任务里,准确率是参考指标,但更要关注的是召回率和精确率。召回率低意味着有钓鱼网站漏掉了,这对用户是直接威胁;精确率低意味着大量正常网站被误伤,用户会烦到关掉检测功能。
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix acc = accuracy_score(y_val, y_pred) prec = precision_score(y_val, y_pred) rec = recall_score(y_val, y_pred) f1 = f1_score(y_val, y_pred) print(f"准确率: {acc:.4f}") print(f"精确率: {prec:.4f}") print(f"召回率: {rec:.4f}") print(f"F1: {f1:.4f}") cm = confusion_matrix(y_val, y_pred) print("混淆矩阵:") print(cm)逻辑说明:混淆矩阵的四象限对应实际正负、预测正负的四种组合。我记得刚开始跑这个项目时,准确率看着很高,但召回率只有 0.6,仔细一看发现是因为数据集里钓鱼样本太少,模型把所有样本都判成正常也能拿高分。class_weight="balanced"解决了一部分问题,但数据本身如果过于失衡,还是需要想办法扩充少数类样本。
5. 避坑与排查:启发式钓鱼检测项目里最容易翻车的五个点
5.1 数据泄漏:特征里混进了未来信息
现象:训练时 F1 达到 0.99,一上测试集就跌到 0.8 以下。
原因:特征工程里包含了对整个数据集的统计信息。比如之前有人提取“URL 出现的平均频率”作为特征,这个频率是用全体样本算出来的,相当于把测试集信息泄漏进了训练集。模型记住了“这个 URL 很罕见”,而不是“钓鱼网站长什么样”。
解决:所有特征只依赖单个 URL 和单个 HTML 页面的信息,不允许跨样本统计。如果确实需要全局统计特征,必须在特征提取之前先把数据切分开,只对训练集部分计算统计量,再应用到其他数据集。
5.2 HTML 编码问题导致 BeautifulSoup 解析乱码
现象:特征是跑完了,但中文页面的标题长度总是 0,文本内容全是乱码,特征值分布明显异常。
原因:requests 抓回来的 HTML 是 bytes 类型,直接用text属性获取内容时,requests 会根据 HTTP 头猜测编码,但很多站点在 HTML 的<meta>标签里声明了编码,或者根本没有声明。按错编码解析后,字符全变成乱码,关键词匹配全部失效。
解决:抓取时把原始 bytes 保存下来,解析前用chardet或BeautifulSoup自带的编码检测机制处理。
import requests from bs4 import BeautifulSoup resp = requests.get(url, timeout=10) # 先拿原始字节,再让 BeautifulSoup 自己检测编码 html = resp.content soup = BeautifulSoup(html, "html.parser") # 或者显式指定编码 # soup = BeautifulSoup(html, "html.parser", from_encoding="gb18030")逻辑说明:resp.content是未经解码的原始字节,BeautifulSoup 会参考 HTTP 头、meta 标签内容以及字节内容自动推断编码。从resp.text拿到乱码后,再用html.parser重新解析是无效的,因为乱码已经发生,无法逆向恢复。
5.3 URL 规范化不统一导致特征失真
现象:同一个站点的不同 URL 被算成不同的特征分布,比如example.com和example.com/路径深度一个为 0 一个为 1,但它们实际上指向同一个页面。
原因:没有做 URL 规范化。有些 URL 末尾有斜杠,有些没有;有些带utm_source=xxx追踪参数,有些不带。这些差异会干扰模型学习。
解决:特征提取前用urlparse做一次统一化处理,去掉末尾斜杠、去掉常见追踪参数。
from urllib.parse import urlparse, parse_qsl, urlencode def normalize_url(url): parsed = urlparse(url) path = parsed.path.rstrip("/") # 去掉末尾斜杠 # 过滤掉 utm 开头的追踪参数 params = [(k, v) for k, v in parse_qsl(parsed.query) if not k.startswith("utm_")] normalized_query = urlencode(params) return parsed._replace(path=path, query=normalized_query).geturl()逻辑说明:rstrip("/")把路径末尾的斜杠全部去掉,parse_qsl把查询参数解析成列表,再过滤掉utm_开头的追踪参数,最后用urlencode重新拼回去。这样入口不同但指向同一页面的 URL 会归一到同一条记录上。
5.4 爬虫被反爬机制拦截导致数据集里全是 403 页面
现象:正常样本特征里title_length=0、form_count=0,数据分布和钓鱼样本完全重叠。
原因:目标站点对高频率请求做了拦截,返回的 403 页面内容很简单,特征提取出来全是空值。这些页面混在数据集里,实际上等于给模型喂了大量噪声。
解决:爬取时控制请求频率,每次请求随机延时 1 到 3 秒。如果目标站点返回 403,立即跳过并记录日志。之后检查日志里失败率,超过 20% 就降低并发或者换 IP 池。
5.5 特征重复提取导致训练推理不一致
现象:模型在验证集上表现很好,上线跑新的 URL 时预测结果完全乱套。
原因:训练时的特征提取代码和线上推理的特征提取代码不是同一份,比如线上版本漏掉了某个字段,或者字段顺序排列不同,模型输入的特征空间和训练时不一致。
解决:训练前把特征提取代码封装成独立模块,训练和推理都从同一个模块导入函数,不要复制粘贴代码。必要的话,保存特征列名列表,推理时对比列名是否完全一致。
import joblib # 保存列名和模型 joblib.dump({"model": model, "feature_columns": list(X_train.columns)}, "detector.pkl") # 推理时加载 saved = joblib.load("detector.pkl") loaded_model = saved["model"] expected_columns = saved["feature_columns"] # 检查当前输入是否包含全部特征列 missing = [col for col in expected_columns if col not in current_df.columns] if missing: raise ValueError(f"缺少特征列: {missing}")逻辑说明:joblib.dump把模型和特征列名打包存成文件,推理时先加载列名做校验。这个习惯是从一次线上事故养成的——那时我手改了一个特征名称,训练和推理脚本各改一半,结果模型上线后对所有请求都返回同一个预测值,排查了半天才发现是特征空间错位。
6. 实时检测与模型更新:把训练好的模型封装成可调用的检测服务
训练阶段搞定后,下一个要解决的问题是:别人怎么用这个检测系统。直接丢一个训练脚本让人家跑是不现实的,正确做法是把模型导出,再封装成一个闹钟服务——给一个 URL,返回钓鱼/正常的判定和置信度。
import joblib from flask import Flask, request, jsonify app = Flask(__name__) # 启动时加载模型和特征列 saved = joblib.load("detector.pkl") model = saved["model"] expected_columns = saved["feature_columns"] def extract_features_for_prediction(url): html = fetch_html(url, timeout=8) parsed = urlparse(url) base_domain = parsed.netloc.replace(":", "") row = {} row.update(extract_domain_features(url)) row.update(extract_url_features(url)) row.update(extract_html_features(html, base_domain)) return pd.DataFrame([row]) @app.route("/detect", methods=["POST"]) def detect(): data = request.get_json() url = data.get("url", "") if not url: return jsonify({"error": "url 参数不能为空"}), 400 row = extract_features_for_prediction(url) # 对齐列名 row = row.reindex(columns=expected_columns, fill_value=0) prob = model.predict_proba(row)[0][1] label = 1 if prob >= 0.5 else 0 return jsonify({ "url": url, "is_phishing": label, "confidence": round(prob, 4) }) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)逻辑说明:这个接口收到 POST 请求后,从 JSON 里取出 URL,重新走一遍特征提取流程,再用reindex(columns=expected_columns)确保输入特征列的顺序和训练时完全一致。predict_proba返回的二维数组里,第二列是“钓鱼”类别的概率,阈值默认取 0.5。
运行起来后,可以用一行 curl 请求直接验证:
curl -X POST http://127.0.0.1:5000/detect \ -H "Content-Type: application/json" \ -d '{"url": "http://example.com/login"}'预期返回结果类似:
{ "url": "http://example.com/login", "is_phishing": 0, "confidence": 0.1234 }这里confidence越小,说明模型越确定这是正常网站,而不是模型在瞎猜。
模型更新方面,我的习惯是设置两个触发条件:每周定时重新训练一次,或者当检测到的钓鱼 URL 在当天出现超过某个阈值时额外触发一次。重新训练前把当天新确认的钓鱼样本和正常样本追加进训练集,然后重新跑完整的特征工程和训练脚本。由于这个过程是离线完成的,不会影响线上服务,所以可以直接把新模型文件替换旧的,再重启 Flask 服务即可。
从那以后我每次跑完训练都会顺手把特征列名、训练日期、数据源版本一起存档,这样模型上线后用了一个月,回头还能查它是在哪批数据上训练的。这个习惯帮我省掉了好几次“模型怎么突然不准了”的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取