Files
qx/js/kyapp_qinglong_update.js
T

238 lines
10 KiB
JavaScript

/*
* 快音 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() })