降 AI 率是个技术活,这篇文章记录我如何从零搭建降 AI率自查工作台,所有关键代码都在文中。

1. 起因:每天只有 5 次机会的盲改
在利用AI辅助写作通常都会遇到AI率的检测,要过 AIGC 检测。手头最靠谱的免费检测器是腾讯朱雀,但它每天只有 5 次额度。
于是我的迭代节奏变成了这样:改一版稿 → 送检 → 等结果 → 看哪几段标红 → 再改 → 再送检。第 14 版人工特征率 61.54%,第 15 版 49.63%,第 16 版 37.93%,第 17 版又弹回 46.94%。
分数在反复横跳,而我能做的只是盯着一整页红绿片段猜"它到底讨厌什么"。这不是改稿,这是抽卡。
转折点是我把多版检测报告铺在一起对比,发现被标 0.99 高 AI 值的片段,高度集中在几种固定的"腔调"上:
- 破折号引出补充、冒号引出清单
- “首先/其次/最后”、三点式框架
- “最被低估的是……”"本质上就是……"这类金句
- "那一刻我意识到……"的顿悟式故事弧
- 习题参考答案的"本题考查……"标准答案腔
- 精确到小数点的指标故事(AUC 从 0.78 涨到 0.84)
这些是显性特征,是模型写作留下的指纹,规则可以捕捉。剩下的隐性判断(模型对语义的整体感觉)规则抓不住,只能靠"报告回写校准"慢慢逼近。
想明白这件事,工具的形态就定了:一个离线网页,左边是文本编辑,右边是规则引擎标红,再把送检报告喂回来进化规则。
2. 技术选型:为什么是一个单文件 HTML
我没有上 React/Vue,也没有起后端。理由很朴素:
- 这是个人工具,一个文件拷到哪都能用,双击就开;
- 数据都在
localStorage,不涉及隐私敏感的书稿上传; - 规则引擎本质是正则 + 统计,几百行 JS 就够;
- 唯一需要联网的场景(加载 pdf.js、tesseract.js)都可以做成懒加载。
最后产出一个 1500 行左右的 降AI自查工作台.html,包含六个模块:闯关引导、自查检测、规则库、历史与校准、驾驶舱、报告分析。下面按搭建顺序讲核心代码。
3. 第一步:规则引擎
3.1 规则的数据结构
每条规则就是一个对象:正则 + 严重度 + 命中示例 + 修改建议。规则库从实战里长出来,一开始只有 8 条,最后稳定在 23 条:
const RULES = [
{id:'dash', name:'破折号过密', cat:'标点句式', sev:3,
re:/——/g,
desc:'连续用「——」引出补充说明',
advice:'改逗号或句号;多数情况直接重写后半句'},
{id:'enum', name:'枚举连接词', cat:'结构路标', sev:4,
re:/(首先|其次|再次|最后[,。]|第[一二三四五]点|其一|其二)/g,
desc:'首先/其次/最后、第一点/第二点',
advice:'打散顺序感:用「第一个坑…另一个…」或干脆各自成段'},
{id:'epiphany', name:'顿悟句式', cat:'AI句式', sev:5,
re:/(让我[明白意识到记住]|那一刻我|第一次[深深]?[体会意识]到|从那以后[,,]?我|这才[明白意识到])/g,
desc:'「那一刻我意识到…」「从那以后我…」',
advice:'检测器对顿悟故事弧极敏感,删掉顿悟框架,平铺直叙即可'},
{id:'answer', name:'标准答案腔', cat:'内容模板', sev:5,
re:/(第[一二三四五六七八九十0-9]+题|本题[考查考察]|答案[是:解析]|解题思路|易错[点题])/g,
desc:'「本题考查…」「答案解析…」',
advice:'改「写在最后/个人手记」,内容像跟朋友复盘'},
// ……共 23 条,另含 3 条统计特征规则(见 3.4)
];
sev(severity)取 1~5,是这条规则的"毒性"。顿悟句式和标准答案腔给到了 5,因为实测它们出现在 0.99 高 AI 片段里的频率最高。
3.2 段落切分与标题识别
检测是段落级的,朱雀报告也是按片段给的值,所以段落是整个系统的基本单位:
function splitParas(text){
return text.replace(/\r/g,'').split(/\n\s*\n/)
.map(p=>p.replace(/\n/g,'').trim())
.filter(p=>p.length>0);
}
function isHeading(p){
return p.length<=30 && !/[。!?;]/.test(p) && /^[\d\.\s]*[^\d]/.test(p);
}
按空行分段,段内换行拍平;标题(短、无标点、常见编号开头)单独打标,不计入正文统计。
3.3 段落打分
每个段落跑一遍全部规则,命中就累计扣分,最后给出红/黄/绿三档:
function runCheck(){
const text = document.getElementById('input').value.trim();
const paras = splitParas(text);
const results = paras.map((p, idx)=>{
const hits = [];
RULES.forEach(r=>{
if(rulesOff.has(r.id)) return; // 用户手动关闭的规则跳过
const m = p.match(r.re);
if(m && m.length){
hits.push({rule:r, words:[...new Set(m)], cnt:m.length});
}
});
let score = 0;
hits.forEach(h=>{ score += getSev(h.rule) * 3 * Math.min(h.cnt, 3) });
score = Math.min(100, score);
const level = score>=45?'high':(score>=20?'mid':'low');
return {idx:idx+1, text:p, chars:p.length, hits, score, level};
});
}
两个细节:
Math.min(h.cnt, 3):同一条规则在一小段里命中 10 次和 3 次,危险程度差不多,做封顶防止一段把分数打满;getSev(r)不是直接读r.sev,而是先查ruleAdj(报告对比后人工调整过的严重度)。这为后面"规则进化"留了口子。
3.4 统计特征:三条不用正则的规则
光有正则不够,有三条特征是全文统计出来的,也是踩过坑才加上的:
// 单段超600字 → 极易整段被判 AIGC(v13 实测教训)
if(!isHeading(p) && p.length > 600) hits.push({rule:rToolong});
// 碎句段占比 > 45% → 拆碎句救不了AI率(v14→v15 教训)
if(shortRatio > 0.45 && p.length < 40) hits.push({rule:rTooshort});
// 段落长度变异系数 < 0.45 → 节奏过于均匀,机器感
const lens = bodyParas.map(p=>p.length);
const mean = lens.reduce((a,b)=>a+b,0)/lens.length;
const sd = Math.sqrt(lens.reduce((a,b)=>a+(b-mean)*(b-mean),0)/lens.length);
const cv = mean? sd/mean : 0; // 变异系数
第三条是真正"反直觉"的经验:第 14 版我信了"拆短句降AI",把所有长段拆碎,结果人工特征率从 67% 跌到 35%。段落长短本身不是关键,节奏均匀才是问题。 长短交替、允许单句成段和 400 字长段共存,反而更安全。
3.5 总分公式
段落分聚合为全文风险分,我用的加权不是拍脑袋,是拿多版历史数据调出来的:
const highN = results.filter(r=>r.level==='high'&&!r.isHead).length;
const midN = results.filter(r=>r.level==='mid' &&!r.isHead).length;
const bodyN = results.filter(r=>!r.isHead).length || 1;
const avg = results.reduce((a,r)=>a+r.score,0)/results.length;
let total = Math.round(Math.min(100,
avg*0.6 + (highN/bodyN)*40 + (midN/bodyN)*18));
const lv = total>=40?'high':(total>=20?'mid':'low');
平均分占 60%,红段占比权重最高。经验目标线:风险分 < 20 且红段清零,才值得消耗一次朱雀额度。
4. 第二步:版本存档与历史曲线
改稿是多次迭代的,必须能回答"这版比上版好在哪"。每次 runCheck 自动存档:
function paraHash(t){
let h=0; for(let i=0;i<t.length;i++){ h=(h*31+t.charCodeAt(i))|0 }
return 'p'+Math.abs(h); // 段落指纹:报告回写的锚点
}
function saveVersion(text, total, lv, paraN, highN, chars){
const changed = !versions.length
|| versions[versions.length-1].text !== text;
if(!changed) return; // 没改就不存
versions.push({text, total, lv, paraN, highN, chars, histT:Date.now()});
if(versions.length>30) versions = versions.slice(-30); // 滚动30版
save('zhq_versions', versions);
}
paraHash 是整个闭环的关键设计:段落内容算一个 31 进制的滚动 hash,朱雀报告里"该段人工特征 46%"这个分数就挂在 hash 上。下次同一文本再自查,段落卡片上直接显示"朱雀人工 46%",等于官方给这段盖了章。
历史页画双线趋势:本地风险分 vs 朱雀人工分。攒够几个点,你就有了自己的换算表——“我本地 12 分,朱雀大概人工 90%+”。
5. 第三步:闯关引导,防止瞎花钱
工具很快有了新问题:它只告诉我"哪段红",不告诉我"现在该干什么"。于是加了一个漏斗式的闯关引导,把检测策略固化成 7 关:
const STAGES = [
{id:0, title:'问清出版社要求', tag:'免费·最重要'},
{id:1, title:'本地高频自查', tag:'免费·不限次'}, // 本工具,省朱雀额度
{id:2, title:'免费平台交叉检测', tag:'免费·每天约12次'}, // PaperPass/PaperYY/朱雀
{id:3, title:'驾驶舱手动改稿', tag:'免费·人工精修'},
{id:4, title:'付费校准', tag:'约20~60元'}, // 万方/维普,<10%
{id:5, title:'出版社同款预检', tag:'约40~80元'}, // 知网/大雅,<10%
{id:6, title:'交稿终检', tag:'出版社执行'},
];
顺序的内核是省钱:本地不限次随便测 → 免费平台交叉(每天约 12 次额度)→ 付费校准只在 2~3 轮大改后做一次 → 出版社同款系统交稿前压轴。第一关是发一句话模板给编辑问清检测系统和红线——不同平台算法差异能到 20%+,问不清等于白改。
进度存 localStorage,回来接着闯。
6. 第四步:驾驶舱——从"标红"到"教我改"
规则引擎告诉你哪里有问题,但怎么改还得靠人。驾驶舱模块补上这一环:
- 范文收藏:检测出 AI 率 < 15% 的文章一键收藏进范文库,做风格画像(段长分布、第一人称频次、句长节奏),跟当前稿对比找差距;
- 逐段修改意见:每个红段拼装出具体的改法(规则 advice + 范文风格差距 + 朱雀段落分);
- 一键生成新文章:27 条确定性替换规则跑一遍——套话直接删、精确年份模糊化(“2019 年春天"→"前几年”)、顿悟句式降调、破折号改逗号、超长段拆分。
必须说明边界:自动替换只能消显性特征。 生成的新文章一定要人工通读润色,否则就是把一种 AI 腔换成另一种(v15 教训:自动改出来的"故事腔"被检测器识别得比原来还快)。
7. 第五步:报告分析——让规则库自己进化
这是整个工作台里我最得意的部分。前面所有规则都是我肉眼总结的,而报告分析模块做的是用真实送检数据验证和进化规则库,流程是:上传检测报告 → 解析片段和数值 → 对齐到自查版本 → 统计每条规则的判别力 → 调整权重、挖掘新规则。
7.1 解析报告文本
朱雀报告导出后是纯文本(或 PDF),格式大致是"片段N + 文本 + AIGC 0.xxxx"。解析核心两个函数:
function fragScoreOf(block){
// 优先 AIGC 值(0.9982 → 人工特征 0.2%)
const aigcM = block.match(/0\.\d{3,4}/);
if(aigcM) return +((1 - parseFloat(aigcM[0]))*100).toFixed(1);
// 其次「人工特征 46.94%」
const pm = block.match(/(人工特征|人工痕迹|人工率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
if(pm) return +pm[2];
// 最后「AI特征 10.78%」→ 取补
const am = block.match(/(AI特征|疑似AI|AIGC率?|AI率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
if(am) return +(100 - +am[2]).toFixed(1);
return null;
}
function cleanFragText(s){
// 洗掉「片段1」「AIGC值 0.9982」「8.66%」这类报告自身的标记
return s.replace(/片段\s*\d+/g,'')
.replace(/(AIGC|AI特征|人工特征)[^\n\d]{0,6}\d+(\.\d+)?%?/g,'')
.replace(/0\.\d{3,4}/g,'')
.replace(/\d+(\.\d+)?\s*%/g,'')
.replace(/\s*\n\s*/g,' ').trim();
}
7.2 片段对齐:6-gram 滑窗重叠
报告里的片段文本和我自查版本的段落不是逐字相同的(报告可能有截断、OCR 有错字)。对齐算法用归一化后的 6 字滑窗算重叠率:
function overlapRatio(fn, pn){
// fn: 报告片段归一化文本, pn: 版本段落归一化文本
if(fn.length<6) return 0;
let hits=0, tot=0;
for(let i=0;i+6<=fn.length;i+=3){ // 步长3,容忍部分错字
tot++;
if(pn.includes(fn.substr(i,6))) hits++;
}
return tot? hits/tot : 0; // 0~1,>=0.15 视为对齐成功
}
步长取 3、窗口取 6 是平衡出来的:窗口太短会大量误对齐,太长则 OCR 错一个字就断。对齐成功后,片段的人工特征分通过 paraHash 回写到段落——从此规则库里每条规则都有"朱雀均分"列:命中该规则的段落,真实送检的平均人工特征率是多少。
7.3 规则判别力:lift 统计
有了对齐 + 分数,就能回答那个核心问题:“这条规则到底准不准?”
const ruleStats = RULES.map(r=>{
// 命中该规则且对齐成功的片段
const hitList = withScore.filter(x=>x.res.hits.some(h=>h.rule.id===r.id));
if(!hitList.length) return {rule:r, nHit:0, lift:null};
// 命中段的平均AI值 - 全体对齐段的平均AI值 = 提升度
const avgHit = hitList.reduce((s,x)=>s+(100-x.frag.human),0)/hitList.length;
return {rule:r, nHit:hitList.length, avgHit, lift: avgHit - baseAvg};
});
lift > 0 说明这条规则命中的段确实更 AI,一键盘升权;lift 长期为负就是误报,一键盘降权或直接关闭。严重度被写成 ruleAdj,前面 getSev 优先读它——规则库的权重从此由真实数据说话。
7.4 挖掘新规则
最后一环:官方报告里被标红、但现有 23 条规则没有覆盖的片段,说明存在我没总结到的模式。挖掘算法在高 AI 片段里找"反复出现的 4~8 字 n-gram",同时要求它不出现在低 AI 片段里:
function mineCandidates(matched){
const highN = /* 高AI片段(人工<50%) 归一化文本数组 */;
const lowN = /* 低AI片段(人工>=70%) 归一化拼接 */ ;
const kept = [];
for(let L=8; L>=4 && kept.length<40; L--){ // 从长到短滑窗
for(const t of highN){
for(let i=0; i+L<=t.length && kept.length<40; i+=2){
const s = t.substr(i,L);
if(lowN.includes(s)) continue; // 低AI段也有→不是区分性特征
if(kept.some(k=>k.text.includes(s))) continue;
let c=0; highN.forEach(x=>{ if(x.includes(s)) c++ });
if(c<2) continue; // 至少2个高AI片段共享
if(RULES.some(r=>r.re && new RegExp(r.re.source).test(s))) continue;
kept.push({text:s, cnt:c}); // 现有规则已覆盖的不要
}
}
}
return kept.sort((a,b)=>b.cnt-a.cnt).slice(0,6); // Top6 候选
}
我真实跑过一次:从一批漏检红段里挖出了"整体迁移演练"这个模式——某类项目汇报的固定话术,23 条规则都没覆盖,挖掘器把它找出来存成了自定义规则。从那以后这个模式不再漏检。
8. 第六步:PDF 报告直接上传
朱雀导出的报告是 PDF,而且经常是扫描图版(无文字层)。上传逻辑分两条路:
function ensurePdfJs(cb){
if(window.pdfjsLib){ cb(); return }
const CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/';
const s = document.createElement('script');
s.src = CDN + 'pdf.min.js';
s.onload = ()=>{
window.pdfjsLib.GlobalWorkerOptions.workerSrc = CDN + 'pdf.worker.min.js';
cb();
};
document.head.appendChild(s); // 懒加载:只在用到时联网一次
}
文字版走 page.getTextContent() 直接取文本;取出来不足 30 字符就判定是扫描版,弹窗征求同意后走 OCR 分支:
async function ocrPdfPages(pdf){
const worker = await Tesseract.createWorker('chi_sim', 1, { /* logger 显示进度 */ });
const parts = [];
for(let i=1; i<=pdf.numPages; i++){
const page = await pdf.getPage(i);
const viewport = page.getViewport({scale:2}); // 2倍渲染保证识别率
const canvas = document.createElement('canvas');
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
const { data: { text } } = await worker.recognize(canvas);
if(text && text.trim()) parts.push(text.trim());
}
await worker.terminate();
return parts.join('\n\n');
}
tesseract.js 的中文模型约 15MB,同样懒加载。OCR 完的文本自动进解析管线——反正后面有 6-gram 容错对齐,识别错几个字不影响。
9. 踩坑总结
这套工具跑了几个月后,几条血泪经验:
1. 规则引擎是启发式,不是检测器。 它的价值是"把每天 5 次的官方额度省到刀刃上",终检永远以官方系统为准。本地 0 分不保证过朱雀,但本地满屏红就别浪费送检次数了。
2. 警惕对单一策略过拟合。 v14 拆短句 → 分数腰斩;v15 故事化 → 故事模板被识别得比原来还快。任何"一招鲜"都会被检测器学进模型。所以规则库里同时有"段落过长"和"碎句过密"两条互相制衡的规则,写作侧的正确姿势是自然长短交替。
3. 换算表比单点分数重要。 本地风险分和真实 AI 率之间不是线性关系,但"本地 15 分以下 → 朱雀人工 90%+"这种区间规律,攒够 5~6 个点就稳定了。这就是历史双线 + 校准点设计的目的。
4. 数据闭环 > 规则数量。 23 条规则里最有价值的不是最初 8 条手写的,而是 lift 统计校正过权重的、和从漏检红段里挖出来的那几条。让送检报告成为规则库的训练数据,工具才越用越准。
10. 复用与延伸
后来我把规则引擎从 HTML 里抽出来,做成了独立的 CLI 脚本,接进了"书籍自动成章"的技能流水线——每一节生成后自动跑校验,红段未清零就只重写红段,循环最多 5 轮:
node ai_check.js chapter.txt
# 退出码 0 = 风险分<20 且红段清零;1 = 未达标
# 支持 --json(机器可读)、--threshold 15、--extra rules.json(自定义规则)
同一套思路可以平移到任何"输出要过某种自动审核"的场景:论文降重、文案合规、敏感表述扫描——本质都是显性特征规则化 + 官方反馈数据回写这个闭环。
完整源码是一个 1500 行的单文件 HTML,不依赖任何构建工具,保存下来双击就能跑。如果这篇文章帮你省下几次送检额度,就值了。
完整代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>降AI率自查工作台 · 出版过审引导</title>
<style>
:root{
--bg:#f5f7fa; --card:#ffffff; --line:#e4e8ef; --txt:#24292f; --sub:#57606a;
--blue:#2f6fed; --blue-bg:#eaf1fe; --red:#e5484d; --red-bg:#fdebec;
--yellow:#d48806; --yellow-bg:#fff7e6; --green:#2da44e; --green-bg:#eaf6ee;
--radius:12px; --shadow:0 1px 3px rgba(16,24,40,.08),0 1px 2px rgba(16,24,40,.04);
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,"Segoe UI","Microsoft YaHei","PingFang SC",sans-serif;font-size:14px;line-height:1.7}
.wrap{max-width:1080px;margin:0 auto;padding:20px 16px 60px}
header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px}
header h1{font-size:20px;font-weight:700}
.badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;background:var(--blue-bg);color:var(--blue);font-weight:600}
.badge.gray{background:#eef1f5;color:var(--sub)}
nav{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
nav button{padding:8px 18px;border:1px solid var(--line);background:var(--card);border-radius:8px;cursor:pointer;font-size:14px;color:var(--sub);font-weight:600}
nav button.active{background:var(--blue);border-color:var(--blue);color:#fff}
section{display:none}
section.show{display:block}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:18px;margin-bottom:14px}
.card h2{font-size:16px;margin-bottom:10px}
.muted{color:var(--sub);font-size:13px}
.btn{display:inline-block;padding:8px 16px;border-radius:8px;border:1px solid var(--blue);background:var(--blue);color:#fff;cursor:pointer;font-size:14px;font-weight:600;text-decoration:none}
.btn.ghost{background:#fff;color:var(--blue)}
.btn.small{padding:4px 12px;font-size:13px}
.btn.warn{border-color:var(--red);background:var(--red)}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
/* ---------- 阶段引导 ---------- */
.stage{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);padding:16px 18px;margin-bottom:12px;position:relative}
.stage.done{opacity:.65}
.stage.current{border-color:var(--blue);box-shadow:0 0 0 3px var(--blue-bg)}
.stage-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.stage-num{width:28px;height:28px;border-radius:50%;background:#eef1f5;color:var(--sub);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex-shrink:0}
.stage.current .stage-num{background:var(--blue);color:#fff}
.stage.done .stage-num{background:var(--green);color:#fff}
.stage-title{font-weight:700;font-size:15px}
.stage-tag{font-size:12px;padding:1px 8px;border-radius:999px;background:var(--green-bg);color:var(--green);font-weight:600}
.stage-tag.free{background:var(--blue-bg);color:var(--blue)}
.stage-tag.pay{background:var(--yellow-bg);color:var(--yellow)}
.stage-body{margin-top:10px;padding-left:38px}
.goal{padding:6px 12px;background:var(--green-bg);border-radius:8px;font-size:13px;color:#116327;margin-bottom:10px}
.platforms{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.platforms a{padding:6px 14px;border:1px solid var(--line);border-radius:8px;text-decoration:none;color:var(--txt);font-size:13px;font-weight:600;background:#fff}
.platforms a:hover{border-color:var(--blue);color:var(--blue)}
.platforms a b{color:var(--blue)}
.stage-body ul{margin:6px 0 6px 18px;color:var(--sub)}
.stage-actions{margin-top:10px}
.quote{background:#f6f8fa;border-left:3px solid var(--blue);padding:10px 12px;border-radius:0 8px 8px 0;font-size:13px;color:var(--sub);margin:8px 0}
/* ---------- 自查 ---------- */
.checker-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:900px){.checker-grid{grid-template-columns:1fr}}
textarea{width:100%;height:320px;padding:12px;border:1px solid var(--line);border-radius:8px;font-family:inherit;font-size:14px;resize:vertical;line-height:1.8}
textarea:focus{outline:2px solid var(--blue-bg);border-color:var(--blue)}
.drop-hint{font-size:12px;color:var(--sub);margin-top:6px}
.score-wrap{display:flex;align-items:center;gap:18px;flex-wrap:wrap}
.score-num{font-size:40px;font-weight:800;line-height:1}
.lv-low{color:var(--green)} .lv-mid{color:var(--yellow)} .lv-high{color:var(--red)}
.stats{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;color:var(--sub)}
.stats b{color:var(--txt)}
.top-rules{margin-top:12px}
.top-rules .tr{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:13px}
.tr-name{width:130px;flex-shrink:0;font-weight:600}
.tr-bar{height:10px;border-radius:5px;background:var(--red);opacity:.85;min-width:4px}
.tr-cnt{color:var(--sub)}
.para-card{border:1px solid var(--line);border-left:4px solid var(--green);border-radius:8px;padding:10px 14px;margin-bottom:10px;background:#fff}
.para-card.mid{border-left-color:var(--yellow);background:#fffdf7}
.para-card.high{border-left-color:var(--red);background:#fff8f8}
.para-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;cursor:pointer}
.para-idx{font-weight:700;color:var(--sub);font-size:12px}
.para-chars{font-size:12px;color:var(--sub)}
.para-badge{font-size:11px;padding:1px 8px;border-radius:999px;font-weight:700}
.para-badge.high{background:var(--red-bg);color:var(--red)}
.para-badge.mid{background:var(--yellow-bg);color:var(--yellow)}
.para-badge.low{background:var(--green-bg);color:var(--green)}
.hit-tags{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
.hit-tag{font-size:11px;padding:1px 8px;background:#f0f2f5;border-radius:4px;color:var(--sub)}
.hit-tag.s5{background:var(--red-bg);color:var(--red)} .hit-tag.s4{background:#fdeee0;color:#c2570a} .hit-tag.s3{background:var(--yellow-bg);color:var(--yellow)}
.para-text{font-size:13px;color:#444;margin-top:8px;white-space:pre-wrap;display:none}
.para-card.open .para-text{display:block}
.hit-detail{display:none;margin-top:8px;font-size:12.5px;color:var(--sub);background:#f6f8fa;border-radius:8px;padding:10px}
.para-card.open .hit-detail{display:block}
.hit-line{margin-bottom:6px}
.hit-word{background:#fff;border:1px solid var(--line);border-radius:4px;padding:0 6px;margin-right:6px;font-weight:600;color:var(--txt)}
.advice{color:#8a6d1a}
.empty{padding:60px 0;text-align:center;color:var(--sub)}
/* ---------- 规则库 ---------- */
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#f6f8fa;text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-size:12px;color:var(--sub)}
td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
.sev{font-weight:700}
.sev5{color:var(--red)} .sev4{color:#c2570a} .sev3{color:var(--yellow)} .sev2{color:var(--sub)}
.switch{cursor:pointer;user-select:none}
.zq-avg{font-size:12px;color:var(--blue);font-weight:700}
/* ---------- 历史 ---------- */
.history-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line);flex-wrap:wrap;font-size:13px}
.trend{width:100%;height:160px}
.input-line{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.input-line input{padding:5px 10px;border:1px solid var(--line);border-radius:6px;width:110px;font-size:13px}
/* ---------- 驾驶舱 ---------- */
.dash-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:14px}
@media(max-width:900px){.dash-grid{grid-template-columns:repeat(2,1fr)}}
.kpi{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:12px 14px}
.kpi .k{font-size:12px;color:var(--sub)}
.kpi .v{font-size:22px;font-weight:800;margin-top:2px}
.kpi .v.blue{color:var(--blue)} .kpi .v.green{color:var(--green)} .kpi .v.red{color:var(--red)} .kpi .v.yellow{color:var(--yellow)}
.kpi .s{font-size:11px;color:var(--sub);margin-top:2px}
.fav-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--line);flex-wrap:wrap;font-size:13px}
.fav-rate{font-weight:800;color:var(--green)}
.rate-input{padding:6px 10px;border:1px solid var(--line);border-radius:6px;width:90px;font-size:13px}
.title-input{padding:6px 10px;border:1px solid var(--line);border-radius:6px;width:220px;font-size:13px}
.adv-card{border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:8px;font-size:13px;background:#fff}
.adv-head{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-weight:600}
.adv-body{margin-top:6px;color:var(--sub);font-size:12.5px}
.adv-body b{color:var(--txt)}
.para-ref{background:var(--green-bg);border-left:3px solid var(--green);padding:8px 12px;border-radius:0 8px 8px 0;font-size:12.5px;color:#116327;margin:6px 0}
.gen-area{width:100%;height:300px;padding:12px;border:1px solid var(--line);border-radius:8px;font-family:inherit;font-size:14px;resize:vertical;line-height:1.8;background:#f6fff8}
.gen-stat{font-size:13px;color:var(--sub)}
.gen-stat b{color:var(--green)}
/* ---------- 报告分析 ---------- */
.rep-frag{border:1px solid var(--line);border-radius:8px;padding:10px 12px;margin-bottom:8px;background:#fff}
.rep-frag textarea{height:64px;margin-top:6px;font-size:13px}
.version-row{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:6px;cursor:pointer;font-size:13px;flex-wrap:wrap;background:#fff}
.version-row:hover{border-color:var(--blue)}
.version-row.sel{border-color:var(--blue);background:var(--blue-bg)}
.version-row .sw{font-size:16px;color:var(--blue)}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>降AI率自查工作台</h1>
<span class="badge">规则启发式 · 离线运行</span>
<span class="badge gray">免费不限次</span>
<span class="badge gray">内置18轮实战规则库</span>
</header>
<nav>
<button id="nav-guide" class="active" onclick="switchTab('guide')">闯关引导</button>
<button id="nav-dash" onclick="switchTab('dash')">驾驶舱</button>
<button id="nav-check" onclick="switchTab('check')">自查检测</button>
<button id="nav-report" onclick="switchTab('report')">报告分析</button>
<button id="nav-rules" onclick="switchTab('rules')">规则库</button>
<button id="nav-history" onclick="switchTab('history')">历史与校准</button>
</nav>
<!-- ============ 闯关引导 ============ -->
<section id="sec-guide" class="show">
<div class="card">
<h2>出版过审路线图</h2>
<div class="muted">按顺序闯关。每一关达标后再进入下一关,避免把宝贵的付费检测次数浪费在未打磨的稿子上。红线值请以出版社答复为准填入第0关。</div>
</div>
<div id="stage-list"></div>
</section>
<!-- ============ 自查检测 ============ -->
<section id="sec-check">
<div class="checker-grid">
<div>
<div class="card">
<h2>待检文本</h2>
<textarea id="input" placeholder="把章节内容粘贴到这里(Word里复制即可),或直接拖入 .txt 文件 docx 文件请先用配套脚本「docx转txt.py」转换后再拖入"></textarea>
<div class="drop-hint" id="drop-hint">支持拖拽 .txt 文件 · 内容只在本机处理,不上传</div>
<div class="row" style="margin-top:10px">
<button class="btn" onclick="runCheck()">开始自查</button>
<button class="btn ghost" onclick="loadSample()">载入示例</button>
<button class="btn ghost" onclick="clearInput()">清空</button>
<button class="btn ghost" onclick="exportReport()">导出报告</button>
</div>
</div>
<div class="card">
<h2>免费平台交叉检测</h2>
<div class="muted">本地自查达标(红段清零、风险分<20)后,再去这些平台做交叉验证。朱雀每天只有5次,省着用。</div>
<div class="platforms" style="margin-top:8px">
<a href="https://matrix.tencent.com/zhuge/" target="_blank">朱雀AI检测<b>(5次/天)</b></a>
<a href="https://www.paperpass.com/" target="_blank">PaperPass<b>(5次/天)</b></a>
<a href="https://www.paperyy.com/" target="_blank">PaperYY<b>(2篇/天)</b></a>
</div>
</div>
</div>
<div>
<div class="card" id="result-card" style="display:none">
<h2>检测结果</h2>
<div class="score-wrap">
<div class="score-num" id="score-num">-</div>
<div>
<div style="font-weight:700" id="score-lv">未检测</div>
<div class="muted" id="score-note">启发式评分,用于定位问题段落,不等同于朱雀/知网分数</div>
</div>
</div>
<div class="stats" style="margin-top:12px" id="score-stats"></div>
<div class="top-rules" id="top-rules"></div>
</div>
<div class="card" id="para-card-wrap" style="display:none">
<h2>段落明细 <span class="muted" style="font-size:12px;font-weight:400">(点击段落展开原文与命中详情)</span></h2>
<div id="para-list"></div>
</div>
<div class="card" id="empty-state">
<div class="empty">粘贴或拖入文本后,点击「开始自查」<br><span class="muted">先在本地反复打磨,再去消耗朱雀的每日额度</span></div>
</div>
</div>
</div>
</section>
<!-- ============ 报告分析 ============ -->
<section id="sec-report">
<div class="card">
<h2>① 导入检测报告</h2>
<div class="muted">把朱雀 / PaperPass / 万方等平台的检测报告文本粘贴到下面,或直接上传 PDF。文字版 PDF 自动提取全文并解析;扫描/图片版 PDF 会自动调用浏览器 OCR(tesseract.js + 中文模型,约 15MB,首次需联网下载),逐页识别后解析。网页报告直接全选复制。可识别:总体「人工特征率 / AI特征率」,以及各片段文本 + AIGC值(如 0.9941)。解析后每个片段的数值和文本都可以手动修正。</div>
<textarea id="rep-input" style="height:180px;margin-top:8px" placeholder="粘贴检测报告文本… 示例格式: 人工特征率 46.94% 片段1 说实话,AI项目里最被低估的是沟通成本…… AIGC 0.9982 (无数值的片段解析后在下方填人工特征%)"></textarea>
<div class="row" style="margin-top:8px">
<button class="btn" onclick="parseReport()">解析报告</button>
<label class="btn ghost" style="cursor:pointer">上传 .pdf<input type="file" id="rep-pdf" accept="application/pdf,.pdf" style="display:none" onchange="loadRepPdf(this)"></label>
<label class="btn ghost" style="cursor:pointer">上传 .txt/.csv/.json<input type="file" id="rep-file" accept=".txt,.csv,.json,.md" style="display:none" onchange="loadRepFile(this)"></label>
<button class="btn ghost small" onclick="addFragRow()">+手动补片段</button>
<button class="btn ghost small" onclick="document.getElementById('rep-input').value='';document.getElementById('rep-parse').innerHTML='';repParsed=null">清空</button>
</div>
<div id="rep-progress" style="margin-top:8px;font-size:13px;color:var(--sub);min-height:22px"></div>
<div id="rep-parse" style="margin-top:10px"></div>
</div>
<div class="card">
<h2>② 勾选对应的自查版本</h2>
<div class="muted">每次在「自查检测」跑检测都会自动存一条版本记录(同稿去重)。勾选这份报告对应的是哪一版,再点「开始对比分析」。解析报告后会按文本重叠度自动预选最像的版本。</div>
<div id="rep-versions" style="margin-top:10px"></div>
<div class="row" style="margin-top:10px">
<button class="btn" onclick="runRepCompare()">开始对比分析</button>
</div>
</div>
<div class="card" id="rep-analysis-card" style="display:none">
<h2>③ 对比分析 → 更新规则库</h2>
<div class="muted">对齐报告片段与版本段落:报告判红的段落会回写段落级朱雀分;统计每条规则在红段/绿段中的判别力,给出升权、降权、关闭建议;未被规则覆盖的红段列出原文优先改;红段高频出现的固定模式可一键加入规则库。</div>
<div id="rep-analysis" style="margin-top:10px"></div>
</div>
</section>
<!-- ============ 驾驶舱 ============ -->
<section id="sec-dash">
<div class="dash-grid" id="dash-kpis"></div>
<div class="card">
<h2>① 合格文章收藏(AI率 < 15%)</h2>
<div class="muted">在外部平台(PaperPass / PaperYY / 朱雀)检测出 AI率 < 15% 的文章,一键收藏进范文库。前提:先把该文章正文粘贴到「自查检测」页的待检文本框,再回来填AI率点收藏。范文库用于风格对标和逐段改写参考。</div>
<div class="row" style="margin-top:10px">
<input id="fav-rate" class="rate-input" placeholder="AI率 %" inputmode="decimal">
<input id="fav-title" class="title-input" placeholder="范文标题(可留空自动命名)">
<button class="btn" onclick="collectFav()">一键收藏当前文本</button>
</div>
<div id="fav-list" style="margin-top:10px"></div>
</div>
<div class="card">
<h2>② 合格范文分析</h2>
<div class="muted">点范文列表里的「分析」,看 AI率<15% 的文章长什么样:段落节奏、句式特征、命中了哪些规则、最像人写的段落原文,并与当前稿逐项对比,差距最大的项就是优先要补的。</div>
<div id="fav-analysis" style="margin-top:10px"><div class="muted">从上面范文库选一篇进行分析</div></div>
</div>
<div class="card">
<h2>③ 当前稿逐段修改意见 → 一键生成新文章</h2>
<div class="muted">对「自查检测」页待检文本框里的当前稿逐段生成修改意见;然后一键自动改写(套话删除、顿悟句降调、年份模糊化、括号展开、长段拆分等确定性替换)。自动生成后请人工通读润色,再送回自查复检。</div>
<div class="row" style="margin-top:10px">
<button class="btn" onclick="genAdvice()">生成逐段修改意见</button>
<button class="btn ghost" onclick="autoRewrite()">一键自动生成新文章</button>
</div>
<div id="advice-list" style="margin-top:10px"></div>
<div id="gen-wrap" style="display:none;margin-top:12px">
<textarea id="gen-output" class="gen-area" readonly></textarea>
<div class="gen-stat" id="gen-stat" style="margin-top:6px"></div>
<div class="row" style="margin-top:8px">
<button class="btn small" onclick="copyGen()">复制全文</button>
<button class="btn ghost small" onclick="downloadGen()">下载 .txt</button>
<button class="btn ghost small" onclick="sendGenToCheck()">送回自查复检</button>
</div>
</div>
</div>
</section>
<!-- ============ 规则库 ============ -->
<section id="sec-rules">
<div class="card">
<h2>AI特征规则库(<span id="rule-count">-</span>条)</h2>
<div class="muted">来自第15章18轮送检实测总结:v9→v18逐轮对照朱雀报告定位出的高AI特征模式。关闭的规则不再参与计分。「朱雀均分」为该规则命中段落的人工登记分数均值,登记越多越准。</div>
<div style="overflow-x:auto;margin-top:10px">
<table>
<thead><tr><th style="width:60px">启用</th><th style="width:120px">规则</th><th style="width:90px">类别</th><th style="width:60px">严重度</th><th>命中示例</th><th>改写建议</th><th style="width:80px">朱雀均分</th></tr></thead>
<tbody id="rule-table"></tbody>
</table>
</div>
</div>
</section>
<!-- ============ 历史与校准 ============ -->
<section id="sec-history">
<div class="card">
<h2>分数趋势</h2>
<div class="muted">本地风险分(蓝线)越低越好;录入朱雀人工特征分(红线)越高越好。两线一起看,可以逐步摸清「本地多少分 ≈ 朱雀多少分」的换算关系,省检测次数。</div>
<svg class="trend" id="trend-svg" viewBox="0 0 640 160" preserveAspectRatio="none"></svg>
</div>
<div class="card">
<h2>检测记录</h2>
<div id="history-list"></div>
<div class="row" style="margin-top:12px">
<button class="btn ghost small" onclick="clearHistory()">清空历史</button>
</div>
</div>
<div class="card">
<h2>付费/官方检测入口</h2>
<div class="muted">免费平台稳定达标后,按闯关引导依次进入付费校准和出版社同款预检。</div>
<div class="platforms" style="margin-top:8px">
<a href="https://check.wanfangdata.com.cn/" target="_blank">万方AIGC检测<b>(约2元/千字)</b></a>
<a href="https://vpcs.fanyu.com/" target="_blank">维普检测<b>(约20元/篇)</b></a>
<a href="https://cx.cnki.net/" target="_blank">知网检测<b>(约2元/千字)</b></a>
<a href="http://www.dayainfo.com/" target="_blank">大雅检测<b>(书稿查重)</b></a>
</div>
</div>
</section>
</div>
<script>
'use strict';
/* ================= 存储 ================= */
function load(k, d){ try{ return JSON.parse(localStorage.getItem(k)) ?? d }catch(e){ return d } }
function save(k, v){ try{ localStorage.setItem(k, JSON.stringify(v)) }catch(e){} }
let rulesOff = new Set(load('zhq_rules_off', []));
let history = load('zhq_history', []);
let stageDone = load('zhq_stage_done', []);
/* v2迁移:新增第3关「驾驶舱手动改稿」,原3/4/5关顺延为4/5/6 */
if(!load('zhq_stage_v2', false)){
stageDone = stageDone.map(x=> x>=3? x+1 : x).filter((v,i,a)=>a.indexOf(v)===i).sort((a,b)=>a-b);
save('zhq_stage_done', stageDone);
save('zhq_stage_v2', true);
}
let zhuquePara = load('zhq_zhuque_para', {}); // {hash:{score,ts}}
let redLine = load('zhq_redline', '');
/* ================= 规则库 ================= */
const RULES = [
{id:'dash', name:'破折号过密', cat:'标点句式', sev:3,
re:/——/g,
desc:'连续用「——」引出补充说明',
advice:'改逗号或句号;多数情况直接重写后半句'},
{id:'colonlist', name:'冒号列举', cat:'标点句式', sev:3,
re:/(如下|包括以下|具体包括|主要分为|分为以下|主要有以下)[::]/g,
desc:'「如下:/包括:/分为:」引出清单',
advice:'删掉冒号列举结构,把要点揉进自然叙述'},
{id:'paren', name:'括号解释过密', cat:'标点句式', sev:2,
re:/([^)]{2,25})/g,
desc:'高频用括号补充解释',
advice:'括号内容改写进正文,或删掉'},
{id:'enum', name:'枚举连接词', cat:'结构路标', sev:4,
re:/(首先|其次|再次|最后[,。]|第[一二三四五]点|第[一二三四五]、|其一|其二)/g,
desc:'首先/其次/最后、第一点/第二点',
advice:'打散顺序感:用「第一个坑…另一个…」或干脆各自成段'},
{id:'roadmap', name:'路线图路标词', cat:'结构路标', sev:4,
re:/(接下来我们|接下来会|下面[我们]?[依次]?[介绍|讲解|梳理|看一下]|依次[讲解|介绍]|逐一[介绍|分析|讲解]|按顺序)/g,
desc:'「下面依次讲解」「逐一介绍」',
advice:'删路标句,内容自己会带读者走'},
{id:'summary', name:'总结套话', cat:'结构路标', sev:4,
re:/(综上所述|总而言之|总的来说|一句话总结|总之[,。])/g,
desc:'综上所述/总而言之/总之',
advice:'删掉,或改成具体的个人感受'},
{id:'numstruct', name:'数量结构腔', cat:'结构路标', sev:3,
re:/(几个方面|以下几个方面|几个环节|几条线|几个坑|几大[类步]|三大|四大|[三四五六]个维度|[两三四五]条原则)/g,
desc:'「几个方面」「三条原则」式框架感',
advice:'逐项独立叙述,不预告数量'},
{id:'aphorism', name:'金句模板', cat:'AI句式', sev:4,
re:/(最被低估的|最容易被[忽视忽略]|真正[的关键的问题的]|本质上是|归根结底|说到底[,,]?就是|不难发现|由此可见|这一点至关重要|尤为[重要关键]|比这更[重要关键])/g,
desc:'「最被低估的是…」「本质上是…」',
advice:'抽象结论绑定具体数字/事件,或直接删'},
{id:'epiphany', name:'顿悟句式', cat:'AI句式', sev:5,
re:/(让我[明白意识到记住]|那一刻我|第一次[深深]?[体会意识]到|从那以后[,,]?我|这才[明白意识到]|恍然大悟|彻底改变|深深[地]?[印在|刻在])/g,
desc:'「那一刻我意识到…」「从那以后我…」',
advice:'检测器对顿悟故事弧极敏感,删掉顿悟框架,平铺直叙即可'},
{id:'emphasis', name:'强调套话', cat:'AI句式', sev:3,
re:/(值得[一提注意]的是|需要[特别]?强调的是|特别[需要注意]的是|不得不提|更重要[的]是|需要指出)/g,
desc:'「值得注意的是…」',
advice:'删掉前缀,直接说事'},
{id:'advice', name:'建议收尾腔', cat:'AI句式', sev:3,
re:/(建议你|不妨[试试采用]|务实的[做法策略]|一个[实俗]用的[做法技巧]|这里我建议|我的建议是)/g,
desc:'段落结尾给建议的套路',
advice:'删「建议」壳,用具体做法替代'},
{id:'parallel', name:'对仗排比', cat:'AI句式', sev:2,
re:/(不仅[^。,]{1,15}[,,]?而且|既[^。,]{1,10}又|一方面[^。]{1,20}另一方面|既能[^。]{1,10}又能)/g,
desc:'不仅…而且…/既…又…',
advice:'保留一半即可,或拆成两句'},
{id:'talkreader', name:'对话腔设问', cat:'AI句式', sev:2,
re:/(你可能会[问觉得]|你是否[曾]?|有没有想过|试想一下|想象一下|你也许会[问好奇]|你可能会遇到)/g,
desc:'「你可能会问…」',
advice:'少量可留,密集出现改成直接陈述'},
{id:'storyanchor', name:'故事锚点模板', cat:'内容模板', sev:4,
re:/(20\d{2}年[春夏秋冬]?|几年前[,,]?我|那[一二]年|我[曾经]?做过一个[项目系统模型]|记得有一次|当时我[们]?[负责天真]|曾经接手)/g,
desc:'「2019年春天…」「我做过一个项目…」',
advice:'时间模糊化(前几年/早些时候),或换抱怨式/记忆式开场'},
{id:'textbook', name:'教科书骨架', cat:'内容模板', sev:3,
re:/(所谓[的Xx]?|优缺点[分别是]?|适用[场景情况]为|核心思想是|基本流程[是如下]|定义如下|其[主要]?特点是)/g,
desc:'定义-特点-优缺点-适用场景',
advice:'改成「我选型时按什么顺序排除」的叙述'},
{id:'answer', name:'标准答案腔', cat:'内容模板', sev:5,
re:/(第[一二三四五六七八九十0-9]+题|本题[考查考察]|这道题|[参考]?答案[是:解析]|解题思路|易错[点题]|考点[是包括])/g,
desc:'「本题考查…」「答案解析…」',
advice:'改「写在最后/个人手记」,内容像跟朋友复盘'},
{id:'academic', name:'学术套话', cat:'内容模板', sev:3,
re:/(随着[^。]{2,12}的[不断发展发展|进步]|在当今[^。,]{0,8}时代|在[^。,]{2,10}的背景下|日益[重要突出]|备受[关注瞩目]|蓬勃[发展涌现]|日新月异)/g,
desc:'「随着…的发展」',
advice:'直接删背景句,进入具体场景'},
{id:'numberstory', name:'数字故事腔', cat:'内容模板', sev:2,
re:/(AUC|F1|准确率)[从]? ?0\.\d+|涨了? ?0\.\d+|提升到? ?0\.\d+|只涨了|从 ?0\.\d+ [提升][到?高][^。]{0,6}/g,
desc:'精确小数指标的叙事(AUC 0.78→0.84)',
advice:'数字故事已被识别,改为克制的定性比较'},
{id:'endconcl', name:'段末收束句', cat:'统计特征', sev:2,
re:/(所以[,。]|因此[,。]|这意味着|这说明[,。]?|换句话说[,,]?$|简单来说[,,])/g,
desc:'多段以因果/总结收尾',
advice:'让部分段落以细节或动作收尾,别段段总结'},
{id:'stepwords', name:'教程步骤感', cat:'统计特征', sev:4,
re:/(安装完成后|打开终端|执行以下命令|运行结果如下|接下来输入|等待安装完成|配置环境变量|pip install|conda create|npm install)/g,
desc:'操作手册式步骤引导',
advice:'命令嵌进踩坑叙述,不做独立步骤块'},
{id:'toolong', name:'段落过长', cat:'统计特征', sev:2,
re:null, kind:'toolong',
desc:'单段正文超600字',
advice:'按「一段一个意思」拆分'},
{id:'tooshort', name:'碎句过密', cat:'统计特征', sev:3,
re:null, kind:'tooshort',
desc:'全文短碎句段占比过高',
advice:'v14教训:纯拆短句不降AI,恢复自然长短交替'},
{id:'uniform', name:'段落长度过齐', cat:'统计特征', sev:2,
re:null, kind:'uniform',
desc:'各段长度方差过小,节奏均匀',
advice:'段落长短交替,允许单句成段与长段共存'}
];
/* ---- 报告分析相关存储 ---- */
let ruleAdj = load('zhq_rule_adj', {}); // {id:{sev,ts,note}} 报告对比后调整的严重度
let customRules = load('zhq_custom_rules', []); // 报告挖掘出的自定义规则
let versions = load('zhq_versions', []); // 自查版本记录
let repParsed = null, repSelVersion = null, repMatched = null;
/* 自定义规则合并进规则库 */
customRules.forEach(r=>{ try{ RULES.push(Object.assign({}, r, {re:new RegExp(r.reSource, 'g')})) }catch(e){} });
/* 有效严重度:报告对比调整 > 规则原始值 */
function getSev(r){ const a = ruleAdj[r.id]; return (a && a.sev)? a.sev : (r.sev||3) }
/* ================= 阶段引导 ================= */
const STAGES = [
{id:0, title:'问清出版社要求', tag:'免费·最重要', free:true,
goal:'拿到三件事:①用哪套检测系统 ②AI率红线 ③是否需随稿附报告',
body:'<div class="quote">「老师好,想跟您确认下书稿的AIGC检测要求:1. 咱们用的是哪套检测系统?2. 对AI生成内容占比的上限要求是多少?3. 交稿时需要我附检测报告吗?」</div><ul><li>不同平台算法差异可达20%+,问清能省掉大量白改</li><li>通行红线:AI率≤20%,专业类≤15%,严格社≤10%</li></ul><div class="input-line" style="margin-top:8px">红线(%):<input id="redline-input" placeholder="如 10" value="' + (redLine||'') + '"> <button class="btn small" onclick="saveRedline()">保存红线</button></div>'},
{id:1, title:'本地高频自查(本工具)', tag:'免费·不限次', free:true,
goal:'红段清零,综合风险分 < 20,命中最多的3条规则改到消失',
body:'<ul><li>每次改稿后先过这里,把朱雀的5次/天额度省下来</ul></ul><div class="stage-actions"><a class="btn small" href="#" onclick="switchTab(\'check\');return false">去自查 →</a></div>'},
{id:2, title:'免费平台交叉检测', tag:'免费·每天约12次', free:true,
goal:'AI率 < 15%,朱雀人工特征 ≥ 90%',
body:'<ul><li>顺序:本工具达标 → PaperPass/PaperYY(量大管饱)→ 朱雀(最后压轴,每天5次)</li><li>重点改多平台共同标红的段落</li><li>回来把朱雀人工分录入「历史与校准」,建立换算表</li></ul><div class="platforms"><a href="https://matrix.tencent.com/zhuge/" target="_blank">朱雀AI检测 <b>5次/天</b></a><a href="https://www.paperpass.com/" target="_blank">PaperPass <b>5次/天</b></a><a href="https://www.paperyy.com/" target="_blank">PaperYY <b>2篇/天</b></a></div><div class="stage-actions"><a class="btn small" href="#" onclick="switchTab(\'history\');return false">录入朱雀分 →</a></div>'},
{id:3, title:'驾驶舱手动改稿', tag:'免费·人工精修', free:true,
goal:'免费平台测完后先在这里改稿:逐段意见 → 逐段人工修改 → 通读润色 → 复检风险分 < 20',
body:'<ul><li>免费平台测出AI率或标红段落后,先别急着花付费检测的钱</li><li>在驾驶舱「生成逐段修改意见」拿到每个红段的具体改法,优先处理多平台共同标红的段落</li><li>可先用「一键自动生成新文章」打底,但<b>必须人工通读润色</b>,自动替换只能消显性特征</li><li>收藏过合格范文的话,先做「范文分析」看风格差距(段长/第一人称频次/句长),照着改</li><li>改完把新稿贴到「自查检测」复检,风险分 < 20、红段清零,再进入付费校准</li></ul><div class="stage-actions"><a class="btn small" href="#" onclick="switchTab(\'dash\');return false">去驾驶舱改稿 →</a><a class="btn ghost small" href="#" onclick="switchTab(\'check\');return false">改完去复检 →</a></div>'},
{id:4, title:'付费校准', tag:'约20~60元', pay:true,
goal:'万方或维普 AI率 < 10%',
body:'<ul><li>每2~3轮大改后测一次,不用每轮都测</li><li>顺手记录:本地风险分X时,万方测出Y%,积累换算关系</li></ul><div class="platforms"><a href="https://check.wanfangdata.com.cn/" target="_blank">万方AIGC检测 <b>≈2元/千字</b></a><a href="https://vpcs.fanyu.com/" target="_blank">维普检测 <b>≈20元/篇</b></a></div>'},
{id:5, title:'出版社同款预检', tag:'约40~80元', pay:true,
goal:'知网(或大雅)AI率 < 10%,报告留存',
body:'<ul><li>书稿大概率走知网AIGC检测(图书专著场景),部分社用大雅</li><li>交稿前1~2周做,报告可随稿附上</li></ul><div class="platforms"><a href="https://cx.cnki.net/" target="_blank">知网检测 <b>≈2元/千字</b></a><a href="http://www.dayainfo.com/" target="_blank">大雅检测 <b>≈2元/千字</b></a></div>'},
{id:6, title:'交稿终检', tag:'出版社执行', free:true,
goal:'编辑用自家系统出报告,前面到位即走流程',
body:'<ul><li>留好各阶段检测报告编号,可核验、可自证</li></ul>'}
];
function saveRedline(){
const v = document.getElementById('redline-input').value.trim();
redLine = v; save('zhq_redline', v);
renderStages(); alert('已保存红线:' + (v? v+'%' : '未填写'));
}
function renderStages(){
const wrap = document.getElementById('stage-list');
const cur = stageDone.length;
let html = '';
STAGES.forEach((s,i)=>{
const done = stageDone.includes(s.id);
const current = !done && i === cur;
html += '<div class="stage'+(done?' done':'')+(current?' current':'')+'">'
+ '<div class="stage-head"><div class="stage-num">'+(done?'✓':s.id)+'</div>'
+ '<div class="stage-title">'+s.title+'</div>'
+ '<span class="stage-tag'+(s.pay?' pay':' free')+'">'+s.tag+'</span></div>'
+ '<div class="stage-body">'+s.body;
if(i===0 && redLine) html += '<div class="goal" style="margin-top:8px">已设红线:AI率 ≤ '+redLine+'%(第2关起按此执行)</div>';
html += '<div class="stage-actions" style="margin-top:10px">';
if(!done) html += '<button class="btn small" onclick="finishStage('+s.id+')">已完成本关,进入下一关</button>';
else html += '<button class="btn ghost small" onclick="unfinishStage('+s.id+')">撤销完成</button>';
html += '</div></div></div>';
});
wrap.innerHTML = html;
}
function finishStage(id){
if(!stageDone.includes(id)){
stageDone.push(id);
stageDone.sort((a,b)=>a-b);
save('zhq_stage_done', stageDone);
renderStages();
const next = STAGES.find(s=>!stageDone.includes(s.id));
if(next) alert('进入第'+next.id+'关:'+next.title);
else alert('全部通关!可以交稿了。');
}
}
function unfinishStage(id){
stageDone = stageDone.filter(x=>x!==id);
save('zhq_stage_done', stageDone);
renderStages();
}
/* ================= 检测引擎 ================= */
function paraHash(t){
let h=0; for(let i=0;i<t.length;i++){ h=(h*31+t.charCodeAt(i))|0 }
return 'p'+Math.abs(h);
}
function splitParas(text){
return text.replace(/\r/g,'').split(/\n\s*\n/)
.map(p=>p.replace(/\n/g,'').trim())
.filter(p=>p.length>0);
}
function isHeading(p){ return p.length<=30 && !/[。!?;]/.test(p) && /^[\d\.\s]*[^\d]/.test(p); }
function runCheck(){
const text = document.getElementById('input').value.trim();
if(!text){ alert('请先粘贴文本或载入示例'); return }
const paras = splitParas(text);
if(!paras.length){ alert('未识别到段落'); return }
const totalChars = text.replace(/\s/g,'').length;
const bodyParas = paras.filter(p=>!isHeading(p));
const shortParas = bodyParas.filter(p=>p.length<40 && !/[。!?]/.test(p));
const shortRatio = bodyParas.length? shortParas.length/bodyParas.length : 0;
const results = paras.map((p, idx)=>{
const hits = [];
const ph = paraHash(p);
RULES.forEach(r=>{
if(rulesOff.has(r.id)) return;
if(r.kind === 'toolong'){
if(!isHeading(p) && p.length > 600) hits.push({rule:r, words:['该段'+p.length+'字']});
return;
}
if(r.kind === 'tooshort'){
if(shortRatio > 0.45 && p.length < 40 && !/[。!?]/.test(p) && bodyParas.length>5)
hits.push({rule:r, words:['短碎句段']});
return;
}
if(r.kind === 'uniform'){
return; // 全文级,后面统一处理
}
if(r.re){
const m = p.match(r.re);
if(m && m.length){
const words = [...new Set(m)];
hits.push({rule:r, words, cnt:m.length});
}
}
});
let score = 0;
hits.forEach(h=>{ score += getSev(h.rule) * 3 * Math.min(h.cnt||h.words.length, 3) });
score = Math.min(100, score);
const level = score>=45?'high':(score>=20?'mid':'low');
return {idx:idx+1, text:p, chars:p.length, hits, score, level, hash:ph, isHead:isHeading(p)};
});
// 全文级:uniform
if(bodyParas.length >= 5){
const lens = bodyParas.map(p=>p.length);
const mean = lens.reduce((a,b)=>a+b,0)/lens.length;
const sd = Math.sqrt(lens.reduce((a,b)=>a+(b-mean)*(b-mean),0)/lens.length);
const cv = mean? sd/mean : 0;
const rU = RULES.find(r=>r.id==='uniform');
if(cv < 0.45 && !rulesOff.has('uniform')){
// 附到第一个正文段
const firstBody = results.find(x=>!x.isHead);
if(firstBody){ firstBody.hits.push({rule:rU, words:['段落长度CV='+cv.toFixed(2)]}); firstBody.score=Math.min(100,firstBody.score+6); firstBody.level=firstBody.score>=45?'high':(firstBody.score>=20?'mid':'low'); }
}
}
// 总分
const highN = results.filter(r=>r.level==='high'&&!r.isHead).length;
const midN = results.filter(r=>r.level==='mid'&&!r.isHead).length;
const bodyN = results.filter(r=>!r.isHead).length || 1;
const avg = results.reduce((a,r)=>a+r.score,0)/results.length;
let total = Math.round(Math.min(100, avg*0.6 + (highN/bodyN)*40 + (midN/bodyN)*18));
const lv = total>=40?'high':(total>=20?'mid':'low');
renderResult(results, total, lv, {totalChars, paraN:results.length, bodyN, highN, midN, shortRatio});
saveHistory(total, lv, results.length, highN, totalChars);
saveVersion(text, total, lv, results.length, highN, totalChars);
renderHistory(); renderRuleTable();
}
function renderResult(results, total, lv, st){
document.getElementById('empty-state').style.display='none';
document.getElementById('result-card').style.display='block';
document.getElementById('para-card-wrap').style.display='block';
const num = document.getElementById('score-num');
num.textContent = total;
num.className = 'score-num lv-'+lv;
const lvTxt = {high:'高风险:先改红段', mid:'中风险:黄段继续打磨', low:'低风险:可去免费平台交叉'}[lv];
document.getElementById('score-lv').textContent = lvTxt + '(风险分越低越好)';
document.getElementById('score-stats').innerHTML =
'字数 <b>'+st.totalChars+'</b> | 段落 <b>'+st.paraN+'</b>(正文'+st.bodyN+')| 红段 <b style="color:var(--red)">'+st.highN+'</b> | 黄段 <b style="color:var(--yellow)">'+st.midN+'</b> | 碎句段占比 <b>'+(st.shortRatio*100).toFixed(0)+'%</b>';
// TOP规则
const cnt = {};
results.forEach(r=>r.hits.forEach(h=>{ cnt[h.rule.name]=(cnt[h.rule.name]||0)+1 }));
const top = Object.entries(cnt).sort((a,b)=>b[1]-a[1]).slice(0,6);
const maxC = top.length? top[0][1] : 1;
document.getElementById('top-rules').innerHTML = top.length?
'<div class="muted" style="margin-bottom:6px">命中最多的规则(优先处理):</div>'+
top.map(([n,c])=>'<div class="tr"><div class="tr-name">'+n+'</div><div class="tr-bar" style="width:'+(c/maxC*220)+'px"></div><div class="tr-cnt">×'+c+'段</div></div>').join('')
: '<div class="muted">无命中规则</div>';
// 段落列表(红→黄→绿排序,同色按原序)
const order = {high:0, mid:1, low:2};
const sorted = results.slice().sort((a,b)=>(order[a.level]-order[b.level])||(a.idx-b.idx));
document.getElementById('para-list').innerHTML = sorted.map(r=>{
const zq = zhuquePara[r.hash];
const zqTxt = zq? ' <span class="para-badge low">朱雀人工 '+zq.score+'%</span>' : '';
const tags = r.hits.map(h=>'<span class="hit-tag s'+h.rule.sev+'">'+h.rule.name+' ×'+(h.cnt||h.words.length)+'</span>').join('');
const detail = r.hits.length? r.hits.map(h=>
'<div class="hit-line"><b>'+h.rule.name+'</b>('+h.rule.cat+'·严重度'+h.rule.sev+'):'+
h.words.map(w=>'<span class="hit-word">'+w+'</span>').join('')+
'<div class="advice">→ '+h.rule.advice+'</div></div>').join('')
: '<div class="hit-line">未命中规则</div>';
const head = r.isHead? ' <span class="para-badge low">标题</span>' : '';
return '<div class="para-card '+r.level+'" id="pc-'+r.idx+'">'
+ '<div class="para-head" onclick="togglePara('+r.idx+')">'
+ '<span class="para-idx">段'+r.idx+'</span><span class="para-chars">'+r.chars+'字</span>'
+ '<span class="para-badge '+r.level+'">'+({high:'高风险',mid:'疑似',low:'低风险'}[r.level])+' '+r.score+'分</span>'
+ head + zqTxt
+ '<span class="muted" style="margin-left:auto;font-size:11px">点击展开 ▾</span></div>'
+ '<div class="hit-tags">'+ (tags||'<span class="hit-tag">无命中</span>') +'</div>'
+ '<div class="para-text">'+r.text+'</div>'
+ '<div class="hit-detail">'+detail
+ '<div style="margin-top:6px"><button class="btn ghost small" onclick="event.stopPropagation();markZhuque('+r.idx+')">登记该段朱雀分</button></div></div>'
+ '</div>';
}).join('');
window.__results = results;
}
function togglePara(i){
document.getElementById('pc-'+i).classList.toggle('open');
}
function markZhuque(i){
const r = window.__results && window.__results.find(x=>x.idx===i);
if(!r) return;
const v = prompt('该段在朱雀报告中的「人工特征率」(0-100,只填数字):', zhuquePara[r.hash]? zhuquePara[r.hash].score : '');
if(v===null) return;
const n = parseFloat(v);
if(isNaN(n)||n<0||n>100){ alert('请输入0-100的数字'); return }
zhuquePara[r.hash] = {score:n, ts:Date.now()};
save('zhq_zhuque_para', zhuquePara);
runCheck();
}
/* ================= 历史 ================= */
function saveHistory(total, lv, paraN, highN, chars){
history.push({t:Date.now(), total, lv, paraN, highN, chars, zq:null});
if(history.length>60) history = history.slice(-60);
save('zhq_history', history);
}
function renderHistory(){
const list = document.getElementById('history-list');
if(!history.length){ list.innerHTML = '<div class="muted">暂无记录,跑一次自查就会出现在这里</div>'; drawTrend(); return }
list.innerHTML = history.slice().reverse().map((h,idx)=>{
const i = history.length-1-idx;
const d = new Date(h.t);
const ts = d.toLocaleDateString('zh-CN')+' '+d.toTimeString().slice(0,5);
const zq = h.zq!==null? '<b style="color:var(--blue)">朱雀人工 '+h.zq+'%</b>' : '<span class="muted">未录入朱雀分</span>';
const cls = h.lv==='high'?'sev5':(h.lv==='mid'?'sev3':'');
return '<div class="history-row"><span class="muted">'+ts+'</span>'
+ '<span>风险分 <b class="'+cls+'">'+h.total+'</b></span>'
+ '<span class="muted">'+h.paraN+'段 / 红段'+h.highN+' / '+h.chars+'字</span>'
+ zq
+ '<button class="btn ghost small" onclick="inputZhuque('+i+')">录入朱雀分</button>'
+ '<button class="btn ghost small" onclick="delHistory('+i+')">删除</button></div>';
}).join('');
drawTrend();
}
function inputZhuque(i){
const h = history[i];
const v = prompt('这次检测对应稿子的朱雀「人工特征率」(0-100):', h.zq!==null? h.zq : '');
if(v===null) return;
const n = parseFloat(v);
if(isNaN(n)||n<0||n>100){ alert('请输入0-100的数字'); return }
h.zq = n; save('zhq_history', history);
renderHistory();
}
function delHistory(i){
history.splice(i,1); save('zhq_history', history); renderHistory();
}
function clearHistory(){
if(!confirm('清空全部检测记录?此操作不可恢复。')) return;
history = []; save('zhq_history', history); renderHistory();
}
function drawTrend(){
const svg = document.getElementById('trend-svg');
if(!history.length){ svg.innerHTML = '<text x="20" y="80" fill="#8a94a0" font-size="12">暂无数据</text>'; return }
const W=640, H=160, pad=28;
const n = history.length;
const step = n>1? (W-2*pad)/(n-1) : 0;
let p1='', p2='';
history.forEach((h,i)=>{
const x = pad + i*step;
const y1 = H-pad - (h.total/100)*(H-2*pad);
p1 += (i?'L':'M')+x+' '+y1+' ';
if(h.zq!==null){
const y2 = H-pad - (h.zq/100)*(H-2*pad);
p2 += (p2?'L':'M')+x+' '+y2+' ';
}
});
let g = '<line x1="'+pad+'" y1="'+(H-pad)+'" x2="'+(W-pad)+'" y2="'+(H-pad)+'" stroke="#e4e8ef"/>'
+ '<line x1="'+pad+'" y1="'+pad+'" x2="'+pad+'" y2="'+(H-pad)+'" stroke="#e4e8ef"/>'
+ '<text x="4" y="'+(pad+4)+'" font-size="10" fill="#8a94a0">100</text>'
+ '<text x="8" y="'+(H-pad)+'" font-size="10" fill="#8a94a0">0</text>';
if(p2) g += '<path d="'+p2+'" fill="none" stroke="#e5484d" stroke-width="2" stroke-dasharray="4 3"/>';
g += '<path d="'+p1+'" fill="none" stroke="#2f6fed" stroke-width="2"/>';
history.forEach((h,i)=>{
const x = pad+i*step, y1 = H-pad-(h.total/100)*(H-2*pad);
g += '<circle cx="'+x+'" cy="'+y1+'" r="3" fill="#2f6fed"/>';
if(h.zq!==null) g += '<circle cx="'+x+'" cy="'+(H-pad-(h.zq/100)*(H-2*pad))+'" r="3" fill="#e5484d"/>';
});
svg.innerHTML = g;
}
/* ================= 规则库表 ================= */
function renderRuleTable(){
const tb = document.getElementById('rule-table');
document.getElementById('rule-count').textContent = RULES.length;
tb.innerHTML = RULES.map(r=>{
const off = rulesOff.has(r.id);
const hitParas = Object.entries(zhuquePara).length? null : null;
// 朱雀均分:该规则命中的段落里已登记的
let sum=0, cnt=0;
if(window.__results){
window.__results.forEach(res=>{
if(res.hits.some(h=>h.rule.id===r.id) && zhuquePara[res.hash]!==undefined){ sum+=zhuquePara[res.hash].score; cnt++ }
});
}
const zq = cnt? '<span class="zq-avg">'+(sum/cnt).toFixed(0)+'%</span>' : '<span class="muted" style="font-size:12px">-</span>';
const sample = r.kind? r.desc : (r.desc);
const es = getSev(r);
const adjNote = ruleAdj[r.id]? '<div class="muted" style="font-size:11px">'+r.sev+'→'+ruleAdj[r.id].sev+'</div>' : '';
return '<tr'+(off?' style="opacity:.5"':'')+'>'
+ '<td><span class="switch" onclick="toggleRule(\''+r.id+'\')">'+(off?'◻':'◼')+'</span></td>'
+ '<td><b>'+r.name+'</b></td><td class="muted">'+r.cat+'</td>'
+ '<td><span class="sev sev'+es+'">'+'●'.repeat(es>3?5:3)+'</span>'+adjNote+'</td>'
+ '<td class="muted">'+sample+'</td><td class="muted">'+r.advice+'</td>'
+ '<td>'+zq+'</td></tr>';
}).join('');
}
function toggleRule(id){
if(rulesOff.has(id)) rulesOff.delete(id); else rulesOff.add(id);
save('zhq_rules_off', [...rulesOff]);
renderRuleTable();
}
/* ================= 其他 ================= */
function switchTab(t){
['guide','dash','check','report','rules','history'].forEach(x=>{
document.getElementById('sec-'+x).classList.toggle('show', x===t);
document.getElementById('nav-'+x).classList.toggle('active', x===t);
});
}
function clearInput(){ document.getElementById('input').value=''; }
function loadSample(){
document.getElementById('input').value =
`首先,我们需要明确AI项目的目标。其次,要梳理数据来源。最后,制定评估方案。
——这是很多教程的说法,但真正的问题是:最容易被低估的其实是沟通成本。2019年春天,我做过一个用户流失预测项目,那一刻我深刻地意识到,需求对齐比模型选择更重要。
首先安装Anaconda。打开终端,执行以下命令:conda create -n book python=3.10。安装完成后,配置环境变量。接下来输入pip install xgboost,等待安装完成。
值得注意的是,XGBoost的AUC从0.78提升到0.84,只涨了0.01却花了一周时间。这让我明白,纸面指标最强的模型不一定最好。综上所述,模型选型要结合业务场景、维护成本、团队能力、部署环境四个方面综合考虑。
这道题考查的是过拟合的判断方法。解题思路如下:首先观察训练集与验证集指标差距,其次检查数据泄漏,最后参考答案给出正则化建议。
我把那段监控指标拆成了几条线。服务层的日志里藏着线索。`;
}
function exportReport(){
if(!window.__results){ alert('请先执行一次检测'); return }
const results = window.__results;
const total = document.getElementById('score-num').textContent;
const cnt = {};
results.forEach(r=>r.hits.forEach(h=>{ cnt[h.rule.name]=(cnt[h.rule.name]||0)+1 }));
const top = Object.entries(cnt).sort((a,b)=>b[1]-a[1]);
let rep = '【AI特征自查报告】\n时间:'+new Date().toLocaleString('zh-CN')+'\n综合风险分:'+total+'(启发式,0-100,越低越好)\n\n段落明细:\n';
results.forEach(r=>{
if(r.isHead && !r.hits.length) return;
const hs = r.hits.map(h=>h.rule.name+'×'+(h.cnt||h.words.length)).join('、') || '无';
rep += '[段'+r.idx+'] '+r.score+'分('+r.level+') '+r.chars+'字 | '+hs+'\n';
});
rep += '\n命中最多的规则:\n'+top.map(([n,c],i)=>(i+1)+'. '+n+' ×'+c+'段').join('\n');
rep += '\n\n提示:本报告为规则启发式自查结果,用于定位问题段落;最终请以朱雀/知网等官方检测为准。';
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(rep).then(()=>alert('报告已复制到剪贴板'), ()=>{ prompt('复制下面的报告:', rep) });
}else{
prompt('复制下面的报告:', rep);
}
}
/* 拖拽txt */
document.addEventListener('dragover', e=>{ e.preventDefault(); document.getElementById('drop-hint').textContent='松开鼠标导入 .txt 文件'; });
document.addEventListener('dragleave', ()=>{ document.getElementById('drop-hint').textContent='支持拖拽 .txt 文件 · 内容只在本机处理,不上传'; });
document.addEventListener('drop', e=>{
e.preventDefault();
document.getElementById('drop-hint').textContent='支持拖拽 .txt 文件 · 内容只在本机处理,不上传';
const f = e.dataTransfer.files[0];
if(!f) return;
if(!/\.txt$/i.test(f.name)){ alert('请拖入 .txt 文件(docx 请先用 docx转txt.py 转换)'); return }
const reader = new FileReader();
reader.onload = ()=>{ document.getElementById('input').value = reader.result; runCheck(); };
reader.readAsText(f, 'utf-8');
});
/* ================= 驾驶舱 ================= */
let favs = load('zhq_favs', []);
/* 独立文本分析(不依赖DOM) */
function analyzeText(text){
const paras = splitParas(text);
const bodyParas = paras.filter(p=>!isHeading(p));
const shortParas = bodyParas.filter(p=>p.length<40 && !/[。!?]/.test(p));
const results = paras.map((p, idx)=>{
const hits = [];
RULES.forEach(r=>{
if(rulesOff.has(r.id)) return;
if(r.kind==='toolong'){ if(!isHeading(p) && p.length>600) hits.push({rule:r, words:['该段'+p.length+'字']}); return }
if(r.kind==='tooshort' || r.kind==='uniform') return;
if(r.re){ const m = p.match(r.re); if(m && m.length) hits.push({rule:r, words:[...new Set(m)], cnt:m.length}) }
});
let score = 0;
hits.forEach(h=>{ score += getSev(h.rule)*3*Math.min(h.cnt||h.words.length,3) });
score = Math.min(100, score);
return {idx:idx+1, text:p, chars:p.length, hash:paraHash(p), hits, score, level:score>=45?'high':(score>=20?'mid':'low'), isHead:isHeading(p)};
});
const lens = bodyParas.map(p=>p.length);
const mean = lens.length? lens.reduce((a,b)=>a+b,0)/lens.length : 0;
const cv = lens.length>1? Math.sqrt(lens.reduce((a,b)=>a+(b-mean)*(b-mean),0)/lens.length)/(mean||1) : 0;
const ruleCnt = {};
results.forEach(r=>r.hits.forEach(h=>{ ruleCnt[h.rule.name]=(ruleCnt[h.rule.name]||0)+1 }));
return {results, bodyN:bodyParas.length||paras.length, chars:text.replace(/\s/g,'').length,
meanLen:mean, cv, shortRatio:bodyParas.length?shortParas.length/bodyParas.length:0, ruleCnt};
}
/* 风格画像 */
function styleProfile(text){
const t = text.replace(/\s/g,'');
const periods = (t.match(/。/g)||[]).length;
const commas = (t.match(/,/g)||[]).length;
const me = (t.match(/我/g)||[]).length;
const questions = (t.match(/?/g)||[]).length;
return {me, mePer1k:t.length? me/t.length*1000 : 0,
avgSent:periods? t.length/periods : t.length,
commaRatio:periods? commas/periods : commas, questions};
}
/* KPI 总览 */
function renderDash(){
const kpis = document.getElementById('dash-kpis');
const lastH = history[history.length-1];
const risk = lastH? lastH.total : null;
const zqLast = [...history].reverse().find(h=>h.zq!==null && h.zq!==undefined);
const todayCnt = history.filter(h=>new Date(h.t).toDateString()===new Date().toDateString()).length;
const cur = STAGES.find(s=>!stageDone.includes(s.id));
kpis.innerHTML =
'<div class="kpi"><div class="k">当前本地风险分</div><div class="v '+(risk===null?'':(risk>=40?'red':risk>=20?'yellow':'green'))+'">'+(risk===null?'-':risk)+'</div><div class="s">目标 < 20</div></div>'
+ '<div class="kpi"><div class="k">最近朱雀人工分</div><div class="v '+(zqLast?(zqLast.zq>=90?'green':zqLast.zq>=70?'yellow':'red'):'')+'">'+(zqLast? zqLast.zq+'%':'-')+'</div><div class="s">目标 ≥ 90%</div></div>'
+ '<div class="kpi"><div class="k">合格范文库</div><div class="v blue">'+favs.length+'</div><div class="s">收藏线:AI率<15%</div></div>'
+ '<div class="kpi"><div class="k">今日本地自查</div><div class="v">'+todayCnt+' 次</div><div class="s">'+(cur? '第'+cur.id+'关 · '+cur.title : '全部通关')+'</div></div>';
}
/* ① 收藏 */
function collectFav(){
const rate = parseFloat(document.getElementById('fav-rate').value);
const text = document.getElementById('input').value.trim();
if(!text){ alert('收藏前提:先把该文章正文粘贴到「自查检测」页的待检文本框,再回来点收藏'); return }
if(isNaN(rate) || rate<0 || rate>100){ alert('请填写这篇文章在外部平台检测出的AI率(0-100的数字)'); return }
if(rate >= 15){ alert('AI率 '+rate+'% 未达收藏线(<15%)。先继续打磨,收藏线也是免费阶段的过关线。'); return }
const title = document.getElementById('fav-title').value.trim() || ('合格范文 '+(favs.length+1));
const a = analyzeText(text);
favs.push({title, rate, text, paraN:a.results.length, chars:a.chars, ts:Date.now()});
save('zhq_favs', favs);
document.getElementById('fav-rate').value='';
document.getElementById('fav-title').value='';
renderFavs(); renderDash();
alert('已收藏「'+title+'」(AI率 '+rate+'%,'+a.results.length+'段 / '+a.chars+'字)。\n去②里点「分析」看它的风格画像。');
}
function renderFavs(){
const el = document.getElementById('fav-list');
if(!favs.length){ el.innerHTML = '<div class="muted">范文库为空。在外部平台测出 AI率<15% 后回来收藏,收藏的范文会用于风格对标。</div>'; return }
el.innerHTML = favs.slice().reverse().map((f,ri)=>{
const i = favs.length-1-ri;
const d = new Date(f.ts).toLocaleDateString('zh-CN');
return '<div class="fav-row"><span class="fav-rate">AI率 '+f.rate+'%</span>'
+ '<b>'+f.title+'</b>'
+ '<span class="muted">'+f.paraN+'段 / '+f.chars+'字 / '+d+'</span>'
+ '<button class="btn ghost small" onclick="analyzeFav('+i+')">分析</button>'
+ '<button class="btn ghost small" onclick="loadFavToInput('+i+')">载入待检框</button>'
+ '<button class="btn ghost small" onclick="delFav('+i+')">删除</button></div>';
}).join('');
}
function loadFavToInput(i){
document.getElementById('input').value = favs[i].text;
switchTab('check');
runCheck();
}
function delFav(i){
if(!confirm('删除范文「'+favs[i].title+'」?')) return;
favs.splice(i,1); save('zhq_favs', favs);
renderFavs(); renderDash();
}
/* ② 范文分析 */
function analyzeFav(i){
const f = favs[i];
const a = analyzeText(f.text);
const prof = styleProfile(f.text);
const green = a.results.filter(r=>!r.isHead).sort((x,y)=>x.score-y.score).slice(0,3);
const highN = a.results.filter(r=>r.level==='high'&&!r.isHead).length;
const midN = a.results.filter(r=>r.level==='mid'&&!r.isHead).length;
const topRules = Object.entries(a.ruleCnt).sort((x,y)=>y[1]-x[1]).slice(0,5);
let html = '<div class="adv-card"><div class="adv-head">「'+f.title+'」风格画像(AI率 '+f.rate+'%)</div><div class="adv-body">'
+ '正文 <b>'+a.bodyN+'</b> 段 | 平均段长 <b>'+a.meanLen.toFixed(0)+'</b> 字 | 长短波动系数 <b>'+a.cv.toFixed(2)+'</b>(>0.45 说明长短交替自然)<br>'
+ '「我」出现 <b>'+prof.me+'</b> 次(每千字 <b>'+prof.mePer1k.toFixed(1)+'</b> 次)| 平均句长 <b>'+prof.avgSent.toFixed(0)+'</b> 字 | 逗号/句号比 <b>'+prof.commaRatio.toFixed(1)+'</b> | 问句 <b>'+prof.questions+'</b> 处<br>'
+ '本地规则:红段 <b>'+highN+'</b>、黄段 <b>'+midN+'</b>'
+ (topRules.length? ',top命中:'+topRules.map(([n,c])=>n+'×'+c).join('、') : ',几乎无命中') + '</div></div>';
html += '<div class="muted" style="margin:8px 0 2px">最像人写的段落(改稿时模仿这种节奏):</div>';
green.forEach(g=>{ html += '<div class="para-ref">'+g.text.slice(0,150)+(g.text.length>150?'…':'')+'</div>' });
const cur = document.getElementById('input').value.trim();
if(cur){
const ca = analyzeText(cur); const cp = styleProfile(cur);
const curHigh = ca.results.filter(r=>r.level==='high'&&!r.isHead).length;
const gaps = [];
if(ca.meanLen > a.meanLen*1.6) gaps.push('段落太长,按句拆');
if(ca.meanLen < a.meanLen*0.6 && ca.meanLen>0) gaps.push('段落太碎,合并');
if(ca.cv < 0.45) gaps.push('段落长度太齐,做长短交替');
if(cp.mePer1k < prof.mePer1k*0.5) gaps.push('第一人称太少,加「我」的判断和经历');
if(ca.shortRatio > 0.45) gaps.push('碎句段过多,恢复自然节奏');
html += '<div class="adv-card" style="margin-top:10px"><div class="adv-head">与当前稿对比</div><div class="adv-body">'
+ '平均段长:范文 <b>'+a.meanLen.toFixed(0)+'</b> 字 vs 当前 <b>'+ca.meanLen.toFixed(0)+'</b> 字<br>'
+ '长短波动:范文 <b>'+a.cv.toFixed(2)+'</b> vs 当前 <b>'+ca.cv.toFixed(2)+'</b><br>'
+ '「我」/千字:范文 <b>'+prof.mePer1k.toFixed(1)+'</b> vs 当前 <b>'+cp.mePer1k.toFixed(1)+'</b><br>'
+ '红段占比:范文 <b>'+(a.bodyN? (highN/a.bodyN*100).toFixed(0):0)+'%</b> vs 当前 <b>'+(ca.bodyN? (curHigh/ca.bodyN*100).toFixed(0):0)+'%</b><br>'
+ '→ 当前稿优先补:' + (gaps.length? gaps.join(';') : '各项已接近范文水平,重点做词句级微调') + '</div></div>';
}
document.getElementById('fav-analysis').innerHTML = html;
}
/* ③ 逐段修改意见 */
function genAdvice(){
const text = document.getElementById('input').value.trim();
if(!text){ alert('请先把当前稿粘贴到「自查检测」页的待检文本框'); return }
const a = analyzeText(text);
const order = {high:0, mid:1, low:2};
const list = a.results.filter(r=>!r.isHead).sort((x,y)=>(order[x.level]-order[y.level])||(x.idx-y.idx));
const highN = a.results.filter(r=>r.level==='high'&&!r.isHead).length;
const midN = a.results.filter(r=>r.level==='mid'&&!r.isHead).length;
const prov = favs.length? '(范文库已有 '+favs.length+' 篇,去②点「分析」可对比节奏)' : '(范文库为空:收藏一篇 AI率<15% 的文章后可对比风格)';
let html = '<div class="muted">共 '+a.bodyN+' 个正文段,红段 '+highN+'、黄段 '+midN+',按风险从高到低排列'+prov+'</div>';
html += list.map(r=>{
const items = r.hits.length
? r.hits.map(h=>'<div>· <b>'+h.rule.name+'</b>('+h.words.slice(0,3).join(' / ')+')→ '+h.rule.advice+'</div>').join('')
: '<div>· 未命中显性规则,保持即可</div>';
let extra = '';
if(r.chars>600) extra = '<div>· 该段 '+r.chars+' 字过长:按「一段一个意思」从中间句号处拆开(一键生成会自动拆)</div>';
else if(a.cv<0.45) extra = '<div>· 全文段落长度过齐(CV='+a.cv.toFixed(2)+'):相邻段做一长一短</div>';
return '<div class="adv-card" style="border-left:4px solid '+(r.level==='high'?'var(--red)':r.level==='mid'?'var(--yellow)':'var(--green)')+'">'
+ '<div class="adv-head"><span class="para-idx">段'+r.idx+'</span><span class="para-badge '+r.level+'">'+({high:'高风险',mid:'疑似',low:'低风险'}[r.level])+' '+r.score+'分</span><span class="para-chars">'+r.chars+'字</span></div>'
+ '<div class="adv-body">'+items+extra+'</div></div>';
}).join('');
document.getElementById('advice-list').innerHTML = html;
}
/* 一键自动改写引擎 */
const REWRITES = [
{re:/——/g, to:',', note:'破折号改逗号'},
{re:/首先,?/g, to:'', note:'删「首先」'},
{re:/其次,?/g, to:'', note:'删「其次」'},
{re:/再次,?/g, to:'', note:'删「再次」'},
{re:/最后,/g, to:'', note:'删「最后」'},
{re:/第[一二三四五]点[,,]?/g, to:'', note:'删「第X点」'},
{re:/(综上所述|总而言之|总的来说|一句话总结)[,,]?/g, to:'', note:'删总结套话'},
{re:/总之,/g, to:'', note:'删「总之」'},
{re:/(值得注意的是|需要[特别]?强调的是|需要指出的是|不得不提的是)[,,]?/g, to:'', note:'删强调套话'},
{re:/更重要的是,/g, to:'', note:'删「更重要的是」'},
{re:/(([^()]{2,25}))/g, to:'$1', note:'去括号解释'},
{re:/20\d{2}年(春天|夏天|秋天|冬天|春|夏|秋|冬)?/g, to:'前几年', note:'年份模糊化'},
{re:/(那一刻我|第一次深刻地意识到|从那以后,我)/g, to:'后来我', note:'顿悟句式降调'},
{re:/(让我明白了?|让我意识到|让我记住)/g, to:'后来我才搞清楚', note:'顿悟句式降调'},
{re:/(随着[^。,]{2,12}的(不断)?发展,?)/g, to:'', note:'删宏观背景句'},
{re:/(在当今[^。,]{0,8}时代,?)/g, to:'', note:'删时代背景句'},
{re:/(打开终端[,,]?执行以下命令[::])/g, to:'当时在终端里敲了', note:'教程腔口语化'},
{re:/安装完成后[,,]?/g, to:'装好之后', note:'教程腔口语化'},
{re:/(等待安装完成|等待运行完成)/g, to:'等它跑完', note:'教程腔口语化'},
{re:/(本题考查[^。]{0,12}|这道题)/g, to:'', note:'删标准答案腔'},
{re:/解题思路如下[::]/g, to:'', note:'删标准答案腔'},
{re:/(不难发现|由此可见),/g, to:'', note:'删推论套话'},
{re:/不仅([^。,]{1,15}),?而且/g, to:'$1,', note:'拆对仗排比'},
{re:/(建议你|这里我建议|我的建议是)[,,]?/g, to:'我一般会', note:'建议腔个人化'},
{re:/(不妨试试|不妨采用)/g, to:'可以试试', note:'建议腔口语化'},
{re:/(务实的做法|务实的策略)/g, to:'这么干', note:'套话口语化'},
{re:/(如下[::]|包括以下[::]|具体包括[::]|主要分为[::]|分为以下[::]|主要有以下[::])/g, to:'', note:'删冒号列举'}
];
function autoRewrite(){
const text = document.getElementById('input').value.trim();
if(!text){ alert('请先把当前稿粘贴到「自查检测」页的待检文本框'); return }
const paras = splitParas(text);
const out = []; const changes = []; let applyCnt = 0;
paras.forEach((p, idx)=>{
let np = p; const notes = [];
REWRITES.forEach(rw=>{
const m = np.match(rw.re);
if(m && m.length){ notes.push(rw.note+'×'+m.length); applyCnt += m.length; np = np.replace(rw.re, rw.to); }
});
np = np.replace(/,{2,}/g,',').replace(/。,/g,'。').replace(/^[,。、\s]+/,'').trim();
if(!np) return;
if(!isHeading(np) && np.length>600){
const mid = np.indexOf('。', Math.floor(np.length/2));
const cut = mid===-1? Math.floor(np.length/2) : mid+1;
const p1 = np.slice(0,cut).trim(), p2 = np.slice(cut).trim();
if(p1) out.push(p1);
if(p2) out.push(p2);
notes.push('长段拆分');
} else out.push(np);
if(notes.length) changes.push({idx:idx+1, notes});
});
document.getElementById('gen-wrap').style.display='block';
document.getElementById('gen-output').value = out.join('\n\n');
document.getElementById('gen-stat').innerHTML = '共应用 <b>'+applyCnt+'</b> 处替换 + 结构调整,涉及 <b>'+changes.length+'</b> 个段落('
+ changes.slice(0,8).map(c=>'段'+c.idx+':'+c.notes.join('、')).join(';') + (changes.length>8?';…' : '') + ')。'
+ '<b>注意:自动改写是确定性词句替换,生成后请人工通读,重点检查语序是否通顺,再送回自查复检。</b>';
}
function copyGen(){
const v = document.getElementById('gen-output').value;
if(!v){ alert('请先生成新文章'); return }
if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(v).then(()=>alert('已复制全文'), ()=>prompt('复制:', v)); }
else prompt('复制:', v);
}
function downloadGen(){
const v = document.getElementById('gen-output').value;
if(!v){ alert('请先生成新文章'); return }
const blob = new Blob([v], {type:'text/plain;charset=utf-8'});
const el = document.createElement('a');
el.href = URL.createObjectURL(blob);
el.download = '改写稿_自动生成.txt';
el.click();
}
function sendGenToCheck(){
const v = document.getElementById('gen-output').value;
if(!v){ alert('请先生成新文章'); return }
document.getElementById('input').value = v;
switchTab('check');
runCheck();
}
/* ================= 报告分析 ================= */
function normText(t){
return (t||'').replace(/[\s\u3000,。、;:?!“”‘’()()【】\[\]《》〈〉.,!?;:'"~\-—…·]/g,'');
}
function escapeRe(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') }
/* 版本记录:runCheck 每次自动存(同稿去重) */
function saveVersion(text, total, lv, paraN, highN, chars){
const h = paraHash('V'+text);
let v = versions.find(x=>x.hash===h);
if(v){
v.ts = Date.now(); v.total = total; v.lv = lv; v.paraN = paraN; v.highN = highN; v.chars = chars;
v.histT = history.length? history[history.length-1].t : null;
}else{
v = {id:'v'+Date.now(), ts:Date.now(), hash:h, total, lv, paraN, highN, chars, text,
histT: history.length? history[history.length-1].t : null};
versions.push(v);
if(versions.length>30) versions = versions.slice(-30);
}
save('zhq_versions', versions);
renderRepVersions();
}
function renderRepVersions(){
const el = document.getElementById('rep-versions');
if(!el) return;
if(!versions.length){ el.innerHTML = '<div class="muted">暂无版本记录:在「自查检测」页对稿子跑一次自查,会自动存为版本。</div>'; return }
el.innerHTML = versions.slice().reverse().map((v,ri)=>{
const i = versions.length-1-ri;
const d = new Date(v.ts);
const sel = repSelVersion && repSelVersion.id===v.id;
const cls = v.lv==='high'?'sev5':(v.lv==='mid'?'sev3':'');
return '<div class="version-row'+(sel?' sel':'')+'" onclick="selectVersion('+i+')">'
+ '<span class="sw">'+(sel?'◉':'◯')+'</span>'
+ '<span class="muted">'+d.toLocaleDateString('zh-CN')+' '+d.toTimeString().slice(0,5)+'</span>'
+ '<span>风险分 <b class="'+cls+'">'+v.total+'</b></span>'
+ '<span class="muted">'+v.paraN+'段 / '+v.chars+'字 / 红段'+v.highN+'</span>'
+ '<button class="btn ghost small" onclick="event.stopPropagation();loadVersionToInput('+i+')">载入待检框</button>'
+ '</div>';
}).join('');
}
function selectVersion(i){ repSelVersion = versions[i]; renderRepVersions(); }
function loadVersionToInput(i){
document.getElementById('input').value = versions[i].text;
switchTab('check'); runCheck();
}
/* ---- ① 报告导入与解析 ---- */
function loadRepFile(inp){
const f = inp.files[0]; if(!f) return;
const reader = new FileReader();
reader.onload = ()=>{ document.getElementById('rep-input').value = reader.result; parseReport(); };
reader.readAsText(f, 'utf-8');
inp.value = '';
}
/* PDF直接上传:懒加载pdf.js(仅此时联网一次),文字版直接提取,扫描版自动 OCR */
let repPdfBusy = false;
function setRepProgress(msg){
document.getElementById('rep-progress').textContent = msg || '';
}
function ensurePdfJs(cb){
if(window.pdfjsLib){ cb(); return }
const CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/';
const s = document.createElement('script');
s.src = CDN + 'pdf.min.js';
s.onload = ()=>{
window.pdfjsLib.GlobalWorkerOptions.workerSrc = CDN + 'pdf.worker.min.js';
cb();
};
s.onerror = ()=>{ repPdfBusy=false; alert('PDF解析组件加载失败(需联网加载一次,约1MB)。\n离线替代:用配套「报告转txt.py」转换后再上传txt。'); };
document.head.appendChild(s);
}
function ensureTesseract(cb){
if(window.Tesseract && window.Tesseract.createWorker){ cb(); return }
setRepProgress('正在加载 OCR 组件,约 15MB 中文模型首次需联网下载…');
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/tesseract.js@4/dist/tesseract.min.js';
s.onload = ()=> cb();
s.onerror = ()=>{ repPdfBusy=false; setRepProgress(''); alert('OCR 组件加载失败(需联网)。\n离线替代:用配套「报告转txt.py」导出页面图片后,再用本地 OCR 软件识别并粘贴文本。'); };
document.head.appendChild(s);
}
async function ocrPdfPages(pdf){
return new Promise((resolve, reject)=>{
ensureTesseract(async ()=>{
try{
const worker = await Tesseract.createWorker('chi_sim', 1, {
logger: m => {
if(m.status === 'recognizing text'){
setRepProgress('正在 OCR 第 ' + m.jobId + ' 页,进度 ' + Math.round(m.progress*100) + '%…');
}
}
});
let parts = [];
for(let i=1; i<=pdf.numPages; i++){
setRepProgress('正在渲染第 '+i+'/'+pdf.numPages+' 页…');
const page = await pdf.getPage(i);
const scale = 2;
const viewport = page.getViewport({scale});
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await page.render({canvasContext: ctx, viewport}).promise;
setRepProgress('正在 OCR 第 '+i+'/'+pdf.numPages+' 页…');
const { data: { text } } = await worker.recognize(canvas);
if(text && text.trim()) parts.push(text.trim());
}
await worker.terminate();
resolve({text: parts.join('\n\n'), count: parts.length});
}catch(e){ reject(e); }
});
});
}
function loadRepPdf(inp){
const f = inp.files[0]; if(!f || repPdfBusy) return;
repPdfBusy = true;
const name = f.name;
ensurePdfJs(async ()=>{
try{
const buf = await f.arrayBuffer();
const pdf = await window.pdfjsLib.getDocument({data: buf}).promise;
let texts = [], emptyPages = 0;
for(let i=1; i<=pdf.numPages; i++){
const page = await pdf.getPage(i);
const tc = await page.getTextContent();
let t = (tc.items||[]).map(x=>x.str).join(' ').replace(/\s+/g,' ').trim();
if(t.replace(/[^\u4e00-\u9fa5A-Za-z0-9]/g,'').length < 10){ emptyPages++; t=''; }
texts.push(t);
}
let total = texts.filter(Boolean).join('\n\n');
let ocrResult = null;
if(total.replace(/\s/g,'').length < 30){
const ok = confirm('「'+name+'」共 '+pdf.numPages+' 页,未检测到文字层,疑似扫描/图片版 PDF。\n\n将启用浏览器 OCR(tesseract.js 中文模型,首次约需下载 15MB),预计耗时 '+Math.max(10, pdf.numPages*6)+' 秒左右。\n\n确定开始识别吗?');
if(!ok){ setRepProgress(''); repPdfBusy=false; return; }
ocrResult = await ocrPdfPages(pdf);
total = ocrResult.text;
if(total.replace(/\s/g,'').length < 30){
alert('OCR 后仍未识别到有效文字。可能是图片质量过低、非中文报告,或模型下载失败。\n请改用「报告转txt.py」导出页面图片后用本地 OCR 识别,再粘贴文本。');
return;
}
}
document.getElementById('rep-input').value = total;
parseReport();
if(ocrResult){
setRepProgress('OCR 识别完成:'+ocrResult.count+'/'+pdf.numPages+' 页。下方列表可逐条校对。');
}else if(emptyPages>0){
setRepProgress('已提取 '+(pdf.numPages-emptyPages)+'/'+pdf.numPages+' 页文字,其中 '+emptyPages+' 页为扫描图未提取到。');
}else{
setRepProgress('');
}
}catch(e){
alert('PDF解析失败:'+(e && e.message ? e.message : e)+'\n若反复失败,改用「报告转txt.py」转换。');
}finally{
repPdfBusy = false;
}
});
inp.value = '';
}
function cleanFragText(s){
return s.replace(/片段\s*\d+/g,'')
.replace(/(AIGC|AI特征|疑似AI|AI生成|人工特征|人工痕迹)[^\n\d]{0,6}\d+(\.\d+)?%?/g,'')
.replace(/0\.\d{3,4}/g,'')
.replace(/\d+(\.\d+)?\s*%/g,'')
.replace(/-{2,}/g,'')
.replace(/[ \t]+/g,' ')
.replace(/\s*\n\s*/g,' ')
.trim();
}
function fragScoreOf(block){
const aigcM = block.match(/0\.\d{3,4}/);
if(aigcM) return +((1 - parseFloat(aigcM[0]))*100).toFixed(1);
const pm = block.match(/(人工特征|人工痕迹|人工率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
if(pm) return +pm[2];
const am = block.match(/(AI特征|疑似AI|AIGC率?|AI率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/);
if(am) return +(100 - +am[2]).toFixed(1);
return null;
}
function readFragInputs(){
if(!repParsed) return;
document.querySelectorAll('.frag-human').forEach(inp=>{
const i = +inp.getAttribute('data-i'); const v = parseFloat(inp.value);
repParsed.frags[i].human = isNaN(v)? null : Math.max(0, Math.min(100, v));
});
document.querySelectorAll('.frag-text').forEach(t=>{
repParsed.frags[+t.getAttribute('data-i')].text = t.value.trim();
});
}
function parseReport(){
const raw = document.getElementById('rep-input').value.trim();
if(!raw){ alert('请先粘贴检测报告文本'); return }
let human = null, ai = null, m;
if((m = raw.match(/(人工特征|人工痕迹|人工成分|人工率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/))) human = +m[2];
if((m = raw.match(/(AI特征|AI生成|疑似AI|AIGC率?|AI率)[^\d%]{0,8}(\d+(?:\.\d+)?)\s*%/))) ai = +m[2];
if(human===null && ai!==null) human = +(100-ai).toFixed(2);
if(ai===null && human!==null) ai = +(100-human).toFixed(2);
// 片段切分:优先按「片段N」标记,否则按空行分块;均要求带数值且文本≥30字
let blocks = [];
const markerN = (raw.match(/片段\s*\d+/g)||[]).length;
const parts = markerN>=2 ? raw.split(/(?=片段\s*\d+)/) : raw.split(/\n\s*\n/);
parts.forEach(pt=>{
pt = pt.trim(); if(!pt) return;
const score = fragScoreOf(pt);
if(score===null) return;
const txt = cleanFragText(pt);
if(txt.length < 30) return;
blocks.push({text:txt, human:score});
});
repParsed = {human, ai, frags:blocks, ts:Date.now()};
repMatched = null;
document.getElementById('rep-analysis-card').style.display = 'none';
document.getElementById('rep-analysis').innerHTML = '';
renderRepParsed();
if(!blocks.length){
alert('未识别到带数值的片段。可在下方「+手动补片段」逐条录入片段文本和人工特征%,或检查报告格式(片段需带 AIGC 0.xxxx 或 百分比 数值)。');
}
// 按文本重叠度自动预选最像的版本
if(blocks.length && versions.length){
let best=null, bestR=-1;
versions.forEach(v=>{
const paras = splitParas(v.text).map(normText);
let s=0, n=0;
blocks.slice(0,5).forEach(f=>{
const fn = normText(f.text);
if(fn.length<12) return;
let mx=0;
paras.forEach(pn=>{
let hits=0, tot=0;
for(let i=0;i+6<=fn.length;i+=3){ tot++; if(pn.includes(fn.substr(i,6))) hits++; }
mx = Math.max(mx, tot? hits/tot : 0);
});
s += mx; n++;
});
const r = n? s/n : 0;
if(r>bestR){ bestR=r; best=v }
});
if(best && bestR>=0.15){
repSelVersion = best;
renderRepVersions();
alert('已自动勾选最匹配的版本(平均重叠 '+Math.round(bestR*100)+'%,风险分 '+best.total+')。如不对请在②中改选。');
}
}
}
function renderRepParsed(){
const el = document.getElementById('rep-parse');
if(!repParsed){ el.innerHTML=''; return }
const p = repParsed;
let html = '<div class="adv-card"><div class="adv-head">解析结果</div><div class="adv-body">'
+ '总体:'+(p.human!==null? '人工特征 <b>'+p.human+'%</b>' : '未识别总体数值')
+ (p.ai!==null? ' | AI特征 <b>'+p.ai+'%</b>' : '')
+ ' | 识别片段 <b>'+p.frags.length+'</b> 个(数值与文本均可修改;数值填该片段的人工特征%)'
+ '</div></div>';
html += p.frags.map((f,i)=>
'<div class="rep-frag"><div class="row"><span class="muted" style="font-weight:700">片段'+(i+1)+'</span>'
+ '<span class="muted">人工特征%:</span><input class="rate-input frag-human" data-i="'+i+'" value="'+(f.human===null?'':f.human)+'" placeholder="如 46.9">'
+ '<span class="muted frag-match" id="frag-match-'+i+'"></span>'
+ '<button class="btn ghost small" onclick="delFrag('+i+')">删除</button></div>'
+ '<textarea class="frag-text" data-i="'+i+'">'+f.text+'</textarea></div>'
).join('');
el.innerHTML = html;
}
function addFragRow(){
readFragInputs();
if(!repParsed) repParsed = {human:null, ai:null, frags:[], ts:Date.now()};
repParsed.frags.push({text:'', human:null});
renderRepParsed();
}
function delFrag(i){
readFragInputs();
repParsed.frags.splice(i,1);
renderRepParsed();
}
/* ---- ③ 对比分析 ---- */
function overlapRatio(fn, pn){
if(fn.length<6) return 0;
let hits=0, tot=0;
for(let i=0;i+6<=fn.length;i+=3){ tot++; if(pn.includes(fn.substr(i,6))) hits++; }
return tot? hits/tot : 0;
}
function runRepCompare(){
if(!repParsed || !repParsed.frags.length){ alert('请先在①导入并解析报告(或手动补片段)'); return }
if(!repSelVersion){ alert('请先在②勾选报告对应的自查版本'); return }
readFragInputs();
const v = repSelVersion;
const a = analyzeText(v.text);
const matched = repParsed.frags.map((f, fi)=>{
const fn = normText(f.text);
let best=null, bestR=0;
if(fn.length>=10){
a.results.forEach(r=>{
const ratio = overlapRatio(fn, normText(r.text));
if(ratio>bestR){ bestR=ratio; best=r }
});
}
return {frag:f, fi, res: bestR>=0.15? best : null, ratio:bestR};
});
matched.forEach(x=>{
const tag = document.getElementById('frag-match-'+x.fi);
if(tag) tag.innerHTML = x.res? '→ 对齐段'+x.res.idx+'(重叠'+Math.round(x.ratio*100)+'%)' : '<span style="color:var(--red)">未对齐</span>';
});
// 回写段落级朱雀分(规则库「朱雀均分」列因此变准)
let updatedZq = 0;
matched.forEach(x=>{
if(x.res && x.frag.human!==null){ zhuquePara[x.res.hash] = {score:x.frag.human, ts:Date.now()}; updatedZq++; }
});
save('zhq_zhuque_para', zhuquePara);
// 规则判别力统计:命中段的平均AI值 vs 全部对齐段平均AI值
const withScore = matched.filter(x=>x.res && x.frag.human!==null);
const baseAvg = withScore.length? withScore.reduce((s,x)=>s+(100-x.frag.human),0)/withScore.length : null;
const ruleStats = RULES.map(r=>{
const hitList = withScore.filter(x=>x.res.hits.some(h=>h.rule.id===r.id));
if(!hitList.length) return {rule:r, nHit:0, avgHit:null, lift:null};
const avgHit = hitList.reduce((s,x)=>s+(100-x.frag.human),0)/hitList.length;
return {rule:r, nHit:hitList.length, avgHit, lift: baseAvg!==null? avgHit-baseAvg : null};
});
const uncovered = matched.filter(x=>x.frag.human!==null && x.frag.human<50 && !(x.res && x.res.level==='high'));
const candidates = mineCandidates(matched);
repMatched = {matched, ruleStats, baseAvg, updatedZq, uncovered, candidates,
verT:v.histT, verTotal:v.total, matchedN: matched.filter(x=>x.res).length};
renderRepAnalysis();
renderRuleTable(); renderDash();
}
function mineCandidates(matched){
const highs = matched.filter(x=>x.res && x.frag.human!==null && x.frag.human<50);
const rawHigh = highs.map(x=>x.frag.text);
if(rawHigh.length<2) return [];
const highN = rawHigh.map(normText);
const lowN = normText(matched.filter(x=>x.res && x.frag.human!==null && x.frag.human>=70).map(x=>x.frag.text).join(''));
const kept = [];
for(let L=8; L>=4 && kept.length<40; L--){
for(let h=0; h<highN.length && kept.length<40; h++){
const t = highN[h].slice(0,3000);
for(let i=0; i+L<=t.length && kept.length<40; i+=2){
const s = t.substr(i,L);
if(lowN.includes(s)) continue;
if(kept.some(k=>k.text.includes(s))) continue;
let c=0; highN.forEach(x=>{ if(x.includes(s)) c++ });
if(c<2) continue;
let rc=0; rawHigh.forEach(rt=>{ if(rt.replace(/\s/g,'').includes(s)) rc++ });
if(rc<2) continue;
const covered = RULES.some(r=>{
if(!r.re) return false;
try{ return new RegExp(r.re.source).test(s) }catch(e){ return false }
});
if(!covered) kept.push({text:s, cnt:c});
}
}
}
return kept.sort((x,y)=>y.cnt-x.cnt || y.text.length-x.text.length).slice(0,6);
}
function renderRepAnalysis(){
document.getElementById('rep-analysis-card').style.display = 'block';
if(!repMatched || !repParsed){ document.getElementById('rep-analysis').innerHTML = '<div class="muted">请先在①解析报告并完成②③的对比分析。</div>'; return }
let html = '<div class="adv-card"><div class="adv-head">总览</div><div class="adv-body">'
+ '报告人工特征 '+(repParsed.human!==null? '<b>'+repParsed.human+'%</b>' : '未识别(可回①补填)')
+ ' | 版本本地风险分 <b>'+m.verTotal+'</b>'
+ ' | 片段对齐 <b>'+m.matchedN+'/'+repParsed.frags.length+'</b>'
+ ' | 段落级朱雀分已回写 <b>'+m.updatedZq+'</b> 段'
+ (repParsed.human!==null? '<br>换算校验:本地风险分 '+m.verTotal+' vs 报告AI率 '+(100-repParsed.human).toFixed(1)+'% '
+ (Math.abs(m.verTotal-(100-repParsed.human))<15? ',接近,换算关系基本可靠' : ',偏差较大,继续积累校准点') : '')
+ '<div style="margin-top:6px"><button class="btn small" onclick="writeZqToHistory()">写入历史校准点(把报告人工分记到该版本的检测记录)</button></div>'
+ '</div></div>';
// 段落对齐明细(漏检排最前)
const rank = {miss:0, both:1, falsepos:2, bothgreen:3, nodata:4, noalign:5};
const rows = m.matched.map(x=>{
let k;
if(!x.res) k = 'noalign';
else if(x.frag.human===null) k = 'nodata';
else if(x.frag.human<50) k = x.res.level==='high'?'both':'miss';
else if(x.frag.human>=70) k = x.res.level==='high'?'falsepos':'bothgreen';
else k = 'nodata';
return {x, k};
}).sort((p,q)=>rank[p.k]-rank[q.k]);
html += '<div class="muted" style="margin:10px 0 4px">段落对齐明细(漏检排最前,优先改):</div>';
html += rows.map(({x,k})=>{
const f = x.frag;
const hTxt = f.human===null? '未填' : f.human+'%';
const hCol = f.human===null? 'var(--sub)' : (f.human<50?'var(--red)':f.human>=70?'var(--green)':'var(--yellow)');
const local = x.res? '段'+x.res.idx+' · '+x.res.score+'分('+({high:'红',mid:'黄',low:'绿'}[x.res.level])+')' : '未对齐';
const agree = {
miss:'<span style="color:var(--red);font-weight:700">漏检:报告红、本地未红</span>',
both:'<span style="color:var(--green);font-weight:700">双红:规则已抓到</span>',
falsepos:'<span style="color:var(--yellow);font-weight:700">误报:本地红、报告绿</span>',
bothgreen:'<span style="color:var(--green)">双绿</span>',
nodata:'数值缺失', noalign:'<span style="color:var(--red)">未对齐版本段落(可删掉该片段或改选版本)</span>'}[k];
const tags = x.res? (x.res.hits.slice(0,4).map(h=>h.rule.name).join('、')||'无命中') : '-';
return '<div class="adv-card" style="border-left:4px solid '+((k==='miss'||k==='noalign')?'var(--red)':(k==='both'?'var(--red)':(k==='falsepos'?'var(--yellow)':'var(--green)')))+'">'
+ '<div class="adv-head">片段'+(x.fi+1)+' <span style="color:'+hCol+';font-weight:700">人工 '+hTxt+'</span> → '+local+'</div>'
+ '<div class="adv-body">'+agree+' | 命中规则:'+tags+'<br><span style="color:#666">'+f.text.slice(0,80)+(f.text.length>80?'…':'')+'</span></div></div>';
}).join('');
// 规则判别力与更新建议
const sugg = m.ruleStats.filter(s=>s.nHit>=2 && s.lift!==null)
.sort((x,y)=>Math.abs(y.lift)-Math.abs(x.lift)).slice(0,10);
if(sugg.length){
html += '<div class="muted" style="margin:10px 0 4px">规则判别力(命中段平均AI值 − 基准平均AI值,正得越多说明该规则真能抓AI,负得多说明误伤人写段落):</div>';
sugg.forEach(s=>{
const es = getSev(s.rule);
const adjed = ruleAdj[s.rule.id];
let sug, act='';
if(s.lift>=8){ sug='<b style="color:var(--green)">有效规则:建议升权</b>'; act='<button class="btn small" onclick="applyRuleAdj(\''+s.rule.id+'\',1)">严重度+1</button>'; }
else if(s.lift<=-8){ sug='<b style="color:var(--yellow)">判别力弱:建议降权或关闭</b>'; act='<button class="btn small" onclick="applyRuleAdj(\''+s.rule.id+'\',-1)">严重度-1</button> <button class="btn warn small" onclick="applyRuleOff(\''+s.rule.id+'\')">关闭</button>'; }
else { sug='相关性不明显,维持现状'; }
html += '<div class="adv-card"><div class="adv-head">'+s.rule.name+' <span class="muted" style="font-weight:400">(当前严重度 '+es+(adjed? ',已由 '+s.rule.sev+' 调整':'')+')</span></div>'
+ '<div class="adv-body">命中 <b>'+s.nHit+'</b> 段 | 命中段平均AI值 <b>'+s.avgHit.toFixed(1)+'</b> vs 基准 <b>'+m.baseAvg.toFixed(1)+'</b>('+(s.lift>=0?'+':'')+s.lift.toFixed(1)+')→ '+sug+' '+act+'</div></div>';
});
}else{
html += '<div class="muted" style="margin:10px 0">对齐且带数值的片段不足,规则判别力统计暂不可用(至少需要2个对齐片段带人工特征%)。</div>';
}
// 未覆盖红段
if(m.uncovered.length){
html += '<div class="muted" style="margin:10px 0 4px">报告判红但规则未覆盖的片段('+m.uncovered.length+' 个,改稿优先处理):</div>';
m.uncovered.forEach(x=>{
html += '<div class="para-ref" style="border-color:var(--red);background:var(--red-bg);color:#8a1c21">'+x.frag.text.slice(0,120)+(x.frag.text.length>120?'…':'')+'</div>';
});
}
// 红段高频模式
if(m.candidates.length){
html += '<div class="muted" style="margin:10px 0 4px">红段高频固定模式(自动挖掘:在多个红段重复出现、绿段没有,且现有规则未覆盖;人工确认后可加入规则库):</div>';
m.candidates.forEach((c,i)=>{
const added = customRules.some(r=>r.reSource===escapeRe(c.text));
html += '<div class="adv-card"><div class="adv-head">「'+c.text+'」</div><div class="adv-body">出现在 '+c.cnt+' 个红段 '
+ (added? '<span style="color:var(--green);font-weight:700">(已在规则库)</span>' : '<button class="btn small" onclick="addCustomRule('+i+')">加入规则库</button>')
+ '</div></div>';
});
}
document.getElementById('rep-analysis').innerHTML = html;
}
function applyRuleAdj(id, delta){
const cur = getSev(RULES.find(r=>r.id===id) || {sev:3});
const next = Math.max(1, Math.min(5, cur+delta));
ruleAdj[id] = {sev:next, ts:Date.now(), note:'报告对比调整'};
save('zhq_rule_adj', ruleAdj);
renderRuleTable(); renderRepAnalysis();
}
function applyRuleOff(id){
rulesOff.add(id); save('zhq_rules_off', [...rulesOff]);
renderRuleTable(); renderRepAnalysis();
}
function addCustomRule(i){
const c = repMatched && repMatched.candidates[i];
if(!c) return;
const esc = escapeRe(c.text);
if(customRules.some(r=>r.reSource===esc)){ return }
const cr = {id:'c'+Date.now(), name:'自动挖掘·'+c.text.slice(0,6), cat:'自动挖掘', sev:3,
reSource:esc, desc:'红段高频模式「'+c.text+'」('+c.cnt+'个红段出现)', advice:'改写时优先处理该固定模式'};
customRules.push(cr); save('zhq_custom_rules', customRules);
RULES.push(Object.assign({}, cr, {re:new RegExp(esc,'g')}));
renderRuleTable(); renderRepAnalysis();
}
function writeZqToHistory(){
if(repParsed.human===null){ alert('报告未识别总体人工特征率,无法写入(可在①区补填后重新对比)'); return }
const h = history.find(x=>x.t===repMatched.verT);
if(!h){ alert('未找到该版本对应的检测记录(历史可能已清空);段落级朱雀分已更新。'); return }
h.zq = repParsed.human; save('zhq_history', history);
renderHistory(); renderDash();
alert('已写入:该版本的检测记录关联朱雀人工分 '+repParsed.human+'%');
}
/* init */
renderStages(); renderRuleTable(); renderHistory(); renderDash(); renderFavs(); renderRepVersions();
</script>
</body>
</html>
2013

被折叠的 条评论
为什么被折叠?



