feat: add kyapp qinglong qx rewrite
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* 快音 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() })
|
||||
Reference in New Issue
Block a user