双语内容审阅台怎么做?用内容指纹保留核验记录

0 阅读10分钟

多语言内容审阅需要同时回答两个问题:当前稿件引用了什么资料,以及现有人工批准是否对应这一份内容。本文用原生 JavaScript 实现一个本地双语审阅台,演示来源缺失、补齐记录、分别批准和内容变动后的状态变化。

全部产品与来源都是虚构教学记录,界面的批准也只是模拟操作。程序检查结构与版本一致性,不判断自然语言真假,不验证审阅者身份,也不调用 AI 或发布接口。下面八个文件包含完整实现和测试,截图由同一套代码实际生成。

把稿件、资料和人工记录分开

稿件保存 title、body、locale 和 refs;资料保存具体值、出处、版本和复核期限;人工记录保存目标指纹、演示审阅人和时间。输出再给出缺项及 ready。

ready 表示本例的记录检查通过,界面保留这项限制说明,避免把记录齐全误解为事实已自动验证。真实流程需要人在批准前判断来源是否支持表述。Google 的 AI 内容指导强调人工核查;本文的数据模型和界面是自己的教学实现。

运行全部代码

准备 Node.js 22 或以上,把八个文件放在同一目录:

npm install
npx playwright install chromium
npm test
npm run test:browser
npm start

npm start 使用 http://127.0.0.1:8080。浏览器测试自行启动临时端口,无需先启动服务。模型测试不依赖第三方包;浏览器测试使用 Playwright 1.62.1。官方浏览器安装说明

本次实际环境是 Node.js 24.14.0、Playwright 1.62.1 和独立临时 Chrome,复用已安装依赖。没有验证全新网络安装或其他浏览器。自定义 BROWSER_EXECUTABLE_PATH 可用于指定已有 Chromium 系浏览器。

package.json

{"private":true,"type":"module","engines":{"node":">=22"},"scripts":{"start":"node server.mjs","test":"node --test review.test.mjs","test:browser":"node browser.test.mjs"},"devDependencies":{"playwright":"1.62.1"}}

review.mjs

export async function targetHash(draft,facts){
 const selected=[...new Set(draft.refs||[])].sort().map(id=>facts.filter(f=>f.id===id).map(f=>({id:f.id,value:f.value,ref:f.ref,version:f.version,until:f.until})));
 const bytes=new TextEncoder().encode(JSON.stringify({locale:draft.locale,title:draft.title,body:draft.body,refs:draft.refs,facts:selected}));
 return [...new Uint8Array(await crypto.subtle.digest('SHA-256',bytes))].map(b=>b.toString(16).padStart(2,'0')).join('');
}
export async function check(draft,facts,review,now){
 const issues=[];const add=(code,id='')=>issues.push({code,id});
 if(!Number.isFinite(Date.parse(now)))throw Error('INVALID_CHECK_TIME');
 if(!draft.title?.trim()||!draft.body?.trim()||!draft.locale?.trim())add('INCOMPLETE_DRAFT');
 if(!Array.isArray(draft.refs)||!draft.refs.length)add('NO_REFERENCES');
 if(facts.some((f,i)=>facts.findIndex(x=>x.id===f.id)!==i))add('DUPLICATE_FACT_ID');
 for(const id of new Set(draft.refs||[])){
  const f=facts.find(x=>x.id===id);if(!f){add('MISSING_FACT',id);continue;}
  if(!f.value?.trim()||!f.ref?.trim()||!f.version?.trim())add('INCOMPLETE_SOURCE',id);
  if(!Number.isFinite(Date.parse(f.until)))add('INVALID_EXPIRY',id);
  else if(Date.parse(f.until)<Date.parse(now))add('EXPIRED_SOURCE',id);
 }
 const hash=await targetHash(draft,facts);
 const matched=review?.decision==='approved'&&!!review.reviewer?.trim()&&review.target===hash&&Number.isFinite(Date.parse(review.at))&&Date.parse(review.at)<=Date.parse(now);
 if(!matched)add(review?'STALE_OR_INVALID_REVIEW':'NO_HUMAN_REVIEW');
 return {ready:issues.length===0,issues,target:hash,review:review||null,checkedAt:now};
}
export async function recordReview(draft,facts,reviewer,at){
 if(!reviewer?.trim()||!Number.isFinite(Date.parse(at)))throw Error('INVALID_REVIEW');
 return {decision:'approved',reviewer:reviewer.trim(),at,target:await targetHash(draft,facts)};
}

fixtures.mjs

// Fictional records; not company specifications, source URLs or actual human approval.
export const CHECK_TIME='2026-10-23T00:00:00Z';
export function fixture(){return {
 facts:[
  {id:'interface',value:'Demo-A / RS485',ref:'demo://datasheet-A',version:'v1',until:'2026-12-31T23:59:59Z'},
  {id:'support',value:'Demo support scope needs confirmation',ref:'',version:'v1',until:'2026-12-31T23:59:59Z'}
 ],
 drafts:{
  en:{locale:'en',title:'Demo-A interface notes',body:'The teaching record lists RS485 for Demo-A. Confirm the project conditions separately.',refs:['interface']},
  es:{locale:'es',title:'Notas sobre Demo-A',body:'El ejemplo incluye RS485. El alcance del soporte sigue pendiente de confirmación.',refs:['interface','support']}
 },reviews:{}
};}

变更使旧批准失效,不删除旧人工记录

标题与正文的输入事件更新当前稿件,自动取消本次确认勾选,再重新检查指纹。资料升级也触发检查。旧批准保存在 reviews 中,屏幕可以明确显示它已不匹配,而不用覆盖历史来伪装“始终通过”。

不同语言各有自己的 review。英语批准不能借给西语稿件;西语引用资料尚缺出处时,即使点击确认,也不能记录一份可交付状态。补齐来源之后仍要进行本语言的模拟人工审阅。

render 用递增序号处理异步检查,较早的指纹计算返回后不会覆盖较新的界面。批准操作还会捕获当前稿件与资料副本,等待期间若状态改变,则不把结果写回。这是单页面的异步结果保护,不是服务端并发控制。

index.html

<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>内容审阅记录 · 本地教学</title>
<style>*{box-sizing:border-box}body{margin:0;background:#f3eee6;color:#30383d;font:16px/1.6 system-ui}main{max-width:980px;margin:auto;padding:30px 22px}h1{font-size:30px}section{background:white;padding:22px;border:1px solid #d6cbb9;border-radius:12px;margin:20px 0}label{display:block;margin:12px 0}input,textarea,select,button{font:inherit;max-width:100%;padding:9px}input,textarea{width:100%;border:1px solid #aaa}textarea{height:128px}button{background:#334856;color:white;border:0;border-radius:7px;margin:6px 10px 6px 0;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}:focus-visible{outline:3px solid #c27a26;outline-offset:3px}.badge{padding:14px;background:#f9edda;font-weight:bold}.ready{background:#e0eddb}.scroll{overflow-x:auto}table{border-collapse:collapse;min-width:650px;width:100%}th,td{text-align:left;border-bottom:1px solid #ddd;padding:9px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eef0f2;padding:15px}small{color:#56626b}@media(max-width:500px){main{padding:20px 14px}h1{font-size:25px}section{padding:15px}}</style>
<main><small>GIZZAP / CONTENT REVIEW LAB / EP15</small><h1>内容可以生成,核验记录要对得上</h1><p>虚构教学数据;固定检查时间 2026-10-23。程序不判断文意真假,不调用AI或发布接口。</p>
<section><label for="locale">当前稿件 <select id="locale"><option value="en">English</option><option value="es">Español</option></select></label><label for="title">标题 <input id="title"></label><label for="body">正文 <textarea id="body"></textarea></label><div id="status" class="badge" role="status">正在检查</div><ul id="issues"></ul></section>
<section><h2>来源与事实记录(虚构)</h2><div class="scroll"><table><thead><tr><th scope="col">编号</th><th scope="col">事实值</th><th scope="col">来源与版本</th></tr></thead><tbody id="facts"></tbody></table></div><button id="repair">补齐演示来源</button><button id="bump">升级接口资料版本</button></section>
<section><h2>模拟人工审阅</h2><p>先由人判断来源是否支持表述,再记录批准;本例没有用户身份认证。</p><label for="reviewer">演示审阅人 <input id="reviewer" value="Demo reviewer"></label><label><input id="confirmed" type="checkbox" style="width:auto"> 我已核对当前演示稿与所引资料(模拟操作)</label><button id="approve" disabled>记录本稿人工审阅</button><p id="notice" role="status"></p><h2>当前检查与保留的审阅记录</h2><pre id="record"></pre></section></main><script type="module" src="/app.mjs"></script></html>

app.mjs

import {check,recordReview} from './review.mjs';import {fixture,CHECK_TIME} from './fixtures.mjs';
const s=fixture(),$=id=>document.getElementById(id);let locale='en',serial=0;
async function render(){const run=++serial,draft=structuredClone(s.drafts[locale]),result=await check(draft,s.facts,s.reviews[locale],CHECK_TIME);if(run!==serial)return;
 $('status').textContent=result.ready?'记录检查通过;不代表事实已被程序判真':'暂不能交付:需要处理来源或审阅记录';$('status').className='badge'+(result.ready?' ready':'');
 $('issues').replaceChildren(...result.issues.map(x=>{const li=document.createElement('li');li.textContent=x.code+(x.id?' · '+x.id:'');return li;}));
 $('facts').replaceChildren(...s.facts.map(f=>{const tr=document.createElement('tr');for(const value of [f.id,f.value,(f.ref||'来源待补')+' / '+f.version]){const td=document.createElement('td');td.textContent=value;tr.append(td);}return tr;}));
 $('record').textContent=JSON.stringify({scope:'fictional-record-consistency-only',locale,result},null,2);
 $('approve').disabled=!$('confirmed').checked||!$('reviewer').value.trim()||result.issues.some(x=>!['NO_HUMAN_REVIEW','STALE_OR_INVALID_REVIEW'].includes(x.code));
}
function load(){const d=s.drafts[locale];$('title').value=d.title;$('body').value=d.body;$('confirmed').checked=false;$('notice').textContent='';render();}
$('locale').addEventListener('change',()=>{locale=$('locale').value;load();});
for(const id of ['title','body'])$(id).addEventListener('input',()=>{s.drafts[locale][id]=$(''+id).value;$('confirmed').checked=false;$('notice').textContent='稿件已变更;保留旧审阅记录,需重新核对。';render();});
for(const id of ['confirmed','reviewer'])$(id).addEventListener('input',render);
$('repair').addEventListener('click',()=>{s.facts[1].ref='demo://support-note';$('confirmed').checked=false;render();});
$('bump').addEventListener('click',()=>{s.facts[0].version='v'+(Number(s.facts[0].version.slice(1))+1);$('confirmed').checked=false;render();});
$('approve').addEventListener('click',async()=>{const draft=structuredClone(s.drafts[locale]),facts=structuredClone(s.facts),targetLocale=locale,run=serial;const review=await recordReview(draft,facts,$('reviewer').value,CHECK_TIME);if(run!==serial||targetLocale!==locale)return;s.reviews[locale]=review;$('notice').textContent='已记录当前版本的模拟人工审阅。';await render();});
load();

server.mjs

import http from 'node:http';
import fs from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
const files={'/':'index.html','/app.mjs':'app.mjs','/fixtures.mjs':'fixtures.mjs','/review.mjs':'review.mjs'};
export function start(port=8080){const server=http.createServer(async(req,res)=>{
 const file=files[req.url];if(req.method!=='GET'||!file){res.writeHead(404);res.end();return;}
 try{const bytes=await fs.readFile(new URL(file,import.meta.url));res.writeHead(200,{'Content-Type':file.endsWith('.mjs')?'text/javascript; charset=utf-8':'text/html; charset=utf-8','Cache-Control':'no-store'});res.end(bytes);}catch{res.writeHead(500);res.end();}
});return new Promise(resolve=>server.listen(port,'127.0.0.1',()=>resolve(server)));}
if(process.argv[1]===fileURLToPath(import.meta.url)){const server=await start();console.log('http://127.0.0.1:'+server.address().port);}

把重要交互变成实际检查

模型测试覆盖记录和指纹的失效条件;浏览器测试验证键盘操作、语言切换、来源补齐和手机布局。控件不随检查结果重新挂载,状态采用 role=status,资料表有列标题,输出通过 textContent 显示。

review.test.mjs

import test from 'node:test';import assert from 'node:assert/strict';
import {check,recordReview,targetHash} from './review.mjs';import {fixture,CHECK_TIME as now} from './fixtures.mjs';
const approved=async()=>{const s=fixture();s.reviews.en=await recordReview(s.drafts.en,s.facts,'Demo reviewer',now);return s;};
const codes=r=>r.issues.map(x=>x.code);
test('no review is never ready',async()=>{const s=fixture();assert(codes(await check(s.drafts.en,s.facts,null,now)).includes('NO_HUMAN_REVIEW'));});
test('current review plus complete records is ready',async()=>{const s=await approved();assert((await check(s.drafts.en,s.facts,s.reviews.en,now)).ready);});
for(const [name,change] of [
 ['body',s=>s.drafts.en.body+=' Changed.'],['title',s=>s.drafts.en.title+=' Changed'],
 ['locale',s=>s.drafts.en.locale='fr'],['source version',s=>s.facts[0].version='v2'],
 ['fact value',s=>s.facts[0].value='Demo-A / Ethernet'],['source reference',s=>s.facts[0].ref='demo://new'],
 ['expiry',s=>s.facts[0].until='2027-01-01T00:00:00Z']
])test(name+' invalidates the old review',async()=>{const s=await approved();change(s);assert(codes(await check(s.drafts.en,s.facts,s.reviews.en,now)).includes('STALE_OR_INVALID_REVIEW'));});
test('unrelated fact change preserves the English review',async()=>{const s=await approved();s.facts[1].version='v2';assert((await check(s.drafts.en,s.facts,s.reviews.en,now)).ready);});
test('Spanish cannot reuse English review',async()=>{const s=await approved();assert(!(await check(s.drafts.es,s.facts,s.reviews.en,now)).ready);});
test('missing source blocks even a matching review',async()=>{const s=fixture(),r=await recordReview(s.drafts.es,s.facts,'Demo',now);assert(codes(await check(s.drafts.es,s.facts,r,now)).includes('INCOMPLETE_SOURCE'));});
test('expired source blocks',async()=>{const s=fixture();s.facts[0].until='2026-01-01';assert(codes(await check(s.drafts.en,s.facts,null,now)).includes('EXPIRED_SOURCE'));});
test('invalid expiry is not silently accepted',async()=>{const s=fixture();s.facts[0].until='unknown';assert(codes(await check(s.drafts.en,s.facts,null,now)).includes('INVALID_EXPIRY'));});
test('missing fact blocks',async()=>{const s=fixture();s.drafts.en.refs=['absent'];assert(codes(await check(s.drafts.en,s.facts,null,now)).includes('MISSING_FACT'));});
test('duplicate fact ID blocks ambiguous lookup',async()=>{const s=fixture();s.facts.push({...s.facts[0]});assert(codes(await check(s.drafts.en,s.facts,null,now)).includes('DUPLICATE_FACT_ID'));});
test('empty body and references block',async()=>{const s=fixture();s.drafts.en.body='';s.drafts.en.refs=[];const c=codes(await check(s.drafts.en,s.facts,null,now));assert(c.includes('INCOMPLETE_DRAFT'));assert(c.includes('NO_REFERENCES'));});
test('future review is invalid',async()=>{const s=await approved();s.reviews.en.at='2027-01-01';assert(!(await check(s.drafts.en,s.facts,s.reviews.en,now)).ready);});
test('review requires named reviewer',async()=>{const s=fixture();await assert.rejects(()=>recordReview(s.drafts.en,s.facts,'',now));});
test('input order of unrelated facts does not alter target',async()=>{const s=fixture();assert.equal(await targetHash(s.drafts.en,s.facts),await targetHash(s.drafts.en,[...s.facts].reverse()));});
test('check does not mutate content or reviews',async()=>{const s=await approved(),before=JSON.stringify(s);await check(s.drafts.en,s.facts,s.reviews.en,now);assert.equal(JSON.stringify(s),before);});

browser.test.mjs

import assert from 'node:assert/strict';import fs from 'node:fs/promises';import {chromium} from 'playwright';import {start} from './server.mjs';
const server=await start(0),origin='http://127.0.0.1:'+server.address().port;let browser;const checks=[];
try{browser=await chromium.launch({headless:true,...(process.env.BROWSER_EXECUTABLE_PATH?{executablePath:process.env.BROWSER_EXECUTABLE_PATH}:{})});const page=await browser.newPage({viewport:{width:1100,height:1100}}),errors=[],external=[];page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>{if(!r.url().startsWith(origin))external.push(r.url());});
 await page.goto(origin);await page.waitForFunction(()=>document.querySelector('#record').textContent.includes('NO_HUMAN_REVIEW'));assert(await page.locator('#approve').isDisabled());checks.push('未经人工操作不标为通过');
 await page.check('#confirmed');await page.waitForFunction(()=>!document.querySelector('#approve').disabled);await page.locator('#approve').focus();await page.keyboard.press('Enter');await page.waitForFunction(()=>JSON.parse(document.querySelector('#record').textContent).result.ready);assert.equal(await page.evaluate(()=>document.activeElement?.id),'approve');const old=JSON.parse(await page.locator('#record').innerText()).result.review;checks.push('键盘批准当前版本且保留焦点');
 await fs.mkdir('artifacts',{recursive:true});await page.screenshot({path:'artifacts/review-en-approved.png',fullPage:true});
 const body=await page.inputValue('#body');await page.fill('#body',body+' Changed.');await page.waitForFunction(()=>document.querySelector('#issues').textContent.includes('STALE_OR_INVALID_REVIEW'));assert.deepEqual(JSON.parse(await page.locator('#record').innerText()).result.review,old);checks.push('改稿后失效但保留旧人工记录');
 await page.fill('#body',body);await page.waitForFunction(()=>JSON.parse(document.querySelector('#record').textContent).result.ready);await page.selectOption('#locale','es');await page.waitForFunction(()=>document.querySelector('#issues').textContent.includes('INCOMPLETE_SOURCE'));assert(await page.locator('#approve').isDisabled());assert.equal(JSON.parse(await page.locator('#record').innerText()).result.review,null);checks.push('西语缺来源且不借用英语批准');await page.screenshot({path:'artifacts/review-es-blocked.png',fullPage:true});
 await page.click('#repair');await page.waitForFunction(()=>!document.querySelector('#issues').textContent.includes('INCOMPLETE_SOURCE'));assert.match(await page.locator('#issues').innerText(),/NO_HUMAN_REVIEW/);await page.check('#confirmed');await page.waitForFunction(()=>!document.querySelector('#approve').disabled);await page.click('#approve');await page.waitForFunction(()=>JSON.parse(document.querySelector('#record').textContent).result.ready);checks.push('补记录后仍须本语言人工批准');
 await page.selectOption('#locale','en');await page.waitForFunction(()=>JSON.parse(document.querySelector('#record').textContent).result.ready);await page.click('#bump');await page.waitForFunction(()=>document.querySelector('#issues').textContent.includes('STALE_OR_INVALID_REVIEW'));assert.deepEqual(JSON.parse(await page.locator('#record').innerText()).result.review,old);checks.push('相关来源升级后批准失效,未被自动覆盖');
 await page.setViewportSize({width:390,height:844});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));await page.screenshot({path:'artifacts/review-mobile.png',fullPage:true});checks.push('手机整页不横向溢出');
 assert.equal((await fetch(origin+'/review.mjs',{method:'POST'})).status,404);assert.equal((await fetch(origin+'/unknown')).status,404);assert.deepEqual(errors,[]);assert.deepEqual(external,[]);checks.push('无外部生成发布请求,服务器拒绝写入和未知文件');
 await fs.writeFile('artifacts/browser-results.json',JSON.stringify({passed:checks.length,checks},null,2));console.log(JSON.stringify({passed:checks.length,checks}));
}finally{await browser?.close();await new Promise(resolve=>server.close(resolve));}

同源运行结果

实际运行通过 21 项模型测试和 8 项浏览器检查。浏览器脚本先在英语稿上模拟人工审阅,随后修改正文验证失效,再切到西语查看缺来源状态;补齐演示来源并单独批准后,再测试资料升级使批准失效。

英语演示稿的人工记录与当前内容指纹一致,界面同时说明不代表程序判真

这张图显示英语当前版本的模拟审阅结果。资料出处以 demo:// 标明虚构身份,没有伪装成真实企业证据。

西语演示稿缺少服务来源,系统显示待处理且不继承英语人工批准

这张图对应尚未补齐来源的西语稿。手机另外以 390×844 视口检查整页不横向溢出,表格可在自己的区域内滚动。字体与原生控件会随系统变化,跨系统复现应核对状态和字段,不要求像素完全一致。

本地演示与正式审阅流程的边界

指纹证明的是输入对象是否一致,不能证明审阅人确实看过资料。审阅记录在内存中,刷新即重置,也没有账户权限、审计数据库或防篡改签名。页面只提供虚构样本操作,不接收外部“批准结果”,更没有自动发布功能。

生产使用时,还需要可信身份、授权记录、持久化历史,以及交付前对同一版本的再次核对。摘要和图片替代文本等字段也应按实际范围进入检查,不能把本例的 title/body 范围当作完整内容质量保证。

这个界面的价值,是让团队看见事实材料、待确认项和旧人工记录之间的关系。自动检查不替人作结论,明确的状态能帮助人把核验落实到当前版本。