Files
Toolbox/public/tools/free_show/free_show_local_agent.js
yangxiangyuan 6a7a2b089a feat: 新增免费演出雷达工具与定投评估功能,优化周报提醒逻辑
1. 新增免费演出雷达工具:含秀动/猫眼/大麦平台抓取、本地代理鉴权配置
2. 新增建行定投评估模块:四大指数PE-TTM估值与定投档位建议
3. 新增推送告警白名单过滤功能,限制推送指定指数告警
4. 重构周报首页提醒逻辑,移至服务端处理并减少前端请求
5. 新增多套调试探针脚本与单元测试用例
2026-08-04 17:33:14 +08:00

487 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 免费演出雷达 · 本地抓取代理(零依赖单文件 · v2 常驻浏览器版)
//
// 用途:大麦对服务器 IP(尤其阿里云 ECS)风控极严,
// 本代理在家里电脑运行,用家庭宽带 IP 抓大麦,
// 工具箱页面(127.0.0.1 探测)自动把大麦请求路由到这里。
//
// 用法:node free_show_local_agent.js [端口,默认 8978] [--headless] [--cdp <端口,默认 9222>]
// 依赖:仅 Node 内置模块 + 本机已安装的 Chrome 或 Edge(无需 npm install)
//
// v2 工作方式(解决 headless 被大麦风控识别的问题):
// ① 启动代理 → 自动唤起一个【可视化】原生 Edge 窗口(默认不 headless),
// 使用持久化用户配置目录(Cookie / 登录态 / 过验证状态落盘保存);
// ② 首次使用请人工在该窗口完成滑块验证 / 登录(只需一次,会话自动保存);
// ③ 代理通过 CDP(remote-debugging-port)复用【同一个浏览器实例】执行抓取,
// 每次任务只开/关标签页,绝不新建空白浏览器环境、不重启浏览器进程;
// ④ 抓取前等待异步列表渲染完成;若检测到滑块弹窗,保留标签页并明确提示
// 「需要人工完成滑块验证」,由你在可见窗口处理后重试。
//
// 反自动化特征参数(内置):
// --disable-blink-features=AutomationControlled
// --exclude-switches=enable-automation
// ============================================================
'use strict'
const http = require('http')
const net = require('net')
const fs = require('fs')
const os = require('os')
const path = require('path')
const crypto = require('crypto')
const { spawn } = require('child_process')
// ============ CLI 参数 ============
const argv = process.argv.slice(2)
let PORT = 8978
let CDP_PORT = 9222
let HEADLESS = false // 默认可视化窗口;传 --headless 才无头(兼容旧模式)
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--headless') HEADLESS = true
else if (a === '--cdp' && argv[i + 1]) CDP_PORT = Number(argv[++i]) || 9222
else if (/^\d+$/.test(a)) PORT = Number(a)
}
const DAMAI_CITY_CODE = '310100' // 仅上海(业务约定)
const START_URL = 'https://search.damai.cn/search.htm?city=' + DAMAI_CITY_CODE
// 持久化配置目录:放在脚本同级,Cookie/登录态/验证状态长期保存
const PROFILE_DIR = path.join(__dirname, 'edge_profile_free_show')
const sleep = ms => new Promise(r => setTimeout(r, ms))
// ============ 浏览器探测 ============
function findBrowser() {
const candidates = [
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
]
for (const p of candidates) {
if (fs.existsSync(p)) return p
}
return null
}
// ============ CDP HTTP 端点 ============
function cdpHttp(method, pathAndQuery, timeoutMs) {
return new Promise((resolve, reject) => {
const req = http.request({
host: '127.0.0.1', port: CDP_PORT, path: pathAndQuery, method,
timeout: timeoutMs || 5000
}, res => {
let body = ''
res.on('data', c => { body += c })
res.on('end', () => {
try { resolve(JSON.parse(body)) } catch (e) { reject(new Error('CDP 响应解析失败')) }
})
})
req.on('timeout', () => req.destroy(new Error('CDP 连接超时')))
req.on('error', reject)
req.end()
})
}
const cdpVersion = () => cdpHttp('GET', '/json/version').catch(() => null)
// ============ 浏览器生命周期:只拉起一次,长期复用 ============
async function ensureBrowser() {
const ver = await cdpVersion()
if (ver) return { up: true, reused: true }
const browserPath = findBrowser()
if (!browserPath) throw new Error('未找到本机 Chrome/Edge,无法启动抓取浏览器')
const args = [
'--remote-debugging-port=' + CDP_PORT,
'--user-data-dir=' + PROFILE_DIR,
'--disable-blink-features=AutomationControlled', // 反自动化特征
'--exclude-switches=enable-automation', // 去掉"受自动化控制"提示条
'--no-first-run',
'--no-default-browser-check'
]
if (HEADLESS) args.push('--headless=new', '--disable-gpu')
args.push(START_URL) // 首次直接打开大麦上海列表页,方便人工过滑块/登录
const child = spawn(browserPath, args, { detached: true, stdio: 'ignore' })
child.unref() // 代理退出后浏览器继续活着,会话不丢
for (let i = 0; i < 30; i++) {
await sleep(500)
const v = await cdpVersion()
if (v) return { up: true, reused: false }
}
throw new Error('浏览器已拉起但调试端口未就绪(' + CDP_PORT + ' 被占用或启动失败)')
}
async function openTab(url) {
const p = '/json/new?' + encodeURIComponent(url)
try { return await cdpHttp('PUT', p) } catch (e) { return await cdpHttp('GET', p) }
}
const closeTab = id => cdpHttp('GET', '/json/close/' + id).catch(() => null)
// ============ 最小 WebSocket 客户端(Node 内置 net/crypto 手写,RFC6455) ============
function parseWsFrame(buf) {
if (buf.length < 2) return null
const fin = (buf[0] & 0x80) !== 0
const opcode = buf[0] & 0x0f
const masked = (buf[1] & 0x80) !== 0
let len = buf[1] & 0x7f
let off = 2
if (len === 126) {
if (buf.length < 4) return null
len = buf.readUInt16BE(2); off = 4
} else if (len === 127) {
if (buf.length < 10) return null
len = Number(buf.readBigUInt64BE(2)); off = 10
}
let mask = null
if (masked) {
if (buf.length < off + 4) return null
mask = buf.slice(off, off + 4); off += 4
}
if (buf.length < off + len) return null
let payload = buf.slice(off, off + len)
if (mask) {
payload = Buffer.from(payload)
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]
}
return { fin, opcode, payload, total: off + len }
}
function encodeWsFrame(text) {
const payload = Buffer.from(text, 'utf8')
const len = payload.length
let header
if (len < 126) header = Buffer.from([0x81, 0x80 | len])
else if (len < 65536) { header = Buffer.from([0x81, 0x80 | 126, 0, 0]); header.writeUInt16BE(len, 2) }
else { header = Buffer.from([0x81, 0x80 | 127, 0, 0, 0, 0, 0, 0, 0, 0]); header.writeBigUInt64BE(BigInt(len), 2) }
const mask = crypto.randomBytes(4)
const masked = Buffer.from(payload)
for (let i = 0; i < masked.length; i++) masked[i] ^= mask[i % 4]
return Buffer.concat([header, mask, masked])
}
function wsConnect(wsUrl) {
return new Promise((resolve, reject) => {
const u = new URL(wsUrl)
const sock = net.connect(Number(u.port) || 80, u.hostname || '127.0.0.1')
const key = crypto.randomBytes(16).toString('base64')
const handshake = [
'GET ' + (u.pathname || '/') + (u.search || '') + ' HTTP/1.1',
'Host: ' + u.host,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: ' + key,
'Sec-WebSocket-Version: 13',
'', ''
].join('\r\n')
let upgraded = false
let buf = Buffer.alloc(0)
let fragBuf = null
const handlers = { message: null, close: null }
const client = {
send(text) { try { sock.write(encodeWsFrame(text)) } catch (e) {} },
close() { try { sock.end() } catch (e) {} },
onMessage(fn) { handlers.message = fn },
onClose(fn) { handlers.close = fn }
}
sock.on('connect', () => sock.write(handshake))
sock.on('data', chunk => {
buf = Buffer.concat([buf, chunk])
if (!upgraded) {
const idx = buf.indexOf('\r\n\r\n')
if (idx < 0) return
const head = buf.slice(0, idx).toString()
if (!/^HTTP\/1\.1\s+101/.test(head)) {
reject(new Error('WebSocket 握手失败'))
sock.end()
return
}
upgraded = true
buf = buf.slice(idx + 4)
resolve(client)
}
while (true) {
const frame = parseWsFrame(buf)
if (!frame) break
buf = buf.slice(frame.total)
if (frame.opcode === 0x8) { if (handlers.close) handlers.close(); return }
if (frame.opcode === 0x9) { sock.write(Buffer.from([0x8a, 0x80, 0, 0, 0, 0])); continue } // ping→pong
if (frame.opcode === 0xA) continue // pong
if (frame.opcode === 0x1 || frame.opcode === 0x2) {
if (frame.fin) {
const full = fragBuf ? Buffer.concat([fragBuf, frame.payload]) : frame.payload
fragBuf = null
if (handlers.message) handlers.message(full.toString('utf8'))
} else fragBuf = frame.payload
} else if (frame.opcode === 0x0) {
fragBuf = fragBuf ? Buffer.concat([fragBuf, frame.payload]) : frame.payload
if (frame.fin) {
const full = fragBuf
fragBuf = null
if (handlers.message) handlers.message(full.toString('utf8'))
}
}
}
})
sock.on('error', e => { if (!upgraded) reject(e); else if (handlers.close) handlers.close() })
sock.on('close', () => { if (handlers.close) handlers.close() })
})
}
// ============ CDP 会话封装 ============
function makeCdp(ws) {
let nextId = 1
const pending = new Map()
ws.onMessage(text => {
let msg = null
try { msg = JSON.parse(text) } catch (e) { return }
if (msg && msg.id && pending.has(msg.id)) {
const cb = pending.get(msg.id)
pending.delete(msg.id)
cb(msg)
}
})
return {
send(method, params, timeoutMs) {
return new Promise((resolve, reject) => {
const id = nextId++
const timer = setTimeout(() => {
if (pending.has(id)) { pending.delete(id); reject(new Error('CDP 超时: ' + method)) }
}, timeoutMs || 30000)
pending.set(id, msg => { clearTimeout(timer); resolve(msg) })
ws.send(JSON.stringify({ id, method, params: params || {} }))
})
}
}
}
// 页面状态探测:滑块特征 / 列表渲染完成特征
const CHECK_EXPR = '(() => { try { const txt = (document.body && document.body.innerText) ? document.body.innerText.slice(0, 3000) : ""; const slider = !!(document.querySelector("#nc_1_wrapper, .nc-container, #baxia-dialog-content, #nocaptcha, .slidetounlock")) || /拖动滑块|请按照说明拖动滑块|滑动验证/.test(txt) || /rgv587|punish/.test(location.href); const ready = document.querySelectorAll("a[href*=\'detail.damai.cn\']").length > 0 || /共\\s*\\d+\\s*个商品/.test(txt); return { slider: slider, ready: ready }; } catch (e) { return { slider: false, ready: false }; } })()'
async function waitForListOrSlider(cdp, timeoutMs) {
const t0 = Date.now()
while (Date.now() - t0 < timeoutMs) {
try {
const r = await cdp.send('Runtime.evaluate', { expression: CHECK_EXPR, returnByValue: true }, 5000)
const v = r && r.result && r.result.result ? r.result.result.value : null
if (v) {
if (v.slider) return { slider: true }
if (v.ready) return { ready: true }
}
} catch (e) { /* 页面导航中 evaluate 可能短暂失败,忽略后重试 */ }
await sleep(600)
}
return { timeout: true }
}
// ============ 大麦 DOM 解析(字符串版,无 DOM 库) ============
// 价格提取:大麦列表价格形如 "130-2620元" / "380.0元" / "¥130"(无 ¥ 符号是常态)
// 区间价取下限用于 ≤ 过滤;返回 { price, priceText },提取不到返回 null
function extractPrice(win) {
let text = ''
const blockM = win.match(/class="[^"]*price[^"]*"[^>]*>([\s\S]{0,120}?)<\/(?:div|span|p|em|b|i)>/i)
if (blockM) text = blockM[1].replace(/<[^>]+>/g, '').trim()
if (!text) {
const symM = win.match(/[¥¥]\s*[\d.]+(?:\s*[-~至]\s*[\d.]+)?/)
if (symM) text = symM[0]
}
if (!text) {
const yuanM = win.match(/[\d.]+(?:\s*[-~至]\s*[\d.]+)?\s*元/)
if (yuanM) text = yuanM[0]
}
if (!text) return null
const rangeM = text.match(/([\d.]+)\s*[-~至]\s*[\d.]+/)
const singleM = text.match(/([\d.]+)/)
const low = rangeM ? parseFloat(rangeM[1]) : (singleM ? parseFloat(singleM[1]) : NaN)
if (!isFinite(low)) return null
return { price: low, priceText: text.replace(/\s+/g, '') }
}
function parseDamaiHtml(html, city) {
if (/rgv587|punish|captcha|滑动验证|拖动滑块/i.test(html)) {
throw new Error('需要人工完成滑块验证:请在自动打开的 Edge 窗口中完成滑块/登录(会话将自动保存),完成后重试')
}
const events = []
const seen = new Set()
const anchorRe = /<a\b([^>]*)>/g
let m
while ((m = anchorRe.exec(html))) {
const attrs = m[1]
const hrefM = attrs.match(/href="([^"]+)"/)
if (!hrefM) continue
let href = hrefM[1]
const idM = href.match(/id=(\d+)/)
if (!/damai\.cn/.test(href) || !idM) continue
if (href.startsWith('//')) href = 'https:' + href
const titleM = attrs.match(/title="([^"]+)"/)
let title = titleM ? titleM[1] : ''
const win = html.substr(m.index, 3000)
if (!title) {
const t2 = win.match(/<[^>]*class="[^"]*title[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/)
if (t2) title = t2[1].replace(/<[^>]+>/g, '').trim()
}
if (!title || seen.has(title)) continue
seen.add(title)
const priceInfo = extractPrice(win)
const timeM = win.match(/\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}[^<|]*/)
const imgM = win.match(/<img[^>]*src="([^"]+)"/)
events.push({
platform: 'damai',
id: idM[1],
title,
price: priceInfo ? priceInfo.price : 9999,
priceText: priceInfo ? priceInfo.priceText : '价格待定',
showTime: timeM ? timeM[0].trim() : '',
venue: '',
city,
poster: imgM ? imgM[1] : '',
url: href
})
}
return events
}
// ============ 抓取主流程:复用常驻浏览器,只开/关标签页 ============
async function fetchDamai(city, pages) {
await ensureBrowser()
const maxPages = Math.min(Number(pages) || 2, 3)
let allEvents = []
for (let p = 1; p <= maxPages; p++) {
const url = `https://search.damai.cn/search.htm?keyword=&city=${DAMAI_CITY_CODE}&ctl=0&order=1&page=${p}`
// 先开空白标签再 Page.navigate,避免对大麦页面发起两次请求
const tab = await openTab('about:blank')
if (!tab || !tab.webSocketDebuggerUrl) throw new Error('无法在常驻浏览器中打开新标签页')
let ws = null
try {
ws = await wsConnect(tab.webSocketDebuggerUrl)
const cdp = makeCdp(ws)
await cdp.send('Page.enable').catch(() => {})
await cdp.send('Runtime.enable').catch(() => {})
await cdp.send('Page.navigate', { url })
// 等待异步列表渲染完成(单页最多 20s)
const state = await waitForListOrSlider(cdp, 20000)
if (state.slider) {
// 标签页保留在可见窗口,交给人工拖滑块;会话过验证后自动落盘
try { ws.close() } catch (e) {}
return { events: allEvents, sliderNeeded: true, sliderPage: p }
}
const htmlRes = await cdp.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true })
const html = htmlRes && htmlRes.result && htmlRes.result.result ? String(htmlRes.result.result.value || '') : ''
try { ws.close() } catch (e) {}
await closeTab(tab.id)
if (!html) { if (p === 1) throw new Error('页面 DOM 为空(可能仍在加载或被拦截)'); break }
const items = parseDamaiHtml(html, city)
if (items.length === 0) break
allEvents = allEvents.concat(items)
} catch (e) {
try { if (ws) ws.close() } catch (e2) {}
await closeTab(tab.id)
throw e
}
}
return { events: allEvents, sliderNeeded: false, sliderPage: 0 }
}
// ============ HTTP 服务(仅 127.0.0.1) ============
function sendJson(res, code, obj) {
const body = JSON.stringify(obj)
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
})
res.end(body)
}
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, `http://127.0.0.1:${PORT}`)
if (req.method === 'OPTIONS') return sendJson(res, 204, {})
if (u.pathname === '/ping') {
const ver = await cdpVersion()
return sendJson(res, 200, {
ok: true,
agent: 'free_show_local_agent',
version: 2,
browser: !!findBrowser(),
browser_up: !!ver,
headless: HEADLESS,
cdp_port: CDP_PORT,
profile_dir: PROFILE_DIR
})
}
if (u.pathname === '/fetch' && req.method === 'GET') {
const city = String(u.searchParams.get('city') || '上海')
const pages = u.searchParams.get('pages') || 2
if (city !== '上海') return sendJson(res, 400, { success: false, error: '本地代理仅支持上海' })
try {
const result = await fetchDamai(city, pages)
if (result.sliderNeeded && result.events.length === 0) {
return sendJson(res, 200, {
success: false,
code: 'need_manual_slider',
error: '需要人工完成滑块验证:请在自动打开的 Edge 窗口中完成滑块/登录(会话将自动保存),完成后重试'
})
}
const warning = result.sliderNeeded
? ('第 ' + result.sliderPage + ' 页起触发滑块验证,该标签页已保留在 Edge 窗口,请人工处理后重试以补齐后续页')
: ''
return sendJson(res, 200, {
success: true, platform: 'damai', city,
newCount: result.events.length, events: result.events, warning
})
} catch (e) {
return sendJson(res, 500, { success: false, error: String(e.message || e) })
}
}
return sendJson(res, 404, { ok: false, error: 'not_found' })
})
server.listen(PORT, '127.0.0.1', () => {
console.log('==================================================')
console.log(' 免费演出雷达 · 本地抓取代理 v2(常驻浏览器版)')
console.log(` 监听: http://127.0.0.1:${PORT} (仅本机)`)
console.log(` 浏览器调试端口: ${CDP_PORT} | 模式: ${HEADLESS ? 'headless' : '可视化窗口'}`)
console.log(' 浏览器: ' + (findBrowser() || '未找到 Chrome/Edge!'))
console.log(' 配置目录: ' + PROFILE_DIR)
console.log(' ------------------------------------------------')
console.log(' 正在自动唤起 Edge 窗口...')
console.log(' 首次使用请在该窗口人工完成滑块验证/登录(一次即可,会话自动保存)。')
console.log(' 之后回工具箱页面点「Go」,代理复用同一浏览器抓取。')
console.log(' 保持本窗口开启;Ctrl+C 退出(浏览器会继续保留会话)')
console.log('==================================================')
// 启动即拉起浏览器(用户要求①:启动代理自动唤起可视化 Edge),
// 失败不阻塞 HTTP 服务,首次 /fetch 时会再重试
ensureBrowser()
.then(info => console.log(info.reused
? ' 已复用正在运行的 Edge 实例(调试端口 ' + CDP_PORT + ')'
: ' Edge 窗口已打开,可在其中人工过滑块/登录'))
.catch(e => console.log(' [警告] 自动唤起浏览器失败: ' + (e.message || e) + '(首次抓取时会重试)'))
})