归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped

This commit is contained in:
zqlit committed 2026-10-04 08:45:40 +08:00
1 parent 855a001046
commit a74b3c7127
98 files changed
+15657

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
// 在「真实 Artalk 评论区」里逐状态截图:包装页 iframe 里是 /comment.html
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9385;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/human-demo.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/artalk-human.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-atk-human-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1100', 'about:blank'], { stdio: 'ignore' });
const PROBE = "JSON.stringify((()=>{const f=document.getElementById('fr');const d=f&&f.contentDocument;if(!d)return 'no-iframe-doc';const w=d.querySelector('.atk-human-box');const ed=d.querySelector('.atk-main-editor');const r=(e)=>{if(!e)return null;const b=e.getBoundingClientRect();return {x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height)};};const im=w?w.querySelector('.atk-human-img'):null;return {box:w?{cls:w.className,text:w.textContent.trim(),rect:r(w),imgW:im?im.naturalWidth:0}:null,editor:r(ed),hasHook:typeof f.contentWindow.__humanCheck};})())";
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?` + encodeURIComponent(URL_), { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (m, p = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method: m, params: p })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
// 等 iframe 里的评论页 + Artalk 编辑器渲染出来
for (let i = 0; i < 30; i++) {
const p = await ev(PROBE);
if (p && p.indexOf('"editor":{') >= 0) { console.log('— 评论区已就绪(' + (i * 0.5).toFixed(1) + 's)'); break; }
if (i === 29) console.log('— 评论区未就绪,最后探测:', p);
await sleep(500);
}
await sleep(4500); // 等页面自己的静默验证跑完,避免它的 drop 把我的控件带走
console.log('— 初始(正常读者):', await ev(PROBE));
let s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-none.png'), Buffer.from(s.result.data, 'base64'));
await ev("call('click')");
await sleep(700);
console.log('— 点一下状态:', await ev(PROBE));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-click.png'), Buffer.from(s.result.data, 'base64'));
await ev("call('captcha')");
await sleep(2200);
console.log('— 图形验证码状态:', await ev(PROBE));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
await ev("call('run')");
await sleep(5000);
console.log('— 真实验证后:', await ev(PROBE));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-run.png'), Buffer.from(s.result.data, 'base64'));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+66
View File
@@ -0,0 +1,66 @@
// 验证卡片评论的自愈逻辑:即使 Artalk 重渲染把 .atk-main 的内联 display 清掉,
// 只靠 class + !important 的 CSS 规则也仍然隐藏(不再把卡片挤下去)
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9377;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-selfheal-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', `--window-size=${process.argv[4] || '1280,900'}`, 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const holder = document.querySelector('.atk-fl-card-wrap');
const card = document.querySelector('.atk-fl-card');
const wrap = document.querySelector('.atk-comment-wrap.atk-fl-apply');
const firstCmt = document.querySelector('.atk-list-comments-wrap > .atk-comment-wrap');
const r = (el) => { if (!el) return null; const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; };
return JSON.stringify({
holderParent: holder && holder.parentNode ? holder.parentNode.className : null,
holderIsFirstChild: holder && holder.parentNode ? holder.parentNode.firstElementChild === holder : null,
card: r(card),
cardDisplay: card ? getComputedStyle(card).display : null,
cardCount: document.querySelectorAll('.atk-fl-card').length,
cardBorderRadiusBtn: card ? getComputedStyle(card.querySelector('.atk-fl-btn')).borderRadius : null,
transformedWrapDisplay: wrap ? getComputedStyle(wrap).display : 'no transformed comment',
firstComment: r(firstCmt),
avatarDisplay: (() => { const a = card ? card.querySelector('.atk-fl-avatar') : null; return a ? getComputedStyle(a).display : null; })(),
cardFontSize: card ? getComputedStyle(card.querySelector('.atk-fl-desc')).fontSize : null,
cardLetterSpacing: card ? getComputedStyle(card.querySelector('.atk-fl-desc')).letterSpacing : null,
cardPadding: card ? getComputedStyle(card).padding : null,
cardGap: card ? getComputedStyle(card).gap : null,
cardBadge: (() => { const b = card ? card.querySelector('.atk-badge') : null; if (!b) return null; const cs = getComputedStyle(b); return { text: b.textContent.trim(), color: cs.color, background: cs.backgroundColor, h: b.offsetHeight, padding: cs.padding, display: cs.display, fontWeight: cs.fontWeight }; })(),
}, null, 1);
})()`;
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable');
await send('Page.enable');
await sleep(5000);
// 移动端评论可能是进入视口才加载,先滚到评论区
await send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('#Comments'); if (el) el.scrollIntoView({ block: 'center' }); return 'ok'; })()`, returnByValue: true });
await sleep(3000);
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(r.result.result.value);
if (process.argv[3]) {
const shot = await send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3], Buffer.from(shot.result.data, 'base64'));
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+53
View File
@@ -0,0 +1,53 @@
// 检查友圈卡片:背景头像效果(opacity)与头像地址是否正常
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9388;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/circles.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-circles-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,900', 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const items = [...document.querySelectorAll('.fc-item')];
const info = (el) => {
if (!el) return null;
return { src: (el.getAttribute('src') || '').slice(0, 80), opacity: getComputedStyle(el).opacity, natural: el.naturalWidth };
};
const first = items[0];
return JSON.stringify({
itemCount: items.length,
bgImage: first ? info(first.querySelector('.fc-bg-image')) : null,
avatar: first ? info(first.querySelector('.fc-avatar')) : null,
relativeSrcs: items.filter((i) => (i.querySelector('.fc-avatar') || {}).getAttribute
&& (i.querySelector('.fc-avatar').getAttribute('src') || '').charAt(0) === '/').length,
}, null, 1);
})()`;
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable'); await send('Page.enable');
await sleep(7000);
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(r.result.result.value);
if (process.argv[3]) {
const shot = await send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3], Buffer.from(shot.result.data, 'base64'));
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+143
View File
@@ -0,0 +1,143 @@
// 打开评论页,抓「加载失败的请求」+ 控制台错误 + Artalk 错误层文案
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9391;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-cmterr-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,900', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map(); const net = []; const consoleMsgs = [];
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); return; }
if (m.method === 'Network.responseReceived') {
const r = m.params.response;
if (r.status >= 400 || r.url.includes('/api/')) {
net.push(`HTTP ${r.status} ${r.url.slice(0, 120)}`);
}
}
if (m.method === 'Network.loadingFailed') {
net.push(`FAIL ${m.params.errorText} ${(m.params.blockedReason || '')} type=${m.params.type}`);
}
if (m.method === 'Runtime.consoleAPICalled') {
consoleMsgs.push(`[${m.params.type}] ` + (m.params.args || []).map((a) => a.value ?? a.description ?? a.type).join(' '));
}
if (m.method === 'Runtime.exceptionThrown') {
consoleMsgs.push('[exception] ' + (m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text || ''));
}
});
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Network.enable');
await send('Page.enable');
await send('Page.navigate', { url: URL_ });
await sleep(9000);
console.log('=== 网络(>=400 或 /api/) ===');
console.log(net.length ? [...new Set(net)].slice(0, 20).join('\n') : '(无)');
console.log('\n=== 控制台 ===');
console.log(consoleMsgs.length ? consoleMsgs.slice(0, 12).join('\n') : '(无)');
console.log('\n=== 卡片计算样式(代码级验证) ===');
console.log(await ev(`(() => {
const c = document.querySelector('.atk-err-card');
if (!c) return 'NO CARD';
const cs = getComputedStyle(c);
const ic = c.querySelector('.atk-err-ic');
const t = c.querySelector('.atk-err-t');
const d = c.querySelector('.atk-err-d');
const r = c.querySelector('.atk-err-retry');
const rect = c.getBoundingClientRect();
const center = Math.abs((rect.x + rect.width / 2) - window.innerWidth / 2);
return JSON.stringify({
textAlign: cs.textAlign,
padding: cs.padding,
border: cs.borderTopWidth + ' ' + cs.borderTopStyle,
background: cs.backgroundColor,
hasDetails: !!c.querySelector('details'),
hasBodyWrap: !!c.querySelector('.atk-err-body'),
icon: ic ? getComputedStyle(ic).display + '/' + getComputedStyle(ic).fontSize : null,
titleAlign: t ? getComputedStyle(t).textAlign : null,
descMaxW: d ? getComputedStyle(d).maxWidth : null,
retryRadius: r ? getComputedStyle(r).borderRadius : null,
cardCentered: center < 8 ? 'yes(off=' + Math.round(center) + 'px)' : 'no(off=' + Math.round(center) + 'px)'
}, null, 1);
})()`));
console.log('\n=== Artalk 错误层 ===');
console.log(await ev(`(() => {
const el = document.querySelector('.atk-error-layer');
if (!el) return '(没有错误层)';
const h = el.getBoundingClientRect();
return JSON.stringify({ text: el.innerText.replace(/\\n/g, ' | '), h: Math.round(h.height), html: el.innerHTML.slice(0, 500) }, null, 1);
})()`));
if (OUT) {
const s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
// 错误卡片特写(放大 2 倍,用文档坐标 + captureBeyondViewport 才准)
const rectRaw = await ev(`(() => {
const el = document.querySelector('.atk-err-card');
if (!el) return 'null';
const r = el.getBoundingClientRect();
return JSON.stringify({
x: Math.max(0, r.x + window.scrollX - 10),
y: Math.max(0, r.y + window.scrollY - 10),
width: r.width + 20,
height: r.height + 20
});
})()`);
if (rectRaw && rectRaw !== 'null') {
const clip = JSON.parse(rectRaw);
const s2 = await send('Page.captureScreenshot', {
format: 'png', captureBeyondViewport: true, clip: { ...clip, scale: 2 },
});
writeFileSync(OUT.replace(/\.png$/, '.card.png'), Buffer.from(s2.result.data, 'base64'));
}
}
// ── 点「重新加载」→ 应触发新的一轮 /api/v2 请求(验证移动节点后事件还在)
const countApi = () => net.filter((x) => x.includes('/api/v2/')).length;
const before = countApi();
const clicked = await ev(`(() => { const b = document.querySelector('.atk-err-retry'); if (b) b.click(); return !!b; })()`);
await sleep(4500);
const after = countApi();
console.log(`\n=== 点「重新加载」 ===`);
console.log(`按钮存在=${clicked} | /api/v2 请求数 ${before} → ${after} ${after > before ? '✓ 事件仍在' : '✗ 事件丢了'}`);
console.log('错误层现状:', await ev(`(() => {
const el = document.querySelector('.atk-error-layer');
if (!el) return '(已移除)';
const c = el.querySelector('.atk-err-card');
return c ? '仍是我们重写后的卡片(重试再次失败)' : 'Artalk 原生层:' + el.innerText.slice(0, 60);
})()`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+40
View File
@@ -0,0 +1,40 @@
// 验证 svc-down 状态下 .atk-copyright 是否隐藏
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9407;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-cp-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await sleep(9000);
console.log('copyright:', await ev(`JSON.stringify((() => { const c = document.querySelector('.atk-copyright'); return c ? { display: getComputedStyle(c).display, svcDown: document.body.classList.contains('atk-svc-down') } : '(未渲染)'; })())`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+74
View File
@@ -0,0 +1,74 @@
// 验证禁用态:置灰、全子元素禁点、点击弹 toast、UIKit 入场动画
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9403;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-edoff3-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
// 状态检查
console.log(await ev(`JSON.stringify({
bodySvcDown: document.body.classList.contains('atk-svc-down'),
editorOpacity: getComputedStyle(document.querySelector('.atk-main-editor')).opacity,
barAnim: document.getElementById('atk-editor-off-bar')?.className || null,
barText: document.getElementById('atk-editor-off-bar')?.textContent?.trim()?.slice(0, 40) || null,
humanHidden: (() => { const h = document.querySelector('.atk-human-box'); return h ? getComputedStyle(h).display === 'none' : '(未渲染)'; })(),
})`));
// 坐标级真实点击编辑器工具栏区域(表情/预览按钮所在的一排)
const pos = JSON.parse(await ev(`(() => {
const ed = document.querySelector('.atk-main-editor');
const r = ed.getBoundingClientRect();
return JSON.stringify({ x: Math.round(r.x + r.width / 2), y: Math.round(r.top + 14) });
})()`));
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 });
await sleep(600);
console.log('点击后 toast:', await ev(`(() => {
const t = document.getElementById('atk-svc-toast');
return t ? { text: t.textContent.trim().slice(0, 40), visible: getComputedStyle(t).opacity !== '0' } : null;
})()`));
// 确认编辑器内没有任何输入被聚焦(真禁用)
console.log('焦点仍在 body:', await ev('document.activeElement === document.body || document.activeElement === document.documentElement'));
if (OUT) {
const s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+58
View File
@@ -0,0 +1,58 @@
// 量「重新加载按钮」与 footer border-top 的真实间距
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9409;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/ftgap.json';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-ft3-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
const out = await ev(`(() => {
const btn = document.querySelector('.atk-err-retry');
const ft = document.querySelector('.footer');
if (!btn || !ft) return JSON.stringify({ btn: !!btn, footer: !!ft });
const b = btn.getBoundingClientRect();
const f = ft.getBoundingClientRect();
return JSON.stringify({
btnBottom: Math.round(b.bottom + scrollY),
footerBorderTop: Math.round(f.top + scrollY),
gap: Math.round(f.top - b.bottom),
layerMargin: getComputedStyle(document.querySelector('.atk-error-layer')).margin
}, null, 1);
})()`);
writeFileSync(OUT, String(out));
ws.close();
} catch (e) {
writeFileSync(OUT, 'ERR ' + e.message);
} finally {
chrome.kill();
}
+71
View File
@@ -0,0 +1,71 @@
// 量错误卡片与下方 <hr> 的真实几何关系
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9395;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-hr-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,2000', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
console.log(await ev(`(() => {
const layer = document.querySelector('.atk-error-layer');
const card = document.querySelector('.atk-err-card');
const btn = document.querySelector('.atk-err-retry');
if (!card || !btn) return 'NO CARD';
const r = (el) => { const b = el.getBoundingClientRect(); return { top: Math.round(b.top + scrollY), bottom: Math.round(b.bottom + scrollY), h: Math.round(b.height) }; };
// 找卡片之后最近的 hr(含主题里的)
let hr = null, el = layer;
while (el && !hr) {
let s = el.nextElementSibling;
while (s) { if (s.tagName === 'HR') { hr = s; break; } s = s.nextElementSibling; }
el = el.parentElement;
}
const lr = r(layer), br = r(btn), cr = r(card);
const hrr = hr ? r(hr) : null;
const cs = getComputedStyle(layer);
return JSON.stringify({
layer: lr, card: cr, button: br,
hr: hrr,
hrInsideLayer: hr ? layer.contains(hr) : null,
layerMargin: cs.margin,
gapBtnToHr: (hrr && br) ? hrr.top - br.bottom : null,
overlap: (hrr && br) ? (br.bottom > hrr.top) : null,
layerParentCls: layer.parentElement ? (layer.parentElement.className || '').slice(0, 60) : null,
nextSiblings: (() => { const out = []; let s = layer.nextElementSibling; for (let i = 0; s && i < 4; i++) { out.push(s.tagName + '.' + String(s.className).slice(0, 30)); s = s.nextElementSibling; } return out; })(),
}, null, 1);
})()`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+69
View File
@@ -0,0 +1,69 @@
// 打开预览页 → 跑真实链路 → 依次预览三种控件状态并截图
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9383;
const URL_ = process.argv[2] || 'http://127.0.0.1:8791/';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/human-demo.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-demo-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=820,1400', 'about:blank'], { stdio: 'ignore' });
const PROBE_BOX = 'JSON.stringify((()=>{const w=document.querySelector(".atk-human-box");if(!w)return null;const b=w.getBoundingClientRect();const im=w.querySelector(".atk-human-img");return {cls:w.className,text:w.textContent.trim(),w:Math.round(b.width),h:Math.round(b.height),imgW:im?im.naturalWidth:0};})())';
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?` + encodeURIComponent(URL_), { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (m, p = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method: m, params: p })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(1500);
console.log('— 页面标题:', await ev('document.title'));
// 真实链路(浏览器真的算 PoW)
await ev('__demo.runReal()');
await sleep(7000);
console.log('— 真实链路日志:\n' + (await ev('document.getElementById("log").innerText')));
await ev('__demo.previewNone()');
await sleep(500);
let s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-none.png'), Buffer.from(s.result.data, 'base64'));
await ev('__demo.previewClick()');
await sleep(500);
console.log('— 点一下控件:', await ev(PROBE_BOX));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-click.png'), Buffer.from(s.result.data, 'base64'));
await ev('document.querySelector(".atk-human-box").click()');
await sleep(1400);
console.log('— 点击后:', await ev(PROBE_BOX));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-done.png'), Buffer.from(s.result.data, 'base64'));
await ev('__demo.previewCaptcha()');
await sleep(3000);
console.log('— 图形验证码:', await ev(PROBE_BOX));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+93
View File
@@ -0,0 +1,93 @@
// 人机验证前端控件自测:本地评论页加载后
// 1) 看静默流程结果(__humanCheck.state())
// 2) 手动触发「点一下验证」控件并截图
// 3) 手动触发「图形验证码」控件并截图
// 4) 收集控制台错误
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9381;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/human-widget.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-human-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1000', 'about:blank'], { stdio: 'ignore' });
const errs = [];
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
errs.push((m.params.args || []).map((a) => a.value || a.description || '').join(' ').slice(0, 160));
}
if (m.method === 'Runtime.exceptionThrown') {
errs.push('EXCEPTION ' + (m.params.exceptionDetails?.exception?.description || '').slice(0, 160));
}
});
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const evalJs = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(7000);
await evalJs(`(() => { const el = document.querySelector('#Comments') || document.querySelector('.comments-area'); if (el) el.scrollIntoView({ block: 'center' }); return 'ok'; })()`);
await sleep(2500);
console.log('— 钩子存在:', await evalJs('typeof window.__humanCheck'));
// 每 500ms 采一次状态,直到拿到通行证(用于量静默 PoW 的耗时)
const t0 = Date.now();
for (let i = 0; i < 40; i++) {
const st = await evalJs('JSON.stringify(window.__humanCheck ? window.__humanCheck.state() : null)');
if (st && st.indexOf('"hasPass":true') >= 0) {
console.log(`— 静默流程:${((Date.now() - t0) / 1000).toFixed(1)}s 后拿到通行证 ${st}`);
break;
}
if (i === 39) console.log('— 静默流程:60s 内未拿到通行证,最后状态', st);
await sleep(500);
}
console.log('— 是否已有控件:', await evalJs('document.querySelectorAll(".atk-human-box").length'));
// 手动触发「点一下验证」
await evalJs('window.__humanCheck && window.__humanCheck.drop(); window.__humanCheck && window.__humanCheck.showClick();');
await sleep(900);
console.log('— 点一下控件:', await evalJs('JSON.stringify((()=>{const w=document.querySelector(".atk-human-box");if(!w)return null;const b=w.getBoundingClientRect();return {cls:w.className,text:w.textContent.trim(),x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height)};})())'));
const shot1 = await send('Page.captureScreenshot', { format: 'png' });
// 点一下 → 应立即拿到通行证(复用缓存的 PoW 证明,不重算)
const tClick = Date.now();
await evalJs('document.querySelector(".atk-human-box") && document.querySelector(".atk-human-box").click(); "ok"');
let clicked;
for (let i = 0; i < 20; i++) {
clicked = await evalJs('JSON.stringify(window.__humanCheck.state())');
if (clicked && clicked.indexOf('"hasPass":true') >= 0) break;
await sleep(250);
}
console.log(`— 点击验证:${((Date.now() - tClick) / 1000).toFixed(2)}s → ${clicked}`);
writeFileSync(OUT.replace(/\.png$/, '-click.png'), Buffer.from(shot1.result.data, 'base64'));
// 手动触发「图形验证码」
await evalJs('window.__humanCheck && window.__humanCheck.drop(); window.__humanCheck && window.__humanCheck.showCaptcha();');
await sleep(1800);
console.log('— 图形验证码控件:', await evalJs('JSON.stringify((()=>{const w=document.querySelector(".atk-human-box");if(!w)return null;const img=w.querySelector(".atk-human-img");return {cls:w.className,imgLoaded:!!(img&&img.src&&img.naturalWidth>0),imgW:img?img.naturalWidth:0,text:w.textContent.trim()};})())'));
const shot2 = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-captcha.png'), Buffer.from(shot2.result.data, 'base64'));
console.log('— 控制台错误:', errs.length ? errs.slice(0, 5) : '(无)');
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+67
View File
@@ -0,0 +1,67 @@
// 诊断:错误态下评论容器们的实际高度来源
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9411;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/listdiag.json';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-lsd-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
const out = await ev(`(() => {
const pick = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const b = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
h: Math.round(b.height),
minH: cs.minHeight,
inline: (el.getAttribute('style') || '').slice(0, 80),
children: el.children.length,
};
};
return JSON.stringify({
list: pick('.atk-list'),
listBody: pick('.atk-list-body'),
commentsWrap: pick('.atk-list-comments-wrap'),
errorLayer: pick('.atk-error-layer'),
errCard: pick('.atk-err-card'),
editor: pick('.atk-main-editor'),
}, null, 1);
})()`);
writeFileSync(OUT, String(out));
ws.close();
} catch (e) {
writeFileSync(OUT, 'ERR ' + e.message);
} finally {
chrome.kill();
}
+58
View File
@@ -0,0 +1,58 @@
// 打开友链申请弹窗,测量各元素盒子与计算样式,定位错位/溢出原因
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9355;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-modal-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1000', 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const info = (sel) => {
const el = document.querySelector(sel);
if (!el) return { sel, missing: true };
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
return {
sel,
x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height),
display: s.display, width: s.width, minWidth: s.minWidth, maxWidth: s.maxWidth,
margin: s.margin, padding: s.padding, float: s.float, textAlign: s.textAlign,
boxSizing: s.boxSizing, position: s.position, overflowX: s.overflowX,
scrollW: el.scrollWidth, clientW: el.clientWidth,
};
};
return JSON.stringify([
info('#flApplyMask'), info('#flApplyBox'), info('#flName'), info('#flDesc'), info('#flSubmit'),
], null, 1);
})()`;
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable');
await send('Page.enable');
await sleep(5000);
await send('Runtime.evaluate', { expression: `(window.flApplyOpenModal && window.flApplyOpenModal(), 'opened')`, returnByValue: true });
await sleep(600);
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(r.result.result.value);
const shot = await send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3] || 'modal.png', Buffer.from(shot.result.data, 'base64'));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+48
View File
@@ -0,0 +1,48 @@
// 采样检查卡片打字机是否真的在逐字打出
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9344;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-type-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,900', 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const el = document.querySelector('.atk-fl-typed');
const caret = document.querySelector('.atk-fl-caret');
return JSON.stringify({
reduce: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
len: el ? (el.textContent || '').length : -1,
caretShown: caret ? getComputedStyle(caret).display !== 'none' : null,
cardConnected: !!(document.querySelector('.atk-fl-card') && document.querySelector('.atk-fl-card').isConnected),
});
})()`;
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {}
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable');
for (let t = 0; t < 26; t++) {
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(String(t * 200).padStart(5) + 'ms', r.result && r.result.result ? r.result.result.value : r);
await sleep(200);
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# ===========================================================================
# artalk-cf 一键部署(第 2~8 步)
#
# 前置:已经跑过 `npx wrangler login`
# 用法:
# export ADMIN_PASSWORD='你的后台密码'
# bash tools/deploy.sh
#
# 可选环境变量:
# TOKEN_SECRET 不传则自动生成随机串
# DB_NAME 默认 artalk-cf
# SKIP_IMPORT=1 跳过数据导入
# ===========================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
DB_NAME="${DB_NAME:-artalk-cf}"
WR="node node_modules/wrangler/bin/wrangler.js"
if [ ! -f node_modules/wrangler/package.json ]; then
echo "缺少依赖,先跑:npm install" >&2
exit 1
fi
if [ -z "${ADMIN_PASSWORD:-}" ]; then
echo "请先设置管理员密码:export ADMIN_PASSWORD='...'" >&2
exit 1
fi
echo "==> 1/8 检查登录状态"
# 兼容三种情况:OAuth 登录、作用域 Token(cfut_)、账户 Token(cfat_)
WHOAMI=$($WR whoami 2>&1 || true)
if ! printf '%s' "$WHOAMI" | grep -qiE 'associated with the email|Account Name|Account ID|accounts'; then
echo "$WHOAMI" | tail -5
echo
echo "没检测到可用的凭据。二选一:" >&2
echo " a) 交互登录: npx wrangler login" >&2
echo " b) 用作用域 Token: export CLOUDFLARE_API_TOKEN='cfut_...'(可再加 CLOUDFLARE_ACCOUNT_ID)" >&2
exit 1
fi
echo "$WHOAMI" | grep -iE 'Account Name|Account ID|associated with' | head -4 | sed 's/^/ /'
echo "==> 2/8 创建 D1 数据库(已存在则复用)"
DB_ID=$($WR d1 list --json 2>/dev/null \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const a=JSON.parse(s);const m=a.find(x=>x.name===process.argv[1]);if(m)console.log(m.uuid)}catch{}})" "$DB_NAME")
if [ -z "$DB_ID" ]; then
OUT=$($WR d1 create "$DB_NAME" 2>&1)
echo "$OUT"
DB_ID=$(printf '%s' "$OUT" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
fi
if [ -z "$DB_ID" ]; then
echo "拿不到 database_id,请手动去控制台复制后填进 wrangler.toml" >&2
exit 1
fi
echo " database_id = $DB_ID"
echo "==> 3/8 写入 wrangler.toml"
node -e "
const fs=require('fs');
const p='wrangler.toml';
let s=fs.readFileSync(p,'utf8');
const before=s;
s=s.replace(/database_id\s*=\s*\"[^\"]*\"/, 'database_id = \"' + process.argv[1] + '\"');
s=s.replace(/database_name\s*=\s*\"[^\"]*\"/, 'database_name = \"' + process.argv[2] + '\"');
if(s===before){console.error('wrangler.toml 里没找到 database_id,请手动填');process.exit(1);}
fs.writeFileSync(p,s);
console.log(' ok');
" "$DB_ID" "$DB_NAME"
echo "==> 4/8 写入机密"
TOKEN_SECRET="${TOKEN_SECRET:-$(node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))")}"
printf '%s' "$TOKEN_SECRET" | $WR secret put TOKEN_SECRET
printf '%s' "$ADMIN_PASSWORD" | $WR secret put ADMIN_PASSWORD
echo "==> 5/8 建表"
$WR d1 execute "$DB_NAME" --remote -y --file=./schema.sql
echo "==> 6/8 部署"
DEPLOY_OUT=$($WR deploy 2>&1 | tee /dev/stderr)
BASE=$(printf '%s' "$DEPLOY_OUT" \
| grep -oE 'https://[A-Za-z0-9._-]+\.workers\.dev' | head -1)
if [ -z "$BASE" ]; then
read -r -p "请手动粘贴部署输出里的地址(如 https://artalk-cf.xxx.workers.dev): " BASE
fi
BASE="${BASE%/}"
echo " BASE = $BASE"
echo "==> 7/8 导入评论数据(必须在初始化管理员之前——导入会清空 users 表)"
if [ "${SKIP_IMPORT:-}" != "1" ]; then
if command -v python >/dev/null 2>&1; then
python tools/import_artrans.py
else
python3 tools/import_artrans.py
fi
bash tools/import-out/run.sh --remote
else
echo " (SKIP_IMPORT=1,跳过)"
fi
echo "==> 8/8 初始化站点与管理员"
curl -sS -X POST "$BASE/api/v2/setup" -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "============================================================"
echo " 完成。接下来:"
echo " 1) 验证:bash tools/smoke.sh $BASE"
echo " 2) 域名:见 部署清单.md 第 9 步(NS 必须在 Cloudflare)"
echo " 3) 切博客:hugo.toml 的 [params.artalk] server = $BASE"
echo "============================================================"
+22
View File
@@ -0,0 +1,22 @@
"""把 Artalk 的配置模板 YAML 打包成 TS 常量,供设置页使用。"""
import json
import pathlib
SRC = pathlib.Path(r"C:\Users\QunLIn\WorkBuddy\2026-10-02-15-13-24\Artalk-master\conf\artalk.example.zh-CN.yml")
OUT = pathlib.Path(r"C:\Users\QunLIn\WorkBuddy\2026-10-02-15-13-24\artalk-cf\src\template.generated.ts")
text = SRC.read_text(encoding="utf-8")
# 用 JSON 字面量转义,最省事也最安全
literal = json.dumps(text, ensure_ascii=False)
OUT.write_text(
"// 由 tools/gen_template.py 生成,请勿手改。\n"
"// 来源:Artalk 仓库 conf/artalk.example.zh-CN.yml\n"
"// 作用:/settings/template/:locale 下发,供官方后台的设置页渲染表单。\n\n"
f"export const SETTINGS_TEMPLATE_ZH_CN = {literal};\n",
encoding="utf-8",
newline="\n",
)
print("写出:", OUT)
print("模板字符数:", len(text), " 生成文件字节:", OUT.stat().st_size)
+46
View File
@@ -0,0 +1,46 @@
// 人机验证预览页的本地服务:静态托管 demo + 把 /api/* 同源转发到线上 Worker。
// 为什么要转发:Worker 的 CORS 白名单只放行自己的域名,file:// 或本地预览源的
// 跨域请求会被浏览器拦掉;同源就不存在这个问题,预览也能跑真实链路。
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
const PORT = Number(process.argv[2] || 8791);
const FILE = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/human-check-demo.html';
const UPSTREAM = 'https://api.200181.xyz';
const server = createServer(async (req, res) => {
if (req.url.startsWith('/api/')) {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks);
try {
const up = await fetch(UPSTREAM + req.url, {
method: req.method,
headers: {
'Content-Type': req.headers['content-type'] || 'application/json',
'User-Agent': 'human-demo-proxy/1.0',
},
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
});
const buf = Buffer.from(await up.arrayBuffer());
res.writeHead(up.status, { 'Content-Type': up.headers.get('content-type') || 'application/json' });
res.end(buf);
} catch (e) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream failed: ' + e.message }));
}
return;
}
try {
const html = readFileSync(FILE);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (e) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('demo file not found: ' + FILE);
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log('人机验证预览: http://127.0.0.1:' + PORT + '/');
});
+319
View File
@@ -0,0 +1,319 @@
"""
把 Artalk 导出的 .artrans 转成可直接灌进 Cloudflare D1 的 SQL。
为什么不用 Worker 的 /transfer/import:
3440 条评论逐条 INSERT 会产生 3440 次 D1 往返,Worker 免费版单请求 CPU 只有 10ms,
很容易直接超时。批量导入走 wrangler d1 execute --file 最稳、最快。
用法:
python tools/import_artrans.py # 默认只导「优世界」
python tools/import_artrans.py --site 优世界 --src <path.artrans> --out out/
# 然后:
# npx wrangler d1 execute artalk-cf --remote --file=out/import-01-users.sql
# ... 按生成的 run.sh 顺序执行
"""
import argparse
import json
import pathlib
import sys
from collections import OrderedDict
DEFAULT_SRC = "" # 不写死机器路径;用 --src <path.artrans> 显式传入
DEFAULT_SITE = "优世界"
DEFAULT_SITE_URL = "https://usj.cc"
CHUNK = 400 # 每个 SQL 文件最多多少条语句,避免单次 execute 过大
def sql_str(v) -> str:
if v is None:
return "NULL"
s = str(v)
return "'" + s.replace("'", "''") + "'"
def sql_bool(v) -> str:
return "1" if str(v).lower() in ("true", "1", "yes") else "0"
def sql_int(v, dft=0) -> str:
try:
return str(int(str(v).strip()))
except Exception:
return str(dft)
def parse_dt(s: str) -> int:
"""'2021-09-11 20:33:56 +0800 +0800' -> unix 毫秒(+0800)"""
s = (s or "").strip()
if len(s) < 19:
return 0
try:
import datetime as dt
naive = dt.datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
aware = naive.replace(tzinfo=dt.timezone(dt.timedelta(hours=8)))
ms = int(aware.timestamp() * 1000)
return ms if ms > 0 else 0
except Exception:
return 0
def write_chunks(out_dir: pathlib.Path, name: str, stmts: list) -> list:
"""把语句按 CHUNK 切成多个 .sql,返回生成的文件名列表"""
files = []
for i in range(0, len(stmts), CHUNK):
part = stmts[i : i + CHUNK]
fn = f"{name}-{i // CHUNK + 1:03d}.sql"
(out_dir / fn).write_text("\n".join(part) + "\n", encoding="utf-8", newline="\n")
files.append(fn)
return files
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=DEFAULT_SRC)
ap.add_argument("--site", default=DEFAULT_SITE, help="只导这个站点名,逗号分隔可多个;all = 全导")
ap.add_argument("--site-url", default=DEFAULT_SITE_URL)
ap.add_argument("--out", default="tools/import-out")
ap.add_argument("--db", default="artalk-cf", help="wrangler d1 的数据库名")
args = ap.parse_args()
src = pathlib.Path(args.src)
if not args.src:
print("请用 --src <path.artrans> 指定导出的 .artrans 文件路径", file=sys.stderr)
return 1
if not src.exists():
print(f"找不到源文件: {src}", file=sys.stderr)
return 1
data = json.loads(src.read_text(encoding="utf-8"))
want = None if args.site.strip().lower() == "all" else [s.strip() for s in args.site.split(",") if s.strip()]
site_name = want[0] if want else DEFAULT_SITE
items = [c for c in data if (want is None or c.get("site_name") in want)]
print(f"源文件 {len(data)} 条,按站点筛选后 {len(items)} 条 -> 归入站点「{site_name}」")
if not items:
print("筛选后为空,检查 --site", file=sys.stderr)
return 1
out_dir = pathlib.Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
# ---------------- 站点
head = [
"-- =====================================================================",
"-- ⚠️ 这个文件会先清空 comments / pages / users / sites 四张表。",
"-- 只在**首次导入到全新数据库**时执行;库里已有数据请勿再跑。",
"-- =====================================================================",
"DELETE FROM comments;",
"DELETE FROM pages;",
"DELETE FROM users;",
"DELETE FROM sites;",
f"INSERT INTO sites (id, name, urls, created_at, updated_at) "
f"VALUES (1, {sql_str(site_name)}, {sql_str(args.site_url)}, "
f"{parse_dt(max((c.get('created_at') or '') for c in items)) or 0}, 0);",
]
# ---------------- 用户(按 nick+email 去重)
users = OrderedDict()
for c in items:
nick = (c.get("nick") or "").strip()
email = (c.get("email") or "").strip()
if not nick or not email:
continue
users.setdefault(f"{nick}\u0000{email}", {"nick": nick, "email": email, "link": c.get("link") or "",
"badge_name": c.get("badge_name") or "",
"badge_color": c.get("badge_color") or ""})
user_id = {}
user_stmts = ["-- 用户(原数据没有注册账号,全部按 昵称+邮箱 去重重建)"]
for idx, (key, u) in enumerate(users.items(), start=1):
user_id[key] = idx
ts = next((parse_dt(c.get("created_at")) for c in items
if (c.get("nick") or "").strip() == u["nick"] and (c.get("email") or "").strip() == u["email"]), 0)
user_stmts.append(
"INSERT INTO users (id, name, email, link, badge_name, badge_color, receive_email, created_at, updated_at) "
f"VALUES ({idx}, {sql_str(u['nick'])}, {sql_str(u['email'])}, {sql_str(u['link'])}, "
f"{sql_str(u['badge_name'])}, {sql_str(u['badge_color'])}, 1, {ts or 0}, {ts or 0});"
)
print(f"用户 {len(users)} 个")
# ---------------- 页面
pages = OrderedDict()
for c in items:
key = (c.get("page_key") or "").strip()
if not key:
continue
pages.setdefault(key, {"title": c.get("page_title") or "", "admin_only": c.get("page_admin_only"),
"created_at": parse_dt(c.get("created_at"))})
page_id = {}
page_stmts = ["-- 页面"]
for idx, (key, p) in enumerate(pages.items(), start=1):
page_id[key] = idx
page_stmts.append(
"INSERT INTO pages (id, key, site_name, title, admin_only, created_at, updated_at) "
f"VALUES ({idx}, {sql_str(key)}, {sql_str(site_name)}, {sql_str(p['title'])}, "
f"{sql_bool(p['admin_only'])}, {p['created_at'] or 0}, {p['created_at'] or 0});"
)
print(f"页面 {len(pages)} 个")
# ---------------- 评论(保留原始 id,保证 rid 关系不变)
rid_of = {}
rows = []
skip_no_id = skip_no_page = skip_no_user = 0
for c in items:
try:
cid = int(str(c.get("id")))
except Exception:
skip_no_id += 1
continue
try:
rid = int(str(c.get("rid") or 0))
except Exception:
rid = 0
key = f"{(c.get('nick') or '').strip()}\u0000{(c.get('email') or '').strip()}"
pk = (c.get("page_key") or "").strip()
if not pk:
skip_no_page += 1
continue
if key not in user_id:
skip_no_user += 1
continue
rid_of[cid] = rid
rows.append({
"id": cid, "rid": rid, "uid": user_id[key], "pid": page_id[pk], "page_key": pk,
"content": c.get("content") or "",
"is_collapsed": c.get("is_collapsed"), "is_pending": c.get("is_pending"),
"is_pinned": c.get("is_pinned"), "vote_up": c.get("vote_up"), "vote_down": c.get("vote_down"),
"ua": c.get("ua") or "", "ip": c.get("ip") or "",
"created_at": parse_dt(c.get("created_at")), "updated_at": parse_dt(c.get("updated_at")),
})
# 只保留指向本批次内评论的 rid,避免指到没导进来的兄弟站点评论
ids = {r["id"] for r in rows}
for r in rows:
if r["rid"] and r["rid"] not in ids:
r["rid"] = 0
# 计算 root(沿 rid 链上溯到「rid==0 的那条顶层评论」)
#
# ★ 这里必须返回顶层评论自身的 id,而不是 0。
# 对应官方 dao.FindCommentRootID:
# for rootId != 0 { if comment.Rid == 0 { return rootId }; rootId = comment.Rid }
# 即「父链上第一个 rid 为 0 的那条评论」。
# 注意语义是:顶层评论 root_id=0,它的所有子孙 root_id=该顶层评论的 id。
root_cache = {}
def root_of(cid: int) -> int:
if cid in root_cache:
return root_cache[cid]
chain = []
cur = cid
seen = set()
while cur and cur not in root_cache and cur not in seen:
seen.add(cur)
parent = rid_of.get(cur, 0)
if parent == 0:
break # cur 就是顶层评论
chain.append(cur)
cur = parent
root = root_cache.get(cur, cur)
for n in chain:
root_cache[n] = root
root_cache[root] = root
return root
# 先全部插入成顶层,再统一回填 rid / root_id —— 与导入顺序无关
ins, fix = [], []
for r in rows:
# is_verified 在 .artrans 里没有这个字段(官方导出不带)。
# 前端只在「没有徽章」时才把 is_verified 渲染成小勾,所以这里统一给 0:
# 有徽章的 328 条(博主)照样显示徽章,其余 3000+ 匿名评论不会多出勾。
ins.append(
"INSERT INTO comments (id, content, page_key, site_name, user_id, rid, root_id, "
"is_collapsed, is_pending, is_pinned, is_verified, vote_up, vote_down, ua, ip, created_at, updated_at) "
f"VALUES ({r['id']}, {sql_str(r['content'])}, {sql_str(r['page_key'])}, {sql_str(site_name)}, "
f"{r['uid']}, 0, 0, {sql_bool(r['is_collapsed'])}, {sql_bool(r['is_pending'])}, {sql_bool(r['is_pinned'])}, "
f"0, {sql_int(r['vote_up'])}, {sql_int(r['vote_down'])}, {sql_str(r['ua'])}, {sql_str(r['ip'])}, "
f"{r['created_at']}, {r['updated_at'] or r['created_at']});"
)
if r["rid"]:
fix.append(f"UPDATE comments SET rid = {r['rid']}, root_id = {root_of(r['rid'])} WHERE id = {r['id']};")
print(f"评论 {len(rows)} 条(其中回复 {len(fix)} 条)")
if skip_no_page or skip_no_user or skip_no_id:
print(
f"跳过 {skip_no_page + skip_no_user + skip_no_id} 条:"
f"page_key 为空 {skip_no_page}、昵称或邮箱为空 {skip_no_user}、id 非法 {skip_no_id}"
)
if skip_no_page:
print(" ↑ page_key 为空的评论在官方 Artalk 里同样无法挂到任何页面,属于历史脏数据")
# ---------------- 落盘
files = []
(out_dir / "00-reset-and-site.sql").write_text("\n".join(head) + "\n", encoding="utf-8", newline="\n")
files.append("00-reset-and-site.sql")
files += write_chunks(out_dir, "01-users", user_stmts)
files += write_chunks(out_dir, "02-pages", page_stmts)
files += write_chunks(out_dir, "03-comments", ins)
files += write_chunks(out_dir, "04-rid-fix", fix)
# 为了让 wrangler d1 execute 的 SQLite 序列号往后走
tail = ["-- 让 AUTOINCREMENT 从最大值之后继续", f"UPDATE sqlite_sequence SET seq = "
f"(SELECT MAX(id) FROM comments) WHERE name = 'comments';",
f"UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM users) WHERE name = 'users';",
f"UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM pages) WHERE name = 'pages';",
"-- 自检",
"SELECT 'sites' AS t, COUNT(*) AS n FROM sites",
"UNION ALL SELECT 'pages', COUNT(*) FROM pages",
"UNION ALL SELECT 'users', COUNT(*) FROM users",
"UNION ALL SELECT 'comments', COUNT(*) FROM comments",
"UNION ALL SELECT 'replies', COUNT(*) FROM comments WHERE rid != 0;"]
(out_dir / "99-post.sql").write_text("\n".join(tail) + "\n", encoding="utf-8", newline="\n")
files.append("99-post.sql")
# 顺序执行脚本
# -y 必须加:remote 执行会先问一次确认,非交互环境下不加会直接报错退出
# 每个文件重试 3 次:跨境的 fetch 偶发失败(实测在 03-comments-007 断过一次)
lines = [
"#!/usr/bin/env bash",
"set -uo pipefail",
f"DB={args.db}",
"MODE=${1:---remote}",
"export WRANGLER_SEND_METRICS=false",
"WR=\"node node_modules/wrangler/bin/wrangler.js\"",
'[ -f "node_modules/wrangler/bin/wrangler.js" ] || WR="npx wrangler"',
"",
"FAILED=0",
]
for f in files:
lines.append(f'echo "==> {f}"')
lines.append("ok=0")
lines.append("for try in 1 2 3; do")
lines.append(f' if $WR d1 execute "$DB" "$MODE" -y --file={out_dir.as_posix()}/{f} >/dev/null 2>&1; then ok=1; break; fi')
lines.append(' echo " 第 $try 次失败,重试(跨境 fetch 偶发失败)"')
lines.append(" sleep 3")
lines.append("done")
lines.append('if [ "$ok" = "1" ]; then echo " ok"; else echo " FAIL"; FAILED=1; fi')
lines.append("")
lines.append('echo "核对条数:"')
lines.append(
f'$WR d1 execute "$DB" "$MODE" -y --command '
'"SELECT (SELECT COUNT(*) FROM sites) AS sites, (SELECT COUNT(*) FROM pages) AS pages,'
' (SELECT COUNT(*) FROM users) AS users, (SELECT COUNT(*) FROM comments) AS comments,'
' (SELECT COUNT(*) FROM comments WHERE rid!=0) AS replies;"'
)
lines.append("")
lines.append('[ "$FAILED" = "0" ] && echo "全部导入成功" || echo "有文件失败,看上面 FAIL 那几行"')
(out_dir / "run.sh").write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
print(f"\n生成 {len(files)} 个 SQL 于 {out_dir}")
print(f"顺序执行: bash {out_dir.as_posix()}/run.sh --remote")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+388
View File
@@ -0,0 +1,388 @@
"""
增量导入 .artrans —— 只插库里没有的评论/用户/页面,不动已有数据。
和 import_artrans.py 的区别:
import_artrans.py 首次全量导入,头部是 DELETE FROM comments/pages/users/sites(破坏性)
import_delta.py 增量:先从远端 D1 拉现状(已有 comment id / user / page),
再只生成「差集」的 INSERT,rid / root_id 依旧按全量 rid 链计算,
所以新回复挂到已有父评论上时 root_id 也是对的。
用法:
python tools/import_delta.py --src <path.artrans> --site 优世界 # 只生成 SQL,打印计划
python tools/import_delta.py --src <path.artrans> --site 优世界 --apply # 生成并执行
依赖环境变量 CLOUDFLARE_API_TOKEN(wrangler 用)。
"""
import argparse
import json
import os
import pathlib
import subprocess
import sys
import time
from collections import OrderedDict
DEFAULT_SITE = "优世界"
DEFAULT_SITE_URL = "https://usj.cc"
CHUNK = 400
ROOT = pathlib.Path(__file__).resolve().parent.parent
WRANGLER = ROOT / "node_modules" / "wrangler" / "bin" / "wrangler.js"
# ----------------------------------------------------------------- SQL helpers
def sql_str(v) -> str:
if v is None:
return "NULL"
return "'" + str(v).replace("'", "''") + "'"
def sql_bool(v) -> str:
return "1" if str(v).lower() in ("true", "1", "yes") else "0"
def sql_int(v, dft=0) -> str:
try:
return str(int(str(v).strip()))
except Exception:
return str(dft)
def parse_dt(s) -> int:
"""'2021-09-11 20:33:56 +0800 +0800' -> unix 毫秒"""
s = (s or "").strip()
if len(s) < 19:
return 0
try:
import datetime as dt
naive = dt.datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
aware = naive.replace(tzinfo=dt.timezone(dt.timedelta(hours=8)))
return int(aware.timestamp() * 1000)
except Exception:
return 0
# ----------------------------------------------------------------- 远端现状
def wrangler(args: list, tries: int = 3):
cmd = ["node", str(WRANGLER)] + args
last = None
for i in range(tries):
try:
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
if p.returncode == 0:
return p.stdout
last = (p.stdout or "") + (p.stderr or "")
except Exception as e: # noqa: BLE001
last = str(e)
print(f" wrangler 第 {i + 1} 次失败,3 秒后重试", file=sys.stderr)
time.sleep(3)
raise RuntimeError(f"wrangler 失败:{(last or '')[:400]}")
def parse_json_out(s: str):
"""wrangler --json 前面可能插一行代理告警,从第一个 [ 或 { 开始截"""
i = min([x for x in (s.find("["), s.find("{")) if x >= 0] or [0])
return json.loads(s[i:])
def fetch_state(db: str) -> dict:
sql = (
"SELECT id FROM comments;"
"SELECT id, name, email FROM users;"
"SELECT id, key FROM pages;"
"SELECT (SELECT IFNULL(MAX(id),0) FROM comments) mc,"
" (SELECT IFNULL(MAX(id),0) FROM users) mu,"
" (SELECT IFNULL(MAX(id),0) FROM pages) mp;"
)
out = wrangler(["d1", "execute", db, "--remote", "-y", "--command", sql, "--json"])
j = parse_json_out(out)
comment_ids = {int(r["id"]) for r in j[0]["results"]}
users = {(r["name"], r["email"]): int(r["id"]) for r in j[1]["results"]}
pages = {r["key"]: int(r["id"]) for r in j[2]["results"]}
mx = j[3]["results"][0]
return {
"comment_ids": comment_ids,
"users": users,
"pages": pages,
"max_comment": int(mx["mc"]),
"max_user": int(mx["mu"]),
"max_page": int(mx["mp"]),
}
# ----------------------------------------------------------------- 主流程
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--src", required=True)
ap.add_argument("--site", default=DEFAULT_SITE, help="逗号分隔;all = 全导")
ap.add_argument("--site-url", default=DEFAULT_SITE_URL)
ap.add_argument("--out", default="tools/delta-out")
ap.add_argument("--db", default="artalk-cf")
ap.add_argument("--apply", action="store_true", help="生成后直接执行到远端")
args = ap.parse_args()
src = pathlib.Path(args.src)
if not src.exists():
print(f"找不到源文件: {src}", file=sys.stderr)
return 1
data = json.loads(src.read_text(encoding="utf-8"))
want = (
None
if args.site.strip().lower() == "all"
else [s.strip() for s in args.site.split(",") if s.strip()]
)
items = [c for c in data if want is None or c.get("site_name") in want]
site_name = (want[0] if want else DEFAULT_SITE) or DEFAULT_SITE
print(f"源文件 {len(data)} 条 → 站点「{site_name}」{len(items)} 条")
print("拉取远端现状 …")
st = fetch_state(args.db)
print(
f" 现状:comments {len(st['comment_ids'])}、users {len(st['users'])}、"
f"pages {len(st['pages'])}"
)
# ---------- 用户差集
# 按 (昵称,邮箱) 认人;但如果邮箱在库里已存在(昵称变了的情况,比如"小赵同学"→"小赵"),
# 复用老用户,避免同一个博主被拆成两个账号(新的那条也就不会继承 is_admin / 徽章)。
user_id = dict(st["users"]) # (name,email) -> id
by_email = {}
for (n, e), i in st["users"].items():
by_email.setdefault(e, i)
next_uid = st["max_user"] + 1
new_users = OrderedDict()
for c in items:
nick = (c.get("nick") or "").strip()
email = (c.get("email") or "").strip()
if not nick or not email:
continue
k = (nick, email)
if k in user_id:
continue
if email in by_email:
user_id[k] = by_email[email] # 同邮箱复用老 id
continue
if k in new_users:
continue
new_users[k] = {
"id": next_uid,
"nick": nick,
"email": email,
"link": c.get("link") or "",
"badge_name": c.get("badge_name") or "",
"badge_color": c.get("badge_color") or "",
"ts": parse_dt(c.get("created_at")),
}
user_id[k] = next_uid
by_email[email] = next_uid
next_uid += 1
# ---------- 页面差集
page_id = dict(st["pages"])
next_pid = st["max_page"] + 1
new_pages = OrderedDict()
for c in items:
key = (c.get("page_key") or "").strip()
if not key or key in page_id:
continue
new_pages[key] = {
"id": next_pid,
"title": c.get("page_title") or "",
"admin_only": c.get("page_admin_only"),
"ts": parse_dt(c.get("created_at")),
}
page_id[key] = next_pid
next_pid += 1
# ---------- 评论差集(保留原始 id,rid 关系才不用重算)
rows = []
rid_of = {}
have = st["comment_ids"]
dup = 0
for c in items:
try:
cid = int(str(c.get("id")))
except Exception:
continue
try:
rid = int(str(c.get("rid") or 0))
except Exception:
rid = 0
rid_of[cid] = rid
pk = (c.get("page_key") or "").strip()
if not pk or pk not in page_id:
continue
uk = ((c.get("nick") or "").strip(), (c.get("email") or "").strip())
if uk not in user_id:
continue
if cid in have:
dup += 1
continue
have.add(cid)
rows.append(
{
"id": cid,
"rid": rid,
"uid": user_id[uk],
"pid": page_id[pk],
"page_key": pk,
"content": c.get("content") or "",
"is_collapsed": c.get("is_collapsed"),
"is_pending": c.get("is_pending"),
"is_pinned": c.get("is_pinned"),
"vote_up": c.get("vote_up"),
"vote_down": c.get("vote_down"),
"ua": c.get("ua") or "",
"ip": c.get("ip") or "",
"created_at": parse_dt(c.get("created_at")),
"updated_at": parse_dt(c.get("updated_at")),
}
)
# root_id:沿 rid 链上溯。rid_of 来自**全量导出**,所以父评论在库里也能算对
root_cache = {}
def root_of(cid: int) -> int:
if cid in root_cache:
return root_cache[cid]
chain = []
cur = cid
seen = set()
while cur and cur not in root_cache and cur not in seen:
seen.add(cur)
parent = rid_of.get(cur, 0)
if parent == 0:
break # cur 就是顶层评论
chain.append(cur)
cur = parent
root = root_cache.get(cur, cur)
for n in chain:
root_cache[n] = root
root_cache[root] = root
return root
print(
f"\n差集:新用户 {len(new_users)}、新页面 {len(new_pages)}、新评论 {len(rows)}"
f"(导出里已有 {dup} 条跳过)"
)
if not rows and not new_users and not new_pages:
print("库里已经是最新,无需导入。")
return 0
out_dir = ROOT / args.out
out_dir.mkdir(parents=True, exist_ok=True)
for old in out_dir.glob("*.sql"):
old.unlink()
files = []
def dump(name: str, stmts: list):
for i in range(0, len(stmts), CHUNK):
fn = f"{name}-{i // CHUNK + 1:03d}.sql"
(out_dir / fn).write_text(
"\n".join(stmts[i : i + CHUNK]) + "\n", encoding="utf-8", newline="\n"
)
files.append(fn)
if new_users:
us = ["-- 新增用户"]
for u in new_users.values():
us.append(
"INSERT INTO users (id, name, email, link, badge_name, badge_color, receive_email, created_at, updated_at) "
f"VALUES ({u['id']}, {sql_str(u['nick'])}, {sql_str(u['email'])}, {sql_str(u['link'])}, "
f"{sql_str(u['badge_name'])}, {sql_str(u['badge_color'])}, 1, {u['ts']}, {u['ts']});"
)
dump("01-users", us)
if new_pages:
ps = ["-- 新增页面"]
for key, p in new_pages.items():
ps.append(
"INSERT INTO pages (id, key, site_name, title, admin_only, created_at, updated_at) "
f"VALUES ({p['id']}, {sql_str(key)}, {sql_str(site_name)}, "
f"{sql_str(p['title'])}, {sql_bool(p['admin_only'])}, {p['ts']}, {p['ts']});"
)
dump("02-pages", ps)
if rows:
ins = ["-- 新增评论(先全部按顶层插入,再回填 rid / root_id)"]
fix = []
for r in rows:
ins.append(
"INSERT INTO comments (id, content, page_key, site_name, user_id, rid, root_id, "
"is_collapsed, is_pending, is_pinned, is_verified, vote_up, vote_down, ua, ip, created_at, updated_at) "
f"VALUES ({r['id']}, {sql_str(r['content'])}, {sql_str(r['page_key'])}, {sql_str(site_name)}, "
f"{r['uid']}, 0, 0, {sql_bool(r['is_collapsed'])}, {sql_bool(r['is_pending'])}, {sql_bool(r['is_pinned'])}, "
f"0, {sql_int(r['vote_up'])}, {sql_int(r['vote_down'])}, {sql_str(r['ua'])}, {sql_str(r['ip'])}, "
f"{r['created_at']}, {r['updated_at'] or r['created_at']});"
)
if r["rid"]:
fix.append(
f"UPDATE comments SET rid = {r['rid']}, root_id = {root_of(r['rid'])} WHERE id = {r['id']};"
)
dump("03-comments", ins)
if fix:
dump("04-rid-fix", fix)
check = [
"-- 核对",
"SELECT (SELECT COUNT(*) FROM comments) AS comments,"
" (SELECT COUNT(*) FROM comments WHERE rid != 0) AS replies,"
" (SELECT COUNT(*) FROM users) AS users,"
" (SELECT COUNT(*) FROM pages) AS pages;",
]
(out_dir / "99-check.sql").write_text("\n".join(check) + "\n", encoding="utf-8", newline="\n")
files.append("99-check.sql")
print(f"\n生成 {len(files)} 个 SQL 于 {out_dir}")
if not args.apply:
print(f"执行: bash {args.out}/run.sh --remote (或加 --apply 自动跑)")
return 0
env = dict(os.environ)
for f in files:
print(f"==> {f}")
ok = False
for attempt in range(3):
p = subprocess.run(
["node", str(WRANGLER), "d1", "execute", args.db, "--remote", "-y", f"--file={out_dir.as_posix()}/{f}"],
cwd=ROOT,
capture_output=True,
text=True,
env=env,
)
if p.returncode == 0:
ok = True
break
print(f" 第 {attempt + 1} 次失败,重试(跨境 fetch 偶发)", file=sys.stderr)
time.sleep(3)
if not ok:
print(f" FAIL {f}\n{(p.stdout or '')[:300]}\n{(p.stderr or '')[:300]}", file=sys.stderr)
return 1
print(" ok")
out = wrangler(
[
"d1",
"execute",
args.db,
"--remote",
"-y",
"--command",
"SELECT (SELECT COUNT(*) FROM comments) AS comments,"
" (SELECT COUNT(*) FROM comments WHERE rid != 0) AS replies,"
" (SELECT COUNT(*) FROM users) AS users,"
" (SELECT COUNT(*) FROM pages) AS pages;",
"--json",
]
)
print("\n导入后:", json.dumps(parse_json_out(out)[0]["results"][0], ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
/* 邮件模板自检:验证渲染不炸、转义生效、关键字段都在。
* 跑法(见 package.json selftest 同款套路,tools/ 不在 src 的 tsc 范围里):
* npx tsc tools/mailtest.ts --outDir .selftest-build --module commonjs \
* --target es2022 --moduleResolution node --skipLibCheck --esModuleInterop
* node .selftest-build/tools/mailtest.js
*/
import { replyMailHtml, replyMailSubject, adminMailHtml, adminMailSubject } from '../src/lib/mail';
import type { CommentRow } from '../src/types';
let pass = 0,
fail = 0;
const ok = (name: string, cond: boolean) => {
if (cond) {
pass++;
console.log(' ✓', name);
} else {
fail++;
console.log(' ✗', name);
}
};
// CommentRow 只要模板用到的字段;其余给空值占位
const row = (id: number, content: string, createdAt: string): CommentRow =>
({
id,
content,
created_at: createdAt,
page_key: '/20240811.html',
site_name: '优世界',
}) as unknown as CommentRow;
console.log('== 回复提醒模板(reply.html 气泡风) ==');
const rInput = {
siteName: '优世界',
siteUrl: 'https://usj.cc',
pageTitle: '迁移博客至hugo',
pageUrl: 'https://usj.cc/20240811.html',
parentComment: row(15580, '原始评论 <b>加粗</b>', '2026-10-01 10:00:00'),
parentNick: '小赵',
parentEmail: 'imql@qq.com',
comment: row(15590, '试试 <script>alert(1)</script> 和 <img src=x onerror=alert(1)>', '2026-10-02 10:00:00'),
commenterNick: '路人甲',
commenterEmail: 'walker@example.com',
toNick: '小赵',
};
const r = replyMailHtml(rInput);
ok('渲染出 HTML', r.startsWith('<!DOCTYPE html>'));
ok('包含站点名', r.includes('优世界'));
ok('包含页面标题', r.includes('迁移博客至hugo'));
ok('跳转链接带 atk_comment 锚参数', r.includes('atk_comment=15590'));
ok('script 标签被转义', !r.includes('<script>alert(1)</script>'));
ok('没有可执行的 onerror(img 白名单外的 img 一律转义成文本)', !/<img[^>]*onerror/i.test(r));
ok('被回复者昵称出现', r.includes('小赵'));
ok('回复者昵称出现', r.includes('路人甲'));
ok('两个头像 MD5 都渲染', (r.match(/weavatar\.com\/avatar\//g) || []).length >= 2);
ok('site_url 出现在落款', r.includes('https://usj.cc'));
ok('主题格式正确', replyMailSubject(rInput) === '[优世界] 您收到了来自 @路人甲 的回信');
console.log('== 管理员通知模板(notice.html 气泡风) ==');
const aInput = {
siteName: '优世界',
siteUrl: 'https://usj.cc',
pageTitle: '留言',
pageUrl: 'https://usj.cc/comment.html',
comment: row(999, '你好,<img src="https://usj.cc/emotion/douyin/19.png" atk-emoticon="19"> 哈哈', '2026-10-02 11:00:00'),
commenterNick: '路人',
commenterEmail: 'x@example.com',
adminNick: '博主',
};
const a = adminMailHtml(aInput);
ok('渲染出 HTML', a.startsWith('<!DOCTYPE html>'));
ok('标题是新评论', a.includes('有了新的评论'));
ok('含作者昵称', a.includes('路人'));
ok('表情包 img 被保留并内联宽度', /<img[^>]*width:100px[^>]*atk-emoticon/.test(a));
ok('跳转链接带锚参数', a.includes('atk_comment=999'));
ok('site_url 出现在落款', a.includes('https://usj.cc'));
ok('主题格式正确', adminMailSubject(aInput) === '[优世界] 其中 "留言" 页面有一条新的评论!');
console.log('\n通过 ' + pass + ' / 失败 ' + fail);
if (fail > 0) (globalThis as { __fail?: boolean }).__fail = true;
+209
View File
@@ -0,0 +1,209 @@
// 用本地 Chromium 无头渲染评论页,精确测量各徽章的盒子尺寸(CDP,无需第三方依赖)
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9333;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const userDir = mkdtempSync(join(tmpdir(), 'cdp-badge-'));
const chrome = spawn(CHROME, [
'--headless=new',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${userDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--window-size=1280,900',
'about:blank',
], { stdio: 'ignore' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitPort() {
for (let i = 0; i < 40; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return await r.json();
} catch { /* not ready */ }
await sleep(300);
}
throw new Error('chrome 调试端口未就绪');
}
function cdp(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let id = 0;
const pending = new Map();
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result);
}
});
ws.addEventListener('error', reject);
ws.addEventListener('open', () => {
resolve({
send(method, params = {}) {
return new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
},
close: () => ws.close(),
});
});
});
}
const MEASURE = `(() => {
const px = (n) => Math.round(n * 100) / 100;
const list = document.querySelector('.atk-list');
if (list) list.scrollIntoView({ block: 'start' });
const info = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
text: (el.textContent || '').trim(),
cls: el.className.replace(' atk-cmt-badge', ''),
offW: el.offsetWidth, offH: el.offsetHeight,
w: px(r.width), h: px(r.height), top: px(r.top),
fontSize: cs.fontSize, lineHeight: cs.lineHeight, fontWeight: cs.fontWeight,
padding: cs.padding, border: cs.borderTopWidth, display: cs.display,
marginLeft: cs.marginLeft, marginRight: cs.marginRight,
parent: el.parentElement ? el.parentElement.className : null,
};
};
const heads = [...document.querySelectorAll('.atk-header')];
const out = { headerCount: heads.length, samples: [] };
heads.slice(0, 4).forEach((h, i) => {
const badges = [...h.querySelectorAll('.atk-badge, .atk-pinned-badge, .atk-title-badge, .atk-rank-badge, .atk-link-badge, .atk-feed-badge, .atk-region-badge')];
if (!badges.length) return;
out.samples.push({
i,
hidden: !!h.closest('[style*="display: none"]'),
innerWidth: window.innerWidth,
badges: badges.map(info),
});
});
return JSON.stringify(out, null, 1);
})()`;
try {
await waitPort();
// 新建标签页
const created = await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' });
const tab = await created.json();
const c = await cdp(tab.webSocketDebuggerUrl);
await c.send('Runtime.enable');
await c.send('Page.enable');
await sleep(6000); // 等评论异步加载
// 强制日间模式并把评论区滚到视口
await c.send('Runtime.evaluate', {
expression: `(() => {
document.documentElement.setAttribute('data-theme', 'light');
const el = document.querySelector('.atk-comment .atk-header') || document.querySelector('.atk-list');
if (el) el.scrollIntoView({ block: 'center' });
return 'ok';
})()`,
returnByValue: true,
});
await sleep(1200);
const res = await c.send('Runtime.evaluate', { expression: MEASURE, returnByValue: true, awaitPromise: false });
console.log(res.result.value);
const shot = await c.send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3] || 'badge-measure.png', Buffer.from(shot.data, 'base64'));
// 额外抓一张"徽章行"特写(放大 3 倍):先滚动,等布局稳定再取 rect
await c.send('Runtime.evaluate', {
expression: `(() => {
const hs = [...document.querySelectorAll('.atk-header')];
const h = hs.find((x) => x.querySelector('.atk-title-badge') || x.querySelector('.atk-link-badge')) || hs[0];
if (h) h.scrollIntoView({ block: 'center' });
return 'ok';
})()`,
returnByValue: true,
});
await sleep(600);
const rectRes = await c.send('Runtime.evaluate', {
expression: `(() => {
const hs = [...document.querySelectorAll('.atk-header')];
const h = hs.find((x) => x.querySelector('.atk-title-badge') || x.querySelector('.atk-link-badge')) || hs[0];
if (!h) return 'null';
const r = h.getBoundingClientRect();
return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8), width: Math.min(window.innerWidth - Math.max(0, r.x - 8), r.width + 16), height: r.height + 16 });
})()`,
returnByValue: true,
});
if (rectRes.result.value !== 'null') {
const clip = JSON.parse(rectRes.result.value);
const shot2 = await c.send('Page.captureScreenshot', { format: 'png', clip: { ...clip, scale: 3 } });
writeFileSync(String(process.argv[3] || 'badge-measure.png').replace(/\.png$/, '.row.png'), Buffer.from(shot2.data, 'base64'));
}
// 聚焦某个选择器截图(第 4 个参数,例如 .atk-fl-apply),并把它的几何也打印出来
const focusSel = process.argv[4];
if (focusSel) {
const fRes = await c.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector(${JSON.stringify(focusSel)});
if (!el) return 'null';
el.scrollIntoView({ block: 'center' });
return 'ok';
})()`,
returnByValue: true,
});
if (fRes.result.value === 'ok') {
await sleep(700);
const gRes = await c.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector(${JSON.stringify(focusSel)});
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const chain = (node) => {
const out = [];
let n = node;
while (n && n !== document.body) {
const s = getComputedStyle(n);
const b = n.getBoundingClientRect();
out.push({
tag: n.tagName.toLowerCase() + '.' + (n.className || '').toString().split(' ').slice(0, 2).join('.'),
x: Math.round(b.x), w: Math.round(b.width),
ml: s.marginLeft, mr: s.marginRight, pl: s.paddingLeft, pr: s.paddingRight,
bl: s.borderLeftWidth, disp: s.display, style: n.getAttribute('style') || '',
});
n = n.parentElement;
}
return out;
};
const other = [...document.querySelectorAll('.atk-content')].find((x) => !x.closest('.atk-fl-apply'));
const normal = [...document.querySelectorAll('.atk-comment-wrap, .atk-comment')].find((x) => !x.querySelector('.atk-fl-apply'));
return JSON.stringify({
card: { x: Math.round(r.x), w: Math.round(r.width), h: Math.round(r.height) },
cardStyle: { margin: cs.margin, padding: cs.padding, width: cs.width },
cardChain: chain(el),
otherContent: other ? { x: Math.round(other.getBoundingClientRect().x), w: Math.round(other.getBoundingClientRect().width) } : null,
otherChain: other ? chain(other) : null,
normalCommentChain: normal ? chain(normal) : null,
}, null, 1);
})()`,
returnByValue: true,
});
console.log('FOCUS', gRes.result.value);
const shotF = await c.send('Page.captureScreenshot', { format: 'png' });
writeFileSync(String(process.argv[3] || 'badge-measure.png').replace(/\.png$/, '.focus.png'), Buffer.from(shotF.data, 'base64'));
}
}
c.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+57
View File
@@ -0,0 +1,57 @@
// 渲染两封示例邮件 HTML 到 .mail-preview/,供浏览器预览(不发送)
// 用法(在仓库根目录):
// npx tsc tools/preview-mail.ts src/lib/mail.ts --outDir .mail-build --module commonjs --target es2022 --moduleResolution node --skipLibCheck --esModuleInterop --noEmit false
// node .mail-build/tools/preview-mail.js
import * as path from 'path';
const { replyMailHtml, adminMailHtml, replyMailSubject, adminMailSubject } = require('../src/lib/mail');
const now = Date.now();
const parent = {
id: 15630,
content: '终于恢复更新了啊 确实有点无聊了,做些损人不利己的事 :prophet:',
created_at: now - 3600_000,
page_key: '/comment.html',
};
const reply = {
id: 15631,
content: '恢复了,今天有空,把服务全部迁移到 cloudflare 上了,GitHub 仓库只做文件备份了 😄[doge]',
created_at: now,
page_key: '/comment.html',
};
const newMsg = {
id: 15632,
content: '测试留言:这个聊天气泡样式的邮件通知看起来真不错!',
created_at: now,
page_key: '/comment.html',
};
const site = { siteName: '优世界', siteUrl: 'https://usj.cc', pageTitle: '留言', pageUrl: 'https://usj.cc/comment.html' };
const replyHtml = replyMailHtml({
...site,
parentComment: parent,
parentNick: '扳布',
parentEmail: 'banbu@example.com',
comment: reply,
commenterNick: '小赵',
commenterEmail: 'imql@qq.com',
toNick: '扳布',
});
const adminHtml = adminMailHtml({
...site,
comment: newMsg,
commenterNick: '测试访客',
commenterEmail: 'guest@example.com',
adminNick: '小赵',
});
const fs = require('fs');
fs.mkdirSync(__dirname + '/.mail-preview', { recursive: true });
fs.writeFileSync(__dirname + '/.mail-preview/reply.html', replyHtml);
fs.writeFileSync(__dirname + '/.mail-preview/notice.html', adminHtml);
console.log('SUBJECT reply :', replyMailSubject({
...site, parentComment: parent, parentNick: 'a', parentEmail: 'a@a.com',
comment: reply, commenterNick: '小赵', commenterEmail: 'b@b.com', toNick: '扳布',
}));
console.log('SUBJECT notice:', adminMailSubject({ ...site, comment: newMsg, commenterNick: 'c', commenterEmail: 'c@c.com', adminNick: 'x' }));
console.log('OK');
+51
View File
@@ -0,0 +1,51 @@
// 探查 svc-down 状态下编辑器 bottom/header 的真实 DOM
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9405;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-pb-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await sleep(9000);
console.log(JSON.stringify({
bottom: documentBottom(),
header: documentHeader(),
}));
function documentBottom() { return null; }
function documentHeader() { return null; }
console.log(await ev(`JSON.stringify({
bottom: (document.querySelector('.atk-main-editor .atk-bottom')||{}).outerHTML?.slice(0,700)||null,
header: (document.querySelector('.atk-main-editor .atk-header')||{}).outerHTML?.slice(0,500)||null,
})`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+67
View File
@@ -0,0 +1,67 @@
// 探查 svc-down 状态下编辑器内部的渲染细节
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9404;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-probe-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await sleep(9000);
console.log(await ev(`JSON.stringify((() => {
const ed = document.querySelector('.atk-main-editor');
if (!ed) return { err: 'no editor' };
const btn = ed.querySelector('.atk-send-btn, [class*=send]');
const ta = ed.querySelector('.atk-textarea');
const cs = (el) => {
if (!el) return null;
const s = getComputedStyle(el);
return { color: s.color, bg: s.backgroundColor, opacity: s.opacity, fontSize: s.fontSize,
filter: s.filter, visibility: s.visibility, display: s.display, content: (el.innerText||'').slice(0,20) };
};
return {
btnHtml: btn ? btn.outerHTML.slice(0, 200) : null,
btnStyle: cs(btn),
btnChildren: btn ? btn.children.length : 0,
taStyle: cs(ta),
placeholder: ta ? ta.getAttribute('placeholder') : null,
headerInputs: [...ed.querySelectorAll('input')].map(i => ({ cls: i.className, ph: i.placeholder, val: i.value.slice(0,10), display: getComputedStyle(i).display })),
editorChildren: [...ed.children].map(c => c.className),
mainEditorOpacity: getComputedStyle(ed).opacity,
mainEditorFilter: getComputedStyle(ed).filter,
artalkRootOpacity: getComputedStyle(document.querySelector('.artalk') || ed.closest('.artalk-container') || ed).opacity,
};
})())`, ));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+64
View File
@@ -0,0 +1,64 @@
# Artalk 配置(artalk-cf 兼容实现 · 存于 D1 settings 表)
# 服务端识别的键:frontend / captcha / moderator.pending_default / ip_region / site_default
# 邮件相关(email / admin_notify)在 Workers 上走 Resend HTTP API,由 secret 配置,
# 这里的段落仅作参考存档。
# 默认站点名(前台 site 参数必须与此一致)
site_default: 优世界
# 审核
moderator:
# 新评论默认待审
pending_default: false
# 验证码(Workers 无 TCP,turnstile 校验可走 HTTP,暂未启用)
captcha:
enabled: false
always: false
# IP 归属地(回显 Cloudflare 边缘给出的国家码)
ip_region:
enabled: true
# 前台 UI
frontend:
placeholder: ""
noComment: ""
sendBtn: "评论一下"
editorTravel: true
emoticons: https://usj.cc/emotion/OwO.json
vote: true
voteDown: false
uaBadge: false
listSort: true
preview: true
flatMode: false
darkMode: inherit
nestMax: 2
nestSort: DATE_ASC
gravatar:
mirror: https://weavatar.com/avatar/
params: d=mp&s=240
pagination:
pageSize: 20
readMore: true
autoLoad: true
heightLimit:
content: 10000
children: 10000
scrollable: true
imgLazyLoad: data-src
reqTimeout: 15000
versionCheck: false
pluginURLs: []
# 邮件(仅参考;实际发信走 Resend:RESEND_API_KEY / MAIL_FROM / MAIL_ADMIN)
email:
enabled: true
send_name: "书呈{{parent_comment.nick}}台鉴:"
mail_subject: "[{{site_name}}] 您收到了来自 @{{reply_nick}} 的回信"
admin_notify:
email:
enabled: true
mail_subject: '[{{site_name}}] 其中 "{{page_title}}" 页面有一条新的评论!'
+117
View File
@@ -0,0 +1,117 @@
/**
* 自检:不依赖 wrangler / 网络,直接验证最容易出错的那几块纯逻辑。
* node node_modules/.bin/esbuild tools/selftest.ts --bundle --platform=node --format=esm --outfile=tools/.selftest.mjs && node tools/.selftest.mjs
*/
import { md5, md5Lower } from '../src/lib/md5';
import { formatDateCN } from '../src/lib/util';
import { renderMarkdown } from '../src/lib/md';
import { hashPassword, verifyPassword, signToken, verifyToken } from '../src/lib/session';
import { parseSimpleYaml } from '../src/routes/admin';
import type { Env } from '../src/types';
let pass = 0;
let fail = 0;
function eq(name: string, actual: unknown, expected: unknown) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a === e) {
pass++;
console.log(` PASS ${name}`);
} else {
fail++;
console.log(` FAIL ${name}\n 实际: ${a}\n 期望: ${e}`);
}
}
function ok(name: string, cond: boolean) {
eq(name, cond, true);
}
// ------------------------------------------------------------------ MD5
console.log('== MD5(Gravatar hash,必须与官方一致) ==');
eq('md5("")', md5(''), 'd41d8cd98f00b204e9800998ecf8427e');
eq('md5("abc")', md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
eq('md5("test@example.com")', md5('test@example.com'), '55502f40dc8b7c769880b10874abc9d0');
eq('md5("z@usj.cc")', md5('z@usj.cc'), '1cce0a22c2c7648eab76ec876c6a54d9');
eq('md5("优世界") 多字节', md5('优世界'), '7fc1dce97eead18fa9e9ae4fbb8044cb');
eq('md5 长输入(跨 block)', md5('a'.repeat(200)), '887f30b43b2867f4a9accceee7d16e6c');
eq('md5Lower 去空格+小写', md5Lower(' Z@USJ.CC '), '1cce0a22c2c7648eab76ec876c6a54d9');
async function main() {
// ------------------------------------------------------------------ 时间
console.log('== 时间格式(Asia/Shanghai) ==');
eq('1631363636000', formatDateCN(1631363636000), '2021-09-11 20:33:56');
eq('0', formatDateCN(0), '1970-01-01 08:00:00');
// ------------------------------------------------------------------ Markdown
console.log('== Markdown 渲染 ==');
ok('<script> 被转义', !renderMarkdown('<script>alert(1)</script>').includes('<script>'));
ok('img onerror 被转义', !renderMarkdown('<img src=x onerror=alert(1)>').includes('onerror='));
ok('javascript: 链接被剔除', !renderMarkdown('[x](javascript:alert(1))').includes('javascript:'));
ok('**粗体**', renderMarkdown('**hi**').includes('<strong>hi</strong>'));
ok('`行内代码`', renderMarkdown('`code`').includes('<code>code</code>'));
ok('裸链接可点', renderMarkdown('见 https://usj.cc 一游').includes('<a href="https://usj.cc"'));
ok('单换行 → <br>', renderMarkdown('a\nb').includes('a<br>b'));
ok('``` 代码块', renderMarkdown('```js\nlet a=1;\n```').includes('<pre><code class="language-js">'));
ok('& 被转义', renderMarkdown('a & b').includes('a &amp; b'));
ok('含单引号的正文不炸', renderMarkdown("it's fine").includes('it&#39;s fine'));
// ------------------------------------------------------------------ 密码
console.log('== 密码(PBKDF2 / md5 / 明文兼容) ==');
const hashed = await hashPassword('s3cret-密码');
ok('hash 前缀是 (pbkdf2)', hashed.startsWith('(pbkdf2)'));
ok('正确密码通过', await verifyPassword(hashed, 's3cret-密码'));
ok('错误密码拒绝', !(await verifyPassword(hashed, 'wrong')));
ok('空密码拒绝', !(await verifyPassword('', '')));
ok('兼容 (md5) 旧密码', await verifyPassword('(md5)1cce0a22c2c7648eab76ec876c6a54d9', 'z@usj.cc'));
ok('兼容明文旧密码', await verifyPassword('plain', 'plain'));
ok('(bcrypt) 明确不支持', !(await verifyPassword('(bcrypt)$2a$10$xxx', 'whatever')));
// ------------------------------------------------------------------ Token
console.log('== 登录 token(HMAC 无状态) ==');
const env = { TOKEN_SECRET: 'test-secret-please-change' } as Env;
const token = await signToken(env, 42);
ok('token 是两段式', token.split('.').length === 2);
const payload = await verifyToken(env, token);
eq('解出的 uid', payload?.uid, 42);
ok(
'改一位就失效',
!(await verifyToken(env, token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA'))),
);
ok('换密钥就失效', !(await verifyToken({ TOKEN_SECRET: 'other' } as Env, token)));
ok('乱串被拒', !(await verifyToken(env, 'not-a-token')));
ok('空串被拒', !(await verifyToken(env, '')));
// ------------------------------------------------------------------ YAML
console.log('== 设置模板 YAML 解析 ==');
const tpl = `host: "0.0.0.0"
port: 23366
captcha:
enabled: false
mode: off # 关闭验证码
moderator:
pending_default: true
ip_region:
enabled: false
frontend:
pvAdd: true
locale: zh-CN
pluginURLs: []
site_default: 优世界
`;
const parsed = parseSimpleYaml(tpl) as any;
eq('顶层字符串', parsed.host, '0.0.0.0');
eq('顶层数字', parsed.port, 23366);
eq('嵌套布尔', parsed.captcha.enabled, false);
eq('行内注释被剔除', parsed.captcha.mode, 'off');
eq('嵌套布尔2', parsed.moderator.pending_default, true);
eq('三层取值', parsed.frontend.pvAdd, true);
eq('中文值', parsed.site_default, '优世界');
eq('空数组', parsed.frontend.pluginURLs, []);
console.log(`\n通过 ${pass} / 失败 ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
void main();
+45
View File
@@ -0,0 +1,45 @@
// 浅色模式截图验证(切换 data-theme 后截图)
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9406;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[2] || 'light.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-light-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await sleep(9000);
await ev(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(800);
const s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
console.log('btn:', await ev(`JSON.stringify((() => { const b = document.querySelector('.atk-send-btn'); const s = getComputedStyle(b); return { text: b.textContent, color: s.color, bg: s.backgroundColor, opacity: getComputedStyle(b.closest('.atk-main-editor')).opacity }; })())`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 部署后冒烟测试:把 BASE 换成你的 Worker 地址即可
# bash tools/smoke.sh https://artalk-cf.<你的子域>.workers.dev
set -uo pipefail
BASE="${1:-http://127.0.0.1:8787}"
SITE="${SITE:-优世界}"
PAGE_KEY="${PAGE_KEY:-/20210911.html}"
TOKEN="${TOKEN:-}"
# ★ 中文必须 percent-encode 再拼进 URL:直接发原始 UTF-8 字节,
# Cloudflare 边缘会把这行请求判成 400 Bad Request(空 body),看起来像服务端坏了。
# 浏览器里的 Artalk 客户端用 URLSearchParams 会自动编码,所以真实流量没这个问题。
enc() { node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"; }
SITE_Q=$(enc "$SITE")
PAGE_Q=$(enc "$PAGE_KEY")
pass=0
fail=0
chk() {
local name="$1"; shift
local expect="$1"; shift
local code
code=$(curl -s -o /tmp/atk_out -w '%{http_code}' "$@" --max-time 20)
if [ "$code" = "$expect" ]; then
printf ' \033[32mPASS\033[0m %-42s %s\n' "$name" "$code"
pass=$((pass + 1))
else
printf ' \033[31mFAIL\033[0m %-42s %s (期望 %s)\n' "$name" "$code" "$expect"
printf ' %s\n' "$(head -c 300 /tmp/atk_out)"
fail=$((fail + 1))
fi
}
echo "== 基础 =="
chk "GET /api/v2/healthz" 200 "$BASE/api/v2/healthz"
chk "GET /api/v2/version" 200 "$BASE/api/v2/version"
chk "GET /api/v2/conf" 200 "$BASE/api/v2/conf"
chk "GET /api/v2/conf/auth/providers" 200 "$BASE/api/v2/conf/auth/providers"
chk "OPTIONS 预检" 204 -X OPTIONS "$BASE/api/v2/comments" -H "Origin: https://usj.cc"
echo "== 评论读取 =="
chk "GET /api/v2/comments (page 作用域)" 200 "$BASE/api/v2/comments?page_key=$PAGE_Q&site_name=$SITE_Q&limit=5"
chk "GET /api/v2/comments (site 作用域)" 200 "$BASE/api/v2/comments?scope=site&site_name=$SITE_Q&limit=5"
chk "GET /api/v2/comments (搜索)" 200 "$BASE/api/v2/comments?scope=site&site_name=$SITE_Q&search=%E5%B0%8F%E8%B5%B5&limit=5"
chk "GET /api/v2/comments (不存在的站点)" 400 "$BASE/api/v2/comments?page_key=/x.html&site_name=__nope__"
echo "== 后台(未带 token 应 403) =="
chk "GET /api/v2/sites" 403 "$BASE/api/v2/sites"
chk "GET /api/v2/settings" 403 "$BASE/api/v2/settings"
chk "GET /api/v2/stats/site_comment" 403 "$BASE/api/v2/stats/site_comment"
chk "GET /api/v2/users/all" 403 "$BASE/api/v2/users/all"
if [ -n "$TOKEN" ]; then
echo "== 后台(带管理员 token) =="
chk "GET /api/v2/sites" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/sites"
chk "GET /api/v2/settings" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/settings"
chk "GET /api/v2/settings/template/zh-CN" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/settings/template/zh-CN"
chk "GET /api/v2/stats/site_comment" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/stats/site_comment"
chk "GET /api/v2/users/all" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/users/all"
chk "GET /api/v2/pages" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/pages"
chk "GET /api/v2/transfer/export" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/transfer/export?site_name=$SITE_Q"
fi
echo
echo "通过 $pass / 失败 $fail"
[ "$fail" -eq 0 ]
+110
View File
@@ -0,0 +1,110 @@
// 人机验证全链路自测(Node 侧算 PoW,与服务端 webcrypto 同算法)
import crypto from 'node:crypto';
const BASE = 'https://api.200181.xyz/api/v2/human';
const POW_DIFFICULTY = 4;
function sha256Hex(s) {
return crypto.createHash('sha256').update(s).digest('hex');
}
function solve(challenge, difficulty) {
const prefix = '0'.repeat(difficulty);
const t0 = Date.now();
for (let nonce = 0; nonce < 5_000_000; nonce++) {
if (sha256Hex(challenge + nonce).startsWith(prefix)) {
return { nonce, ms: Date.now() - t0, tries: nonce + 1 };
}
}
throw new Error('no solution');
}
async function j(path, init) {
const r = await fetch(BASE + path, init);
const text = await r.text();
try {
return { status: r.status, body: JSON.parse(text) };
} catch {
return { status: r.status, body: text.slice(0, 200) };
}
}
async function main() {
console.log('--- 1) GET /human/challenge');
const ch = await j('/challenge');
console.log(' ', ch.status, JSON.stringify(ch.body).slice(0, 160));
if (!ch.body || ch.body.enabled !== true) {
console.log('开关没开(human_check 不是 1),先设置 KV 再测');
return;
}
console.log('--- 2) POST /human/verify 错误 PoW(应 need_captcha)');
const bad = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: 12345,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 5000,
events: 30,
}),
});
console.log(' ', bad.status, JSON.stringify(bad.body));
console.log('--- 3) 正确 PoW + 无交互信号(应 need_click)');
const sol = solve(ch.body.challenge, POW_DIFFICULTY);
console.log(` 解出 nonce=${sol.nonce}(${sol.tries} 次尝试 / ${sol.ms}ms)`);
const mid = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: sol.nonce,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 300,
events: 0,
}),
});
console.log(' ', mid.status, JSON.stringify(mid.body));
console.log('--- 4) 蜜罐被填(应 need_captcha)');
const honey = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: sol.nonce,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 8000,
events: 12,
honeypot: 'bot@example.com',
}),
});
console.log(' ', honey.status, JSON.stringify(honey.body));
console.log('--- 5) 正确 PoW + 有交互(应 pass:true 并下发通行证)');
const okRes = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: sol.nonce,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 8000,
events: 12,
}),
});
console.log(' ', okRes.status, JSON.stringify(okRes.body));
console.log('--- 6) GET /human/status(应 pass:true,说明 KV 通行证生效)');
const st = await j('/status');
console.log(' ', st.status, JSON.stringify(st.body));
}
main().catch((e) => console.error('ERR', e.message));