feat: 新增多项功能并添加新工具

- 私人剪贴板新增限时直通URL生成功能
- doc_cloud_keeper新增标题复制按钮
- 新增贪蛇出动游戏工具
- 新增OSS中转直链工具
- 添加批量抽帧测试脚本
This commit is contained in:
yangxiangyuan
2026-08-11 10:22:31 +08:00
parent 91148fe064
commit 6dc79a2c12
28 changed files with 3169 additions and 6 deletions
@@ -0,0 +1,132 @@
/**
* 私人剪贴板「限时直通URL」端到端黑盒验证
* 流程:/go 拿 gate → 登录 → 上传测试图片 → 创建直链 → 无鉴权访问 → 模拟过期 → 删除清理验证
* 用法:node dev_test_scripts/debug/debug_private_clipboard_share_link.js
*/
const http = require('http')
const fs = require('fs')
const os = require('os')
const path = require('path')
const BASE = { hostname: 'localhost', port: 8976 }
// 1x1 透明 PNG
const PNG_1PX = Buffer.from('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da63fcffff3f0300050201aad0a95e0000000049454e44ae426082', 'hex')
const loadEnv = () => {
const out = {}
try {
const text = fs.readFileSync(path.join(os.homedir(), 'Toolbox_local_creds.env.local'), 'utf8')
for (const line of text.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
const eq = t.indexOf('=')
if (eq <= 0) continue
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim()
}
} catch {}
return out
}
const req = (urlPath, opts = {}) => new Promise((resolve, reject) => {
const headers = Object.assign({}, opts.headers || {})
if (opts.cookie) headers.Cookie = opts.cookie
if (opts.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json'
if (opts.body) headers['Content-Length'] = Buffer.byteLength(opts.body)
const r = http.request(Object.assign({}, BASE, { path: urlPath, method: opts.method || 'GET', headers }), (rp) => {
const chunks = []
rp.on('data', c => chunks.push(c))
rp.on('end', () => resolve({ status: rp.statusCode, headers: rp.headers, body: Buffer.concat(chunks) }))
})
r.on('error', reject)
if (opts.body) r.write(opts.body)
r.end()
})
const mergeCookies = (prev, resp) => {
const set = resp.headers['set-cookie'] || []
const map = {}
String(prev || '').split(/;\s*/).filter(Boolean).forEach(kv => { const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) })
set.forEach(c => { const kv = c.split(';')[0]; const i = kv.indexOf('='); if (i > 0) map[kv.slice(0, i)] = kv.slice(i + 1) })
return Object.keys(map).map(k => `${k}=${map[k]}`).join('; ')
}
let passed = 0
let failed = 0
const check = (name, cond, detail) => {
if (cond) { passed++; console.log(` [PASS] ${name}`) } else { failed++; console.log(` [FAIL] ${name} ${detail || ''}`) }
}
;(async () => {
const env = loadEnv()
const user = env.PRIVATE_CLIPBOARD_USERNAME || ''
const pass = env.PRIVATE_CLIPBOARD_PASSWORD || ''
if (!user || !pass) { console.error('[FAIL] env 缺少 PRIVATE_CLIPBOARD_USERNAME / PASSWORD'); process.exit(1) }
// 1. /go 拿 gate cookie
let ck = ''
let r = await req('/go?systemId=Tools-private_clipboard')
while (r.status >= 300 && r.status < 400 && r.headers.location) {
ck = mergeCookies(ck, r)
r = await req(r.headers.location, { cookie: ck })
}
ck = mergeCookies(ck, r)
check('1 /go gate 链路', /private_clipboard_gate=1/.test(ck), `cookie=${ck}`)
// 2. 登录拿业务 cookie
const login = await req('/api/private_clipboard/auth/login', { method: 'POST', cookie: ck, body: JSON.stringify({ username: user, password: pass }) })
ck = mergeCookies(ck, login)
const loginJson = JSON.parse(login.body.toString('utf8') || '{}')
check('2 业务登录', login.status === 200 && loginJson.ok === true, `status=${login.status}`)
// 3. 上传测试图片
const up = await req('/api/private_clipboard/upload?filename=share_test.png&mime=image/png', { method: 'POST', cookie: ck, body: PNG_1PX, headers: { 'Content-Type': 'image/png' } })
const upJson = JSON.parse(up.body.toString('utf8') || '{}')
check('3 上传测试图片', up.status === 200 && upJson.ok === true && upJson.id, `status=${up.status} body=${up.body.toString().slice(0, 100)}`)
const itemId = upJson.id
// 4. 创建限时直链
const sh = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: itemId }) })
const shJson = JSON.parse(sh.body.toString('utf8') || '{}')
check('4 创建限时直链', sh.status === 200 && shJson.ok === true && /^https:\/\/traesite\.umersoft\.com:8975\/share\/clip\/[a-f0-9]{32}$/.test(String(shJson.url || '')), `body=${sh.body.toString().slice(0, 160)}`)
const token = String(shJson.url || '').split('/').pop()
console.log(` 直链过期时间(北京时间): ${shJson.expires_at_text}`)
// 5. 无鉴权访问(模拟第三方浏览器,不带任何 cookie)
const pub = await req(`/share/clip/${token}`)
check('5 无鉴权访问直链', pub.status === 200 && String(pub.headers['content-type'] || '').includes('image/png'), `status=${pub.status} type=${pub.headers['content-type']}`)
// 5b. 未登录调用 share/create 必须被拒
const shNoAuth = await req('/api/private_clipboard/share/create', { method: 'POST', body: JSON.stringify({ id: itemId }) })
check('5b 未授权创建直链被拒', shNoAuth.status === 401 || shNoAuth.status === 403, `status=${shNoAuth.status}`)
// 6. 模拟过期:把 expires_at 改成过去
const Database = require(path.join(process.cwd(), 'node_modules', 'better-sqlite3'))
const db = new Database(path.join(process.cwd(), 'data', 'private_clipboard.db'))
db.prepare('UPDATE clipboard_share_links SET expires_at = ? WHERE token = ?').run(Date.now() - 1000, token)
db.close()
const expired = await req(`/share/clip/${token}`)
const expiredHtml = expired.body.toString('utf8')
check('6 过期链接返回 410 + 提示文案', expired.status === 410 && expiredHtml.includes('你的链接期限已经大于三天') && expiredHtml.includes('无法访问'), `status=${expired.status}`)
// 7. 重新生成(覆盖旧 token),再删除条目,验证 share 链接被清理
const sh2 = await req('/api/private_clipboard/share/create', { method: 'POST', cookie: ck, body: JSON.stringify({ id: itemId }) })
const sh2Json = JSON.parse(sh2.body.toString('utf8') || '{}')
const token2 = String(sh2Json.url || '').split('/').pop()
const del = await req(`/api/private_clipboard/${itemId}`, { method: 'DELETE', cookie: ck })
const delJson = JSON.parse(del.body.toString('utf8') || '{}')
check('7 删除条目', del.status === 200 && delJson.ok === true, `status=${del.status}`)
const afterDel = await req(`/share/clip/${token2}`)
check('7b 删除后直链失效', afterDel.status === 410, `status=${afterDel.status}`)
const db2 = new Database(path.join(process.cwd(), 'data', 'private_clipboard.db'))
const left = db2.prepare('SELECT COUNT(1) AS c FROM clipboard_share_links WHERE item_id = ?').get(itemId)
db2.close()
check('7c share_links 表已清理', Number(left && left.c) === 0, `remaining=${left && left.c}`)
// 8. 伪造 token
const fake = await req('/share/clip/00000000000000000000000000000000')
check('8 伪造 token 返回失效页', fake.status === 410, `status=${fake.status}`)
console.log(`\n结果: ${passed} 通过 / ${failed} 失败`)
process.exit(failed ? 1 : 0)
})().catch(e => { console.error('[ERR]', e); process.exit(1) })
@@ -0,0 +1,168 @@
/**
* OSS 中转脚本:把 oss://android-o1-images/trae-files-temp/oss-browser-win32-x64.zip
* 下载到本地 public/download_files/,成功后立即删除 OSS 对象。
*
* 密钥来源:~/Toolbox_local_creds.env.local(DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID / SECRET)
* 用法:node dev_test_scripts/runners/run_oss_browser_zip_transit.js [--keep-oss]
* --keep-oss 仅下载不删除 OSS 对象(调试用,默认下载成功后删除)
*/
const fs = require('fs')
const os = require('os')
const path = require('path')
const crypto = require('crypto')
const https = require('https')
const BUCKET = 'android-o1-images'
const ENDPOINT = 'oss-cn-shanghai.aliyuncs.com'
const OSS_PREFIX = 'trae-files-temp/'
const TARGET_NAME = 'oss-browser-win32-x64.zip'
const LOCAL_DIR = path.join(process.cwd(), 'public', 'download_files')
const LOCAL_FILE = path.join(LOCAL_DIR, TARGET_NAME)
const loadEnv = () => {
const out = {}
try {
const p = path.join(os.homedir(), 'Toolbox_local_creds.env.local')
const text = fs.readFileSync(p, 'utf8')
for (const line of text.split(/\r?\n/)) {
const t = line.trim()
if (!t || t.startsWith('#')) continue
const eq = t.indexOf('=')
if (eq <= 0) continue
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim()
}
} catch {}
return out
}
const ENV = loadEnv()
const AK = ENV.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID || ''
const SK = ENV.DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_SECRET || ''
if (!AK || !SK) {
console.error('[FAIL] 缺少 DOC_CLOUD_KEEPER_OSS_ACCESS_KEY_ID / SECRET(~/Toolbox_local_creds.env.local)')
process.exit(1)
}
const sign = (method, date, resource, contentType = '', canonicalHeaders = '') => {
const stringToSign = `${method}\n\n${contentType}\n${date}\n${canonicalHeaders ? canonicalHeaders + '\n' : ''}${resource}`
return crypto.createHmac('sha1', SK).update(stringToSign).digest('base64')
}
const ossRequest = (method, urlPath, resource) => new Promise((resolve, reject) => {
const date = new Date().toUTCString()
const req = https.request({
hostname: `${BUCKET}.${ENDPOINT}`,
path: urlPath,
method,
headers: {
Host: `${BUCKET}.${ENDPOINT}`,
Date: date,
Authorization: `OSS ${AK}:${sign(method, date, resource)}`
}
}, (res) => {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }))
})
req.on('error', reject)
req.setTimeout(120000, () => req.destroy(new Error('timeout')))
req.end()
})
const listPrefix = async (prefix) => {
const resp = await ossRequest('GET', `/?prefix=${encodeURIComponent(prefix)}&max-keys=100`, `/${BUCKET}/`)
if (resp.status !== 200) throw new Error(`OSS list 失败: ${resp.status} ${resp.body.toString('utf8').slice(0, 200)}`)
const xml = resp.body.toString('utf8')
const keys = []
let idx = 0
while (true) {
const s = xml.indexOf('<Key>', idx)
if (s < 0) break
const e = xml.indexOf('</Key>', s)
if (e < 0) break
keys.push(xml.slice(s + 5, e))
idx = e
}
return keys
}
const downloadToFile = (key, localPath) => new Promise((resolve, reject) => {
const date = new Date().toUTCString()
const resource = `/${BUCKET}/${key}`
const req = https.request({
hostname: `${BUCKET}.${ENDPOINT}`,
path: `/${key.split('/').map(encodeURIComponent).join('/')}`,
method: 'GET',
headers: {
Host: `${BUCKET}.${ENDPOINT}`,
Date: date,
Authorization: `OSS ${AK}:${sign('GET', date, resource)}`
}
}, (res) => {
if (res.statusCode !== 200) {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => reject(new Error(`OSS 下载失败: ${res.statusCode} ${Buffer.concat(chunks).toString('utf8').slice(0, 200)}`)))
return
}
const ws = fs.createWriteStream(localPath)
let received = 0
res.on('data', c => { received += c.length })
res.pipe(ws)
ws.on('finish', () => resolve(received))
ws.on('error', reject)
})
req.on('error', reject)
req.setTimeout(600000, () => req.destroy(new Error('下载超时')))
req.end()
})
const deleteObject = async (key) => {
const resource = `/${BUCKET}/${key}`
const urlPath = `/${key.split('/').map(encodeURIComponent).join('/')}`
const resp = await ossRequest('DELETE', urlPath, resource)
if (resp.status !== 204 && resp.status !== 200) {
throw new Error(`OSS 删除失败: ${resp.status} ${resp.body.toString('utf8').slice(0, 200)}`)
}
return true
}
const bjNow = () => new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' })
;(async () => {
console.log(`[${bjNow()}] 列举 ${BUCKET}/${OSS_PREFIX} ...`)
const keys = await listPrefix(OSS_PREFIX)
console.log(` 前缀下共 ${keys.length} 个对象`)
const zipKey = keys.find(k => k.endsWith('/' + TARGET_NAME) || k === TARGET_NAME)
if (!zipKey) {
console.error(`[FAIL] 未找到 ${OSS_PREFIX}${TARGET_NAME},现有对象:`)
keys.forEach(k => console.error(' - ' + k))
process.exit(1)
}
console.log(` 找到对象: ${zipKey}`)
if (!fs.existsSync(LOCAL_DIR)) fs.mkdirSync(LOCAL_DIR, { recursive: true })
const tmpFile = LOCAL_FILE + '.part'
console.log(`[${bjNow()}] 开始下载到 ${LOCAL_FILE} ...`)
const received = await downloadToFile(zipKey, tmpFile)
const size = fs.statSync(tmpFile).size
if (size < 1024 * 1024) {
fs.unlinkSync(tmpFile)
throw new Error(`下载文件过小(${size} 字节),疑似异常,已丢弃临时文件`)
}
if (fs.existsSync(LOCAL_FILE)) fs.unlinkSync(LOCAL_FILE)
fs.renameSync(tmpFile, LOCAL_FILE)
console.log(` 下载完成: ${(size / 1024 / 1024).toFixed(2)} MB`)
if (process.argv.includes('--keep-oss')) {
console.log('[SKIP] --keep-oss:保留 OSS 对象不删除')
} else {
console.log(`[${bjNow()}] 删除 OSS 对象 ${zipKey} ...`)
await deleteObject(zipKey)
console.log(' OSS 对象已删除(中转完成)')
}
console.log(`[${bjNow()}] 中转成功:本地 ${LOCAL_FILE}`)
})().catch(e => {
console.error('[FAIL]', e.message || e)
process.exit(1)
})
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""
tool_extract_snake_frames.py
从「贪蛇出动」游戏录屏视频中抽帧,用于分析玩法与抠素材。
用法:
python tool_extract_snake_frames.py # 默认每 2 秒抽 1 帧
python tool_extract_snake_frames.py 0.5 # 每 0.5 秒抽 1 帧
python tool_extract_snake_frames.py 0.2 60 70 # 每 0.2 秒抽 1 帧,只抽 60~70 秒区间
"""
import os
import sys
import cv2
VIDEO = r"D:\Trae_Files\TRAE-Toolbox\temp\a4ef87cf84a3152cb5800eae5e06e6a8.mp4"
OUT_DIR = r"D:\Trae_Files\TRAE-Toolbox\temp\snake_frames"
def main():
step = float(sys.argv[1]) if len(sys.argv) > 1 else 2.0
start = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
end = float(sys.argv[3]) if len(sys.argv) > 3 else 1e18
os.makedirs(OUT_DIR, exist_ok=True)
cap = cv2.VideoCapture(VIDEO)
if not cap.isOpened():
print("无法打开视频:", VIDEO)
sys.exit(1)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
print(f"fps={fps:.2f} total_frames={total} duration={total / fps:.1f}s step={step}s")
t = start
saved = 0
while t <= end:
cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000.0)
ok, frame = cap.read()
if not ok:
break
name = "t%07d.jpg" % int(round(t * 10)) # 文件名单位:0.1 秒
cv2.imwrite(os.path.join(OUT_DIR, name), frame, [cv2.IMWRITE_JPEG_QUALITY, 88])
saved += 1
t += step
cap.release()
print(f"saved={saved} -> {OUT_DIR}")
if __name__ == "__main__":
main()
@@ -0,0 +1,86 @@
/* 贪蛇出动 - 核心逻辑单元测试(无 DOM,Node 直接运行)
* 运行: node dev_test_scripts/unit/test_greedy_snake_core.js
*/
'use strict';
const path = require('path');
const fs = require('fs');
global.window = global; // 让模块的 window.GS 挂到全局
const JS_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'greedy_snake', 'js');
for (const f of ['config.js', 'utils.js', 'snake.js', 'ai.js', 'world.js']) {
const src = fs.readFileSync(path.join(JS_DIR, f), 'utf-8');
new Function(src)();
}
const { U, Snake, AI, World, CFG } = global.GS;
let failed = 0;
const assert = (cond, msg) => {
if (cond) console.log(' [PASS] ' + msg);
else { failed++; console.error(' [FAIL] ' + msg); }
};
console.log('== 工具函数 ==');
assert(U.fmt(276496) === '276,496', 'fmt 千分位: ' + U.fmt(276496));
assert(U.fmt(1194000) === '1,194k', 'fmt k 格式: ' + U.fmt(1194000));
assert(Math.abs(U.normAngle(Math.PI * 2)) < 1e-9, 'normAngle 归一化');
assert(U.clamp(5, 0, 3) === 3, 'clamp');
console.log('== 蛇移动与采样 ==');
const s = new Snake({ id: 'p', name: 't', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 2000, angle: 0 });
s.targetAngle = 0;
for (let i = 0; i < 120; i++) s.update(1 / 60, 0);
assert(Math.abs(s.head.x - (2000 + 130 * 2)) < 3, '直线移动距离正确: ' + (s.head.x - 2000).toFixed(1));
const segs = s.segments();
assert(segs.length === s.segCount, '身体节数=' + segs.length);
const d01 = U.dist(segs[0].x, segs[0].y, segs[1].x, segs[1].y);
assert(Math.abs(d01 - s.spacing) < 0.5, '节间距=spacing: ' + d01.toFixed(2));
console.log('== 世界/金币/碰撞 ==');
const w = new World();
assert(w.coins.length >= CFG.coinCount, '初始金币数: ' + w.coins.length);
const before = w.coins.length;
w.dropFromSegments(segs);
assert(w.coins.length > before, '尸体掉币: +' + (w.coins.length - before));
const s2 = new Snake({ id: 'p2', name: 't2', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 3000, angle: 0 });
s2.targetAngle = 0;
for (let i = 0; i < 120; i++) s2.update(1 / 60, 0); // 先走出一条身体
const bot = new Snake({ id: 'b', name: 'bot', color: { body: '#fff', dark: '#ccc' }, x: s2.head.x + 120, y: s2.head.y, score: 20, angle: Math.PI });
bot.targetAngle = Math.PI; // 朝玩家身体冲
let hit = null;
for (let i = 0; i < 60 && !hit; i++) {
s2.update(1 / 60, 0); bot.update(1 / 60, 0);
hit = w.collide(bot, [s2, bot]);
}
assert(hit !== null, '撞身体判定: ' + (hit && hit.type));
console.log('== AI + 综合模拟 600 步 ==');
const w2 = new World();
const player = new Snake({ id: 'p', name: 'me', isPlayer: true, color: { body: '#f00', dark: '#900' }, x: 2000, y: 2000 });
const bots = [];
const ais = new Map();
for (let i = 0; i < CFG.botCount; i++) {
const b = new Snake({ id: 'b' + i, name: 'bot' + i, color: { body: '#fff', dark: '#ccc' }, x: U.rand(500, 3500), y: U.rand(500, 3500), score: U.randInt(10, 200) });
bots.push(b); ais.set(b.id, new AI(b));
}
let deaths = 0, eaten0 = player.score;
for (let t = 0; t < 600; t++) {
player.targetAngle += 0.02;
for (const b of bots) ais.get(b.id).update(1 / 60, w2, [player, ...bots]);
for (const x of [player, ...bots]) x.update(1 / 60, t);
w2.update(1 / 60, [player, ...bots], t);
for (const x of [player, ...bots]) {
if (x.dead) continue;
const h = w2.collide(x, [player, ...bots]);
if (h) {
if (x.shield) { x.shield = false; continue; }
x.die(); deaths++;
}
}
}
assert(true, '模拟完成,死亡数=' + deaths + ',玩家分数 ' + eaten0 + ' -> ' + Math.floor(player.score));
assert(w2.coins.length > 0, '模拟后金币存在: ' + w2.coins.length);
assert(isFinite(player.head.x) && isFinite(player.head.y), '玩家坐标有限');
console.log(failed === 0 ? '\n全部通过' : `\n${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);