同步用户更新

This commit is contained in:
hani committed 2026-09-28 20:40:29 +08:00
1 parent 0d21610f6c
commit 8e71c2ee7a
5 files changed
+210 -6

No files matched your search

+37 -6
View File
@@ -2131,6 +2131,25 @@ const BOILERPLATE_MSG = [
'今日已签到', '签到成功', '签到失败', '请求成功', '领取成功', '签到完毕',
];
// 「网站回馈」那一行该显示什么?
//
// 用户看到的原话:*「反馈中的英文代码要去掉」* —— 比如日志里那行
// 网站回馈:网站返回:{"success":false,"message":"今天已完成签到,请勿重复操作"}
// 主文案已经写着「今天已完成签到,请勿重复操作」了,再把整串 JSON 摆一遍只有两种效果:
// 占地方、看着像报错(用户甚至会以为签到失败了)。所以:
// ① 能从原文里抽出网站自己那句话的:
// · 和主文案一样 → 不重复占位(鼠标移上去仍能看完整原文);
// · 和主文案不一样 → 就显示那句话本身(不摆花括号);
// ② 抽不出话的(只有技术字段的纯 JSON)→ 不摆出来;
// ③ 不是 JSON 的(HTTP 403、WAF 指纹、网站给的细节)→ 照旧显示,那是排障唯一的线索。
function rawFeedbackLine(raw, shownMsg) {
const t = decodeEscapes(String(raw == null ? '' : raw)).replace(/^网站返回:/, '').trim();
if (!t) return '';
const own = siteOwnMessage(t);
if (own) return own === String(shownMsg == null ? '' : shownMsg).trim() ? '' : own;
return /^[[{]/.test(t) ? '' : t;
}
// 从网站原文里抠出网站自己的那句话(JSON 的 message / msg / errmsg / status_msg…)
function siteOwnMessage(raw) {
const m = String(raw || '').match(/"(?:message|msg|errmsg|info|status_msg)"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
@@ -2261,7 +2280,10 @@ async function loadAccounts() {
const own = siteOwnMessage(rawText);
if (own && own !== msg) { msgShow = own; bySite = true; }
}
const rawShow = rawText && rawText !== msgShow ? rawText.slice(0, 220) : '';
// 网站回馈那一行(英文 JSON 不摆出来;真需要看原文时,鼠标移到主文案上有气泡)
const rawClean = rawFeedbackLine(a.last_detail, msgShow);
const rawShow = rawClean && rawClean !== msgShow ? rawClean.slice(0, 220) : '';
const rawTip = !rawShow && rawText ? ` data-tip="网站原始返回:${esc(rawText.slice(0, 600))}"` : '';
// 换路记录只在「真的换过一条路线」时展示(没有换路时它只是一句「路线:CF 直连」,
// 那是站点徽章已经在说的事,不必再占一行)。
const routeShow = /自动改走|都没成功/.test(routeNote) ? routeNote : '';
@@ -2273,14 +2295,17 @@ async function loadAccounts() {
// 页面上显示时去掉「建议:/说明:」前缀(前面的 💡 已经说明它是提示了),
// 顺手去掉 markdown 强调符 —— 完整原文仍然留在悬浮气泡里。
const hintShow = String(hintText).replace(/^(?:建议|说明):/, '').replace(/\*\*/g, '').trim();
return `
// data-label 是给窄屏用的:小屏上表格会变成一张张卡片(每格前面带列名),
// 手机上不用再左右拖表了。
// 【踩坑】这两行注释以前写在 `return \`` 的下一行 —— 那就成了模板字符串的**内容**,
// 每一行账号都会把这段注释渲染成文字(表格里还会被浏览器提到表格上方),
// 排版被撑得乱七八糟。注释必须在 return 之前。
return `
<tr>
<td class="site-cell" data-label="站点" data-exec-cell="${a.id}">${execCell}</td>
<td class="c" data-label="全局签到"><div class="sched-cell"><div data-tp-acc="${a.id}" title="点一下给这个账号单独设时间;不点就一直跟全局(当前全局签到 ${GLOBAL_HOUR})"></div></div></td>
<td class="c" data-label="状态">${statusBadge}</td>
<td class="msg" data-label="网站反馈"><div class="msg-main">${msgShow ? esc(msgShow) : '-'}${bySite ? '<span class="msg-by">网站原话</span>' : ''}</div>${rawShow ? `<div class="msg-raw" data-tip="网站原始返回:${esc(rawText.slice(0, 600))}">${esc(rawShow)}</div>` : ''}${routeShow ? `<div class="msg-route" data-tip="${esc(routeShow)}">${esc(routeShow)}</div>` : ''}${hintText ? `<div class="msg-foot"><span class="hint-tip" data-tip="${esc(hintText)}">${esc(hintShow)}</span></div>` : ''}</td>
<td class="msg" data-label="网站反馈"><div class="msg-main"${rawTip}>${msgShow ? esc(msgShow) : '-'}${bySite ? '<span class="msg-by">网站原话</span>' : ''}</div>${rawShow ? `<div class="msg-raw" data-tip="网站原始返回:${esc(rawText.slice(0, 600))}">${esc(rawShow)}</div>` : ''}${routeShow ? `<div class="msg-route" data-tip="${esc(routeShow)}">${esc(routeShow)}</div>` : ''}${hintText ? `<div class="msg-foot"><span class="hint-tip" data-tip="${esc(hintText)}">${esc(hintShow)}</span></div>` : ''}</td>
<td class="muted c mono" data-label="上次运行">${fmtTime(a.last_run_at)}</td>
<td class="c" data-label="启用"><button class="switch ${a.enabled ? 'on' : ''}" data-toggle="${a.id}" title="启用/停用"></button></td>
@@ -3113,14 +3138,20 @@ async function loadLogs() {
const { runs } = await api('/api/runs?' + q.toString());
const tb = $('logs-body');
if (!runs.length) { tb.innerHTML = '<tr><td colspan="5" class="muted">暂无符合条件的记录。</td></tr>'; return; }
tb.innerHTML = runs.map((r) => `
tb.innerHTML = runs.map((r) => {
// 网站回馈那一行:英文 JSON 不摆出来(主文案已经是网站那句话时它就是重复的),
// 真需要看原文时鼠标移上去有气泡。详见 rawFeedbackLine 的说明。
const detailLine = rawFeedbackLine(r.detail, r.message);
const rawTip = !detailLine && r.detail ? ` data-tip="网站原始返回:${esc(String(r.detail).slice(0, 600))}"` : '';
return `
<tr>
<td class="muted" style="white-space:nowrap">${fmtTime(r.created_at)}</td>
<td><b>${esc(r.name)}</b> <span class="badge site">${esc(siteName(r.site))}</span></td>
<td>${r.status === 'ok' ? '<span class="badge ok">成功</span>' : r.status === 'skip' ? '<span class="badge skip">跳过</span>' : '<span class="badge fail">失败</span>'}</td>
<td class="muted" style="white-space:nowrap">${r.duration_ms != null ? (r.duration_ms / 1000).toFixed(1) + 's' : '-'}</td>
<td class="msg">${esc(r.message || '')}${r.detail ? `<br><span class="muted">网站回馈:${esc(r.detail)}</span>` : ''}</td>
</tr>`).join('');
<td class="msg"${rawTip}>${esc(r.message || '')}${detailLine ? `<br><span class="muted">网站回馈:${esc(detailLine)}</span>` : ''}</td>
</tr>`;
}).join('');
}
$('btn-refresh-logs').onclick = loadLogs;
$('btn-clear-logs').onclick = async () => {
+26
View File
@@ -160,4 +160,30 @@ t('执行路线自动选路:徽章显示「实际路线」,换过路要写
assert.match(html, /hutue: '糊涂鳄的签到接口从 CF 网络直连正常/, '糊涂鳄要改成「直连正常」的说法');
});
t('反馈里不摆英文代码,账号行模板里也不能夹注释(会被当成页面内容)', () => {
// 用户看到的:日志里那行「网站回馈:网站返回:{"success":false,"message":"…"}」——
// 主文案已经写着那句话了,再摆一串 JSON 只是占地方 + 看着像报错。
assert.match(html, /function rawFeedbackLine\(raw, shownMsg\)/, '要有 rawFeedbackLine');
assert.match(html, /const rawClean = rawFeedbackLine\(a\.last_detail, msgShow\)/, '账号行要用它');
assert.match(html, /const detailLine = rawFeedbackLine\(r\.detail, r\.message\)/, '运行日志也要用它');
assert.match(html, /网站回馈:\$\{esc\(detailLine\)\}/, '日志里的网站回馈要用清理后的文本');
assert.doesNotMatch(html, /网站回馈:\$\{esc\(r\.detail\)\}/, '不能把整串 JSON 直接摆出来');
assert.match(html, /const rawTip = !rawShow && rawText \?/, '不摆出来时也要留一个悬浮看原文的入口');
// 曾经踩过:注释写在 `return \`` 的下一行 → 成了模板内容,每行账号都把它渲染成文字
const at = html.indexOf('tb.innerHTML = accounts.map((a) => {');
assert.ok(at > 0, '找不到账号行模板');
const seg = html.slice(at, at + 9000);
const ret = seg.indexOf('return `');
assert.ok(ret > 0, '账号行模板里找不到 return `');
const firstLine = (seg.slice(ret + 'return `'.length).split('\n')[1] || '').trim();
assert.match(firstLine, /^<tr>/, '账号行模板第一行必须是 <tr>,实际:' + firstLine.slice(0, 40));
// 这个坑单独立了一条自检(语法检查发现不了:它本来就是合法字符串)
const checkTool = readFileSync(join(root, 'tools', 'check-template-comments.mjs'), 'utf8');
assert.match(checkTool, /模板字符串里出现了/, '自检工具要能报出泄漏');
const verifySrc = readFileSync(join(root, 'tools', 'verify.mjs'), 'utf8');
assert.match(verifySrc, /check-template-comments\.mjs/, '自检要接进 verify.mjs');
});
console.log(`\n${n} 通过`);
+95
View File
@@ -0,0 +1,95 @@
// 扫描 public/index.html 的内联 JS,找出「写在模板字符串里的 // 注释」。
//
// 为什么要单独立一条检查:
// 账号表格的每一行都是用模板字符串拼出来的。注释一旦写在 `return \`` 的**下一行**,
// 它就不再是注释,而成了这段 HTML 的**内容**:每一行账号都会把那段注释渲染成文字
// (表格里还会被浏览器提到表格上方),整页排版立刻被撑乱 ——
// 而 `new Function()` 语法检查完全看不出问题(它是合法字符串)。
//
// 用法:node tools/check-template-comments.mjs [文件]
// 退出码:0=干净;1=发现泄漏(打印行号与内容)
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const file = process.argv[2] || join(root, 'public', 'index.html');
const src = readFileSync(file, 'utf8');
// 只检查内联 <script>(末尾那个),别去扫 HTML 文本 —— HTML 里的 // 是正常内容
const m = src.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/);
if (!m) {
console.log('找不到内联脚本(跳过)');
process.exit(0);
}
const script = m[1];
const offset = m.index + m[0].indexOf(script); // 换算回整份文件的行号
// 极简词法:state = code | line | block | sq | dq | tpl
// 模板字符串里遇到 ${ 要进一层 code(里面还能再嵌字符串/模板),所以用栈记层。
const stack = ['code'];
const leaks = [];
let line = 1;
let lineStart = 0;
const atLineStartNoCode = () => stack[stack.length - 1] === 'tpl';
for (let i = 0; i < script.length; i++) {
const c = script[i];
const top = stack[stack.length - 1];
if (c === '\n') {
if (top === 'line') stack.pop(); // 行注释到行尾结束
line++;
lineStart = i + 1;
continue;
}
if (top === 'line') continue;
if (top === 'block') {
if (c === '*' && script[i + 1] === '/') { stack.pop(); i++; }
continue;
}
if (top === 'sq' || top === 'dq') {
if (c === '\\') { i++; continue; }
if ((top === 'sq' && c === "'") || (top === 'dq' && c === '"')) stack.pop();
continue;
}
// 模板字符串内部:只认结束反引号与 ${(其余都是文本)
if (top === 'tpl') {
if (c === '\\') { i++; continue; }
if (c === '`') { stack.pop(); continue; }
if (c === '$' && script[i + 1] === '{') { stack.push('code'); i++; continue; }
// 在一行里(前面只有空白)出现 // → 这行注释会被当成内容渲染出去
if (c === '/' && script[i + 1] === '/' && script.slice(lineStart, i).trim() === '') {
let end = script.indexOf('\n', i);
if (end < 0) end = script.length;
const raw = script.slice(lineStart, end);
const ln = src.slice(0, offset + lineStart).split('\n').length;
leaks.push({ ln, text: raw.trim().slice(0, 100) });
i = end - 1;
}
continue;
}
// code
if (c === '/' && script[i + 1] === '/') { stack.push('line'); i++; continue; }
if (c === '/' && script[i + 1] === '*') { stack.push('block'); i++; continue; }
if (c === "'") { stack.push('sq'); continue; }
if (c === '"') { stack.push('dq'); continue; }
if (c === '`') { stack.push('tpl'); continue; }
if (c === '}') {
// 关掉一层 ${…}:只在「这层是 code 且不是最外层」时弹栈
if (stack.length > 1) stack.pop();
continue;
}
}
void atLineStartNoCode;
if (leaks.length) {
console.log('❌ 模板字符串里出现了 ' + leaks.length + ' 行 // 注释(会被当成内容渲染到页面上):');
for (const l of leaks) console.log(' 第 ' + l.ln + ' 行:' + l.text);
process.exit(1);
}
console.log('✅ 没有模板字符串内的注释泄漏');
+3
View File
@@ -36,6 +36,9 @@ run([join('tools', 'check-html.mjs')], 'index.html');
step('DOM 引用检查(拦住「$() 返回 null → 整段脚本断掉」这类崩溃)');
run([join('tools', 'check-dom-refs.mjs')], 'index.html');
step('内联 JS 模板字符串:不许把 // 注释当成页面内容渲染出去');
run([join('tools', 'check-template-comments.mjs')], 'index.html');
step('index.html 内联 JS 语法');
{
const html = readFileSync(join(root, 'public', 'index.html'), 'utf8');
+49
View File
@@ -1214,3 +1214,52 @@ if (CAPTCHA.some((m) => all.includes(m))) return { ok:false, message:'遇到人
这次两处都改成了「没有正面证据就不说成功」。
3. **自动行为要能被看见**:客户端在等挑战、面板在换路线,这些后台动作必须在反馈里留一句话,
否则用户只会看到自相矛盾的两行字。
## 二十七、2026-09-28:两处「看着像出错」的显示问题
用户原话:*「// data-label 是给窄屏用的…这个显示要去掉」* 和
*「反馈中的英文代码要去掉,比如日志中 网站回馈:网站返回:{"success":false,"message":"今天已完成签到,请勿重复操作"}」*。
### 27.1 注释被当成页面内容渲染出来了
账号表格每行都是模板字符串拼的,而这段注释**写在 `return \`` 的下一行** —— 它就成了模板的**内容**:
```js
return `
// data-label 是给窄屏用的…… ← 这两行会变成页面上的一行行文字
<tr>
```
每行账号都吐一遍这段注释,表格里的裸文字还会被浏览器「搬」到表格上方,整页排版立刻被撑乱。
`new Function()` 语法检查对此完全无感(它本来就是合法字符串),所以之前一路绿灯。
修法:注释移到 `return` 之前。并顺手加了一条**专门的自检**
`tools/check-template-comments.mjs`(接进 `verify.mjs`):它用一个极简词法扫内联脚本,
维护 `code / tpl / ${} / 字符串 / 注释` 的栈,凡是「模板字符串里、行首只有空白的 `//`」一律报出来。
负向验证过:把注释注回去,它能准确报出行号(语法检查发现不了的那一类)。
### 27.2 反馈里不该整串 JSON 摆出来
原来的那行是:`网站回馈:网站返回:{"success":false,"message":"今天已完成签到,请勿重复操作"}` ——
主文案已经写着「今天已完成签到,请勿重复操作」了,再把整串摆一遍只有两种效果:占地方、看着像报错。
新增 `rawFeedbackLine(raw, shownMsg)`(账号行与运行日志共用):
| 情况 | 显示什么 |
|---|---|
| 能抽出网站那句话,且和主文案**一样** | 不占位(鼠标移到主文案上仍能看到完整原文) |
| 能抽出网站那句话,但和主文案**不一样** | 显示那句话本身(不摆花括号) |
| 只有技术字段的纯 JSON | 不摆出来 |
| 不是 JSON(HTTP 403、WAF 指纹、接口名…) | 照旧显示 —— 那是排障唯一的线索 |
于是 NodeSeek 那一行从「一行中文 + 一行 JSON」变成**只有一行中文**,
吾爱破解那种「403 Forbidden Client IP … reason:UrlACL」仍然完整保留(去掉它就没人知道为什么签不上了)。
### 27.3 教训
1. **模板字符串不是注释能安身的地方**:写 HTML 的模板里,注释必须放 `return` 之前。
这类 bug 语法检查抓不到,得有自己的检查(已加)。
2. **「原始数据」不等于「有用的反馈」**:raw 是给排障的,不是给首页看的。
同一个字段,展示层要按「读者能不能用上」做一次取舍 —— 而取舍的原则是
**保留能解释「为什么」的(HTTP 状态、WAF 指纹),去掉只是重复的(同一句 JSON)**。
3. **隐藏不等于删掉**:不摆出来的原文放进悬浮气泡,谁要排障谁去拿。