归档 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
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:
1 parent
855a001046
commit
a74b3c7127
98 files changed
+15657
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 "============================================================"
|
||||
@@ -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)
|
||||
@@ -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 + '/');
|
||||
});
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}}" 页面有一条新的评论!'
|
||||
@@ -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 & b'));
|
||||
ok('含单引号的正文不炸', renderMarkdown("it's fine").includes('it'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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 ]
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user