/* * 快音 CK 抓取并更新青龙 KYAPP - Quantumult X Rewrite * * 用法: * 1. 在 Quantumult X 添加重写订阅: * https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/conf/kyapp_qinglong.conf * 2. 配置以下 QX 变量(建议放 [general] geo_location_checker 或 BoxJs/我的变量管理方式;不同客户端变量入口略有差异): * ql_host 青龙地址,例如 http://192.168.2.66:5700 * ql_client_id 青龙应用 client_id * ql_client_secret 青龙应用 client_secret * kyapp_seed 可选:预置完整 KYAPP JSON;不配置时会从当前青龙 KYAPP 读取并合并 * 3. 开启 MitM 并信任证书,打开快音 App 触发 passport/get_token 与 task/get_task_page_info。 * * 注意: * - 本脚本会把抓到的 device_id/token_body 与 txAmount=3 合并进青龙环境变量 KYAPP。 * - 不把 CK 上传到除你配置的青龙地址以外的地方。 */ const ENV_NAME = 'KYAPP' const ENV_REMARK = '快音 CK - Quantumult X 自动更新' function getPref(key, def) { try { const v = $prefs.valueForKey(key) return v === undefined || v === null || v === '' ? def : v } catch (e) { return def } } function setPref(key, val) { try { return $prefs.setValueForKey(String(val), key) } catch (e) { return false } } function notify(sub, msg) { $notify('快音 KYAPP', sub || '', msg || '') } function done() { $done({}) } function trimSlash(s) { return String(s || '').trim().replace(/\/+$/, '') } function getHeader(headers, name) { const lower = String(name).toLowerCase() for (const k in (headers || {})) if (String(k).toLowerCase() === lower) return headers[k] return '' } function parseJson(s, def) { try { return JSON.parse(s) } catch (e) { return def } } function b64urlDecode(seg) { let s = String(seg || '').replace(/-/g, '+').replace(/_/g, '/') while (s.length % 4) s += '=' try { return decodeURIComponent(escape(atob(s))) } catch (e) { try { return atob(s) } catch (_) { return '' } } } function decodeJwt(token) { const parts = String(token || '').split('.') if (parts.length < 2) return null return parseJson(b64urlDecode(parts[1]), null) } function tokenLooksValid(token) { return /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(String(token || '').trim()) } function mask(s) { s = String(s || '') if (s.length <= 12) return s ? '***' : '' return s.slice(0, 6) + '...' + s.slice(-6) } function normalizeHost(host) { host = trimSlash(host) if (!host) return '' if (!/^https?:\/\//i.test(host)) host = 'http://' + host return host } function qxRequest(opts) { return new Promise((resolve, reject) => { $task.fetch(opts).then(resp => resolve(resp), err => reject(err)) }) } function loadLocalRecords() { const s = getPref('kyapp_records', '[]') const arr = parseJson(s, []) return Array.isArray(arr) ? arr : [] } function saveLocalRecords(arr) { setPref('kyapp_records', JSON.stringify(arr)) } function mergeRecord(arr, rec) { const out = Array.isArray(arr) ? arr.slice() : [] const idx = out.findIndex(x => x && x.device_id === rec.device_id) const clean = { device_id: rec.device_id, token_body: rec.token_body, txAmount: 3 } if (idx >= 0) out[idx] = Object.assign({}, out[idx], clean) else out.push(clean) return out } function parseBody(body) { const raw = String(body || '') const obj = parseJson(raw, null) if (obj && typeof obj === 'object') return obj const form = {} raw.split('&').forEach(p => { const i = p.indexOf('=') if (i > 0) { const k = decodeURIComponent(p.slice(0, i)) const v = decodeURIComponent(p.slice(i + 1)) form[k] = v } }) return form } function extractTokenFromBody(body) { const obj = parseBody(body) const candidates = [] function walk(v) { if (v === undefined || v === null) return if (typeof v === 'string') { if (tokenLooksValid(v)) candidates.push(v.trim()) return } if (Array.isArray(v)) return v.forEach(walk) if (typeof v === 'object') Object.keys(v).forEach(k => walk(v[k])) } ;['token_body', 'token', 'access_token', 'authorization', 'auth', 'jwt'].forEach(k => walk(obj[k])) walk(obj) return candidates[0] || '' } function extractDeviceId(headers, body) { const names = ['device_id', 'device-id', 'deviceid', 'x-device-id', 'Device-Id', 'Deviceid'] for (const n of names) { const v = getHeader(headers, n) if (v) return String(v).trim() } const obj = parseBody(body) for (const k of ['device_id', 'deviceId', 'deviceid', 'device-id']) if (obj[k]) return String(obj[k]).trim() return '' } function getPending() { return { device_id: getPref('kyapp_pending_device_id', ''), token_body: getPref('kyapp_pending_token_body', '') } } function setPending(p) { if (p.device_id) setPref('kyapp_pending_device_id', p.device_id) if (p.token_body) setPref('kyapp_pending_token_body', p.token_body) } async function getQlToken(host, clientId, clientSecret) { const url = `${host}/open/auth/token?client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}` const resp = await qxRequest({ url, method: 'GET' }) const obj = parseJson(resp.body || '', {}) const token = obj && obj.data && obj.data.token if (!token) throw new Error(`青龙授权失败 HTTP ${resp.statusCode}: ${String(resp.body || '').slice(0, 120)}`) return token } async function listQlEnvs(host, qlToken, searchValue) { const url = `${host}/open/envs?searchValue=${encodeURIComponent(searchValue || ENV_NAME)}` const resp = await qxRequest({ url, method: 'GET', headers: { Authorization: `Bearer ${qlToken}` } }) const obj = parseJson(resp.body || '', {}) if (resp.statusCode >= 400 || obj.code && obj.code !== 200) throw new Error(`读取青龙变量失败 HTTP ${resp.statusCode}: ${String(resp.body || '').slice(0, 160)}`) return Array.isArray(obj.data) ? obj.data : [] } async function createQlEnv(host, qlToken, value) { const body = JSON.stringify([{ name: ENV_NAME, value, remarks: ENV_REMARK }]) const resp = await qxRequest({ url: `${host}/open/envs`, method: 'POST', headers: { Authorization: `Bearer ${qlToken}`, 'Content-Type': 'application/json' }, body }) const obj = parseJson(resp.body || '', {}) if (resp.statusCode >= 400 || obj.code && obj.code !== 200) throw new Error(`创建青龙变量失败 HTTP ${resp.statusCode}: ${String(resp.body || '').slice(0, 160)}`) return obj } async function updateQlEnv(host, qlToken, env, value) { const id = env.id || env._id if (!id) throw new Error('找到 KYAPP 但缺少 id/_id,无法更新') const body = JSON.stringify({ name: ENV_NAME, value, remarks: env.remarks || ENV_REMARK, id, _id: id }) const resp = await qxRequest({ url: `${host}/open/envs`, method: 'PUT', headers: { Authorization: `Bearer ${qlToken}`, 'Content-Type': 'application/json' }, body }) const obj = parseJson(resp.body || '', {}) if (resp.statusCode >= 400 || obj.code && obj.code !== 200) throw new Error(`更新青龙变量失败 HTTP ${resp.statusCode}: ${String(resp.body || '').slice(0, 160)}`) return obj } async function syncToQingLong(records) { const host = normalizeHost(getPref('ql_host', '')) const clientId = getPref('ql_client_id', '') const clientSecret = getPref('ql_client_secret', '') if (!host || !clientId || !clientSecret) { notify('已本地保存,未同步青龙', '缺少 ql_host / ql_client_id / ql_client_secret 变量') return { synced: false, reason: 'missing ql config' } } const qlToken = await getQlToken(host, clientId, clientSecret) const envs = await listQlEnvs(host, qlToken, ENV_NAME) const env = envs.find(e => e && e.name === ENV_NAME) let base = [] if (env && env.value) base = parseJson(env.value, []) if (!Array.isArray(base) || base.length === 0) { const seed = parseJson(getPref('kyapp_seed', '[]'), []) if (Array.isArray(seed) && seed.length) base = seed } let merged = Array.isArray(base) ? base : [] records.forEach(r => { merged = mergeRecord(merged, r) }) const value = JSON.stringify(merged) if (env) await updateQlEnv(host, qlToken, env, value) else await createQlEnv(host, qlToken, value) saveLocalRecords(merged) return { synced: true, count: merged.length } } async function main() { const url = ($request && $request.url) || '' const headers = ($request && $request.headers) || {} const body = ($request && $request.body) || '' const pending = getPending() if (/\/passport\/get_token/.test(url)) { const token = extractTokenFromBody(body) const device = extractDeviceId(headers, body) if (token) pending.token_body = token if (device) pending.device_id = device setPending(pending) const jwt = decodeJwt(pending.token_body) notify('已抓到 token', `uid=${jwt && jwt.uid ? jwt.uid : '未知'} device=${mask(pending.device_id)}`) } if (/\/task\/get_task_page_info/.test(url)) { const device = extractDeviceId(headers, body) if (device) pending.device_id = device const auth = getHeader(headers, 'Authorization') || getHeader(headers, 'authorization') if (!pending.token_body && auth) pending.token_body = String(auth).replace(/^Bearer\s+/i, '').trim() setPending(pending) } if (!pending.device_id || !pending.token_body) { notify('等待完整 CK', `device_id=${pending.device_id ? '已抓到' : '缺失'} token=${pending.token_body ? '已抓到' : '缺失'}`) return done() } if (!tokenLooksValid(pending.token_body)) { notify('token 格式异常', mask(pending.token_body)) return done() } const record = { device_id: pending.device_id, token_body: pending.token_body, txAmount: 3 } let records = mergeRecord(loadLocalRecords(), record) saveLocalRecords(records) try { const ret = await syncToQingLong([record]) if (ret.synced) notify('已更新青龙 KYAPP', `本地/青龙合并后 ${ret.count} 个账号,当前 token=${mask(record.token_body)}`) } catch (e) { notify('同步青龙失败,已本地保存', String(e && e.message || e).slice(0, 220)) } done() } main().catch(e => { notify('脚本异常', String(e && e.message || e).slice(0, 220)); done() })