尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

基于DOM结构特征的AI生成网页检测实践

发布时间:2026/9/26 6:33:40

资讯中心
01
ARTICLE

基于DOM结构特征的AI生成网页检测实践

基于DOM结构特征的AI生成网页检测实践
先说一个我自己的判断2024年到2025年AI生成的网页内容已经不只是文字看起来怪的层面了它在HTML结构上留下来一套非常稳定的指纹。之前我参与过一个项目目标非常直接训练一个模型仅凭网页的DOM结构特征判断一段页面内容是不是AI生成的。这活儿很多人第一反应是那岂不是要做语义理解得用LLM吧但实际做下来结构信号比语义信号省力得多而且效果超出预期。这篇文章就是把整个思路、数据准备、特征工程、模型训练、线上部署和踩坑过程完整复盘一遍给想在这个方向动手的同行一个可复现的路线。先说清楚这个项目是干什么的它不是一个检测文本抄袭的系统也不是去读正文语义而是把网页的HTML骨架当成笔迹通过训练一个轻量级分类模型判断页面是AI生成的还是人工创作的。它能解决的内容包括垃圾站批量生产、AI改写工具站、SEO作弊页面、以及各种看起来像内容平台其实全是模板的站点识别。适合谁看想给内容平台加一道AI内容过滤器的同学、做风控和反作弊的工程师、以及想了解结构特征表格模型这种低成本方案怎么落地的朋友。1. 为什么选结构这条少有人走的路1.1 语义识别看着高大上但坑都在后面最早我和很多人想的一样检测AI生成内容肯定要用语义模型。试了几条路线先用BERT做句向量去计算文本困惑度再试直接用GPT系列来判断文本像不像AI写的。结果发现几条硬伤一是成本高一个文本分类请求要经过一次完整的大模型推理线上流量一大就扛不住二是语义检测很容易被改写绕过把很AI的句子换几个同义词、打乱几个句法分数立刻波动三是可解释性太差运营同学问为什么这篇被标成AI生成我只能回一句模型觉得它不自然这种反馈没法要。但后来我注意到一个现象我手动翻了几百个被识别为AI生成的垃圾页面它们的HTML结构有一种惊人的相似感。大量页面都用同一种模板一个header一个hero区域两个三个div塞满正文下面一个footer。class命名要么是脱胎于某个UI库container、wrapper、text-section之类要么干脆是随机哈希。你再看正常用户手工写的页面结构往往是个性化的有人用table布局有人一个div从头写到尾有人嵌套七层但每层都有语义有人JS渲染出几十个button。这种差异太显眼了。1.2 结构信号的几个硬优点结构信号不是玄学它有四个特别实际的优点稳定AI生成工具再花哨最终输出一定是套模板的模板就会留下可统计的模式。文本可以改写但DOM层级和class命名习惯很难通过简单改写抹掉。便宜特征计算只需要一个HTML解析器不需要GPU不需要大模型推理。一次解析加特征提取大概一两毫秒。可解释模型告诉你这个页面连续出现了12个空链接且所有文本段长度高度一致这就是明确证据运营可以直接拿去核验。难伪装想让结构特征失效必须专门做一个反检测模板而这种模板本身又会形成新的结构指纹。当然也有局限结构特征不擅长判断一段人工文本被AI润色这种精修内容它更适合抓整体就是AI模板生产的页面。我们项目一开始目标就是抓后者所以路就走对了。1.3 这个项目到底要解决什么问题把目标定义清楚特别重要。我们最终确定的任务是给一个网页HTML文档二分类——AI生成内容页或人工创作内容页。判断的不是某一句而是整个页面的生产方式。这就意味着我们要找的特征是页面级的比如DOM树形状、标签分布、class命名规律、链接密度、文本块长度分布等等。这样定义以后训练数据也好弄很多。不需要逐句标注只需要给整个URL打标签。实际运营反馈说这个域名下的页面全是AI堆的那我们就把这个域名的页面都标成正样本。这种弱标签虽然有噪声但胜在量级够大用对训练方法以后效果依然很能打。2. 数据准备拿到可训练语料的三个坑2.1 数据从哪来自建抓取加公开收集我们训练数据主要靠两条腿走路一条是自建爬虫去抓那些已知的AI垃圾站另一条是采集正常的平台内容页公众号文章、博客、静态门户作为负样本。抓取过程没太多技巧但有三个坑值得先说注意robots与版权我们只做结构特征不保留正文内容只存HTML骨架和标签名列表实际上减小了合规压力但抓取时仍然只抓自己有权使用的站点以及公开测试集。同一个CMS套板的页面一个用WordPress建的人工博客可能1000篇文章都是同一个主题模板它们的结构高度相似。如果负样本里集中了某个CMS的页面模型会错误学到WordPress结构人工。必须对同域名做聚合防止数据泄漏。动态渲染页面很多现代页面是JS渲染的爬虫抓到的可能是个空壳里面只有一个root节点。这时你不能直接拿这个空壳当AI页面特征因为人工页面也可能是SPA。我们做了两层抓取先静态抓一次再用无头浏览器渲染一次两个版本都保留作为特征输入。2.2 标签怎么打宁可噪声大也要覆盖全我们最开始的标签是从域名维度打团队里两个同学分别看50个页面如果超过80%页面都明显是AI模板就给域名标为AI超过90%像个正常站长手工维护的就标为人工。混合的域名直接丢弃不训练。这种方式噪声肯定不小后期模型训练时也发现了一批误标页面。解法是做一个粗筛训练第一个版本后把预测概率接近50%的样本捞出来人工复审修正标签后重新训练。这个主动学习的闭环非常重要后面遇到准确率对但线上误杀的问题时几乎全靠它来救场。2.3 清洗HTML只留骨架不留正文特征计算前需要清洗这一步决定了特征质量。我们的清洗规则大体如下去掉script、style、noscript、svg、canvas等非结构性标签。把所有属性名、class名、id名单独抽出来文本内容只保留长度分布不保留具体文字。将标签名统一小写删除自定义的Web Components前缀只保留是否含连字符等标记。把注释、CDATA移除。清洗后得到的结果就是一颗干净的标签树。原本一坨几KB的HTML变成一个层次分明的骨架。这个骨架不只是给人看的还要转换成结构特征向量。3. 特征工程把HTML结构变成一维特征向量3.1 先说几个高识别力的静态特征所谓静态特征就是不需要看树结构、直接统计出来的数字。我列几个实测下来特别有区分度的标签种类的奇异性正常人工页面标签种类差异很大AI模板页常见的问题是某种标签极端集中比如整个页面只有div和span连section、article、header都没有。所以我们要记录页面上出现过的标签种类数如果小于某个临界值模板概率就很高。div/span占比AI模板页的divspan能占到全部标签的90%以上。人工页面虽然也用div但通常会有一定比例的p、a、ul、li、img等。class与id的数量及重复率模板页面里class重复使用率极高比如container出现了30次id却几乎没有。人工页面通常id更多、class命名杂乱且只出现一两次。空链接比例AI生成的页面特别喜欢塞上一堆href#或javascript:void(0)的假链接为了做SEO布局但根本没有实际落地页。这个比例在垃圾站里能到40%以上。文本长度分布我们不对每个文本块内容做理解但对页面里所有文本块长度的均值和标准差进行统计。AI模板生成的文本块长度高度一致因为模型输出被固定格式约束而人工写作段落长短很不均匀。3.2 再上一点结构化的序列特征光有统计特征还不够因为两个页面可能有完全一样的标签统计量但树的结构差异巨大。所以我们还设计了几个树形结构特征最大嵌套深度计算DOM树从根到叶子的最大深度。AI模板页因为模板固定深度通常在8到14层之间很稳定人工手写页面有时深度只有4层有时单页面因为各种内嵌布局能到20层。平均深度叶子节点深度的平均值能反映出页面是不是扁而广的框架型布局。每层子节点数的方差AI模板页层级非常均匀方差小人工页面很多地方一个节点下只有一个子节点方差大。重复子树哈希比例把每个节点的子树降级到两层或三层做哈希统计相同的哈希占了多少比例。AI模板页会有大量重复的卡片结构重复比例极高。这个特征是我们项目里单特征AUC最高的一项。节点路径集合把从根到每个叶子节点的路径如.htmlbodydivdivp做成集合计算集合大小和出现次数。路径集合小且重复次数高基本就是模板无疑。3.3 特征计算的工程实现为了让大家可以动手我给一个简化的特征提取函数模板用BeautifulSoup加lxml解析。实际生产里我们用了更快的lxml迭代器但逻辑一样from bs4 import BeautifulSoup from collections import Counter, defaultdict def extract_structure_features(html_text): soup BeautifulSoup(html_text, lxml) tags [node.name for node in soup.find_all()] path_counter Counter() def walk(node, path): for child in node.find_all(recursiveFalse): new_path path / child.name path_counter[new_path] 1 walk(child, new_path) walk(soup) tag_counter Counter(tags) total max(len(tags), 1) div_span tag_counter.get(div, 0) tag_counter.get(span, 0) a_count tag_counter.get(a, 0) empty_links len(soup.select(a[href#], a[hrefjavascript:void(0)])) features { total_tags: total, unique_tags: len(tag_counter), div_span_ratio: div_span / total, link_ratio: a_count / total, empty_link_ratio: empty_links / max(a_count, 1), max_depth: maximum_depth, avg_depth: average_depth, mean_path_repeat: path_counter.most_common(1)[0][1] / len(path_counter), } return features注意这个代码只是演示核心部分真实生产里还需要把maximum_depth和average_depth从树遍历里算出来。特征工程这一步我建议多多益善先堆到50个以上特征再用模型筛选。3.4 特征之间的一眼假模式我做个对比让大家直观感受特征差异特征典型AI模板页正常人工内容页total_tags800~150080~500unique_tags5~812~30div_span_ratio0.85以上0.4~0.6empty_link_ratio0.3~0.60~0.1subtree_hash_repeat_ratio0.5以上0.1以下text_block_len_stddivider极低较高4. 模型选型与训练LightGBM是最实在的起步4.1 为什么不是CNN或Transformer直接干HTML肯定有人会说你都把结构特征做了那直接把HTML当序列丢给Transformer不就行了理论上当然行但实际工程里我强烈建议先用表格模型。原因有三我们的样本量最多几万页这点量对于训练一个HTML tokenizer bert-like模型来说远远不够很容易过拟合到语法规则。结构特征经过人工设计后信息密度非常高一个50维特征向量已经能表达这个页面长什么样没必要让模型再从原始token里重新学习嵌套关系。表格模型推理速度快一张特征矩阵进LightGBM只需要几毫秒而且可以输出特征重要性方便运营解释。我后来见到不少团队一上来就搞HTML2Vec、搞预训练结果数据不够、线上延迟高、效果还不如一个XGBoost。这个领域里的技术选型糙快猛比高大上重要得多。4.2 分组交叉验证防止同源污染训练模型之前最容易被坑的是数据划分。如果按页面随机划分同一个域名的几十个相似页面会被同时分到训练集和验证集验证指标会虚高不少。我们采用的是按域名分组一个域名的所有页面只能出现在一个折里CV时按域名分成5组。这样得到的AUC才是真实泛化水平。实际操作里我们还对正负样本做了一件事负样本数量远超正样本一开始我不做采样直接训练结果模型精度看着很高但PR曲线很难看。后来改了损失函数权重同时用负样本的欠采样把比例压到3:1效果好了很多。4.3 一次完整的LightGBM训练代码下面是我们项目早期跑通的训练脚本不要迷信参数重点是整体流程import lightgbm as lgb from sklearn.model_selection import GroupKFold from sklearn.metrics import roc_auc_score def train_lgb(df, feature_cols, label_colis_ai, group_coldomain): gkf GroupKFold(n_splits5) oof_pred np.zeros(len(df)) for fold, (train_idx, valid_idx) in enumerate(gkf.split(df, df[label_col], groupsdf[group_col])): X_tr, X_va df.iloc[train_idx][feature_cols], df.iloc[valid_idx][feature_cols] y_tr, y_va df.iloc[train_idx][label_col], df.iloc[valid_idx][label_col] model lgb.LGBMClassifier( objectivebinary, boosting_typegbdt, n_estimators800, learning_rate0.05, num_leaves31, min_child_samples40, subsample0.8, colsample_bytree0.8, reg_alpha0.1, reg_lambda0.1, n_jobs-1, random_state42, ) model.fit(X_tr, y_tr, eval_set[(X_va, y_va)], callbacks[lgb.early_stopping(50, verboseFalse)]) oof_pred[valid_idx] model.predict_proba(X_va)[:, 1] print(ffold {fold}: auc{roc_auc_score(y_va, oof_pred[valid_idx])}) overall_auc roc_auc_score(df[label_col], oof_pred) print(OOF AUC:, overall_auc) return model跑完这个脚本我们第一个版本OOF AUC到了0.94当时还挺兴奋。但真正上线后才发现离线AUC高和线上好用完全是两码事。4.4 特征重要性与样本迭代训练完成后不要急着完事儿先用model.feature_importances_看一下哪些特征在起作用。我们项目里排前三的永远是重复子树哈希比例、div/span占比、文本块长度标准差。这说明判断AI生成页最核心的信号就是模板感。如果某个特征重要度几乎为零不代表它没用可能是跟其他特征高度相关。我们用SHAP拉了两张图之后发现总标签数这个特征虽然重要度不高但在特定分布区间内有强指示性。所以特征筛选时要结合SHAP和不做单特征删除那种粗暴操作最好每次迭代只去掉一个特征看AUC下降多少。5. 实操过程从模型训练到线上识别服务5.1 完整管线爬虫、解析器、模型、API这个项目最终落地成了一套实时检测接口。流程很简单接收一个URL或HTML文本。用解析器实际生产用了lxml.etree.HTMLParser生成DOM树。执行特征提取函数得到一维特征字典。加载LightGBM模型文件预测出概率。对于超过阈值的返回疑似AI生成同时把命中的关键特征打包给调用方。我们在API前面加了一层缓存相同URL一天内只算一次特征模型预测完结果直接进Redis。这样应对运营的批量扫描QPS能扛住。5.2 线上阈值怎么定先保零误杀再谈召回模型输出概率后阈值不是拍脑袋定0.5就完事儿。我们做过一个统计人工页面也有极小概率得到高分数AI页面也有少量概率得到低分数。面向最终用户的时候误杀比漏判更难受。所以线上默认阈值我们设成0.85只有分数高于0.85才标AI。这个阈值一上去精确率从80%提到了95%以上召回率掉了点但产品能接受了。如果你做的是后台风控还想抓漏网之鱼可以采用双阈值超过0.9直接标AI0.6到0.9进人工复审队列。这让运营爽了不少。5.3 模型更新与灰度发布结构信号会随着AI工具升级而漂移所以模型需要周期性重建。我们每两周做一次重新抓一批新出现的AI模板站和人工页面。合并上一轮人工复审的结果刷新标签。重新分组交叉验证。上新模型后先在5%流量灰度对比误杀率指标没问题再全量。这个循环看起来简单但很多团队死在不做数据Versioning前一天刷了标签后一天忘了留底模型回滚时连旧特征集都找不回来。我们在每次打分之前都会把特征向量存到数据仓库这样线上任何误判都能回放诊断这个习惯强烈建议养成。6. 常见问题与排查技巧实录6.1 离线AUC高线上误杀一堆这是我踩过最大的坑。我们第一版模型离线AUC 0.96上线的第二天运营就发来截图一个非常正常的人工手写博客被标成了AI生成而且分数高达0.93。排查半天问题出在数据分布偏移上训练集里的人工页面大多是静态页线上却遇到了大量无头浏览器渲染后的页面主体结构是JS注入的标签统计和静态页面差很多模型一看div/span占比高且路径重复多就误杀。解法是抓取渲染后页面重新加入训练集同时增加一条特征——是否包含大段静态可见文本却由脚本执行渲染也就是把渲染后DOM与静态DOM的特征差异调出来给模型。之后误杀率明显回落。6.2 标签噪声导致模型左右摇摆做弱标签数据时一批之前标成AI的域名后来发现其实是人工批量生产的SEO站它们结构也很模板化但确实是真人攒的。这导致模型对模板化和AI的区分能力被稀释。后来我们人工复审了两千个边缘样本把半数标签改掉重新训练后AUC涨了0.015左右。所以不要怕改标签怕的是不改标签硬跑。训练迭代里务必留出预过滤规则如果一个样本的特征值落在所有样本特征分布的极端位置先放着别急着上模型。6.3 线上推理报错与模型服务本身项目做到后半段我们需要给运营做一个动态更新模板的在线学习接口当时图省事打算调外部大模型API来做语义兜底。结果踩了几天坑一会儿报selected model is at capacity一会儿报the models maximum context length is 1048576 tokens要么就是config.toml里provider配置不对。那段时间让我深刻体会到线上应用真不该把核心链路压在一个不可控的外部接口上。最后我们的方案是核心用自训练的LightGBM外部大模型只做离线抽样审核不做实时识别这样既保住稳定性又保留语义参考。6.4 特征失效的快捷自查方法我们整理了一个三分钟自查流程专治最近识别能力好像变差了从近期数据中随机抽500个AI页和500个人工页。重新算一遍所有特征画出特征分箱分布图。看那两个类别区分度最高的特征通常是重复子树哈希比例、空链接比例如果二者分布几乎重叠了说明模板迭代了模型要重新训练。有一次我们测试发现重复子树哈希比例的区分度从0.72骤降到0.31原因是AI工具更新到了新版本模板结构变得更多样了。我们发现后立刻抓了新样本加了一条两次折叠节点相似度特征才算稳住。6.5 关于阈值和运营配合的“沟通智慧”最后一条经验可能不算纯技术但也重要做这种识别工具一定要把模型的输出包装成可疑等级或特征命中列表而不是只给一个概率。运营拿到0.87不知道咋处理但拿到该页面出现87次空链接且32个节点结构完全重复就一目了然。特征可解释性反而是这个项目能长期跑下去的最大保障。最后的实际操作心得如果让我重新做一次这个项目我的路线不会变先用结构特征和表格模型打底再迭代数据质量。结构识别能抓到的是AI生成过程中那个模板感的尾巴而模板感恰恰是AI生成工具最难隐藏的部分。文本可以改写语义可以让大模型自己跟自己辩论但一个AI排版引擎生产出的DOM就像它的笔迹想换一套笔迹成本远远高于直接换文案。我也建议想上手的朋友不要一开始就做所谓通用AI检测器那种目标太大会让自己迷失。先像我一样聚焦到某个具体形态比如AI生成的SEO落地页用结构特征和表格模型快速跑通建立数据闭环再去考虑融合语义信号、升级成多模态模型。这个项目后面还可以扩展的方向我目前正在尝试的是结合视觉特征把页面截图像对待图像指纹一样去比对。但那个是另外一个大坑回头再聊。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。