diff --git a/README.md b/README.md index 7fa8d47..489aa2a 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ ## 仓库结构 ```text -conf/qdreader.conf # 起点读书 QX 远程重写订阅文本,参考 chickliu/qx 老仓库风格 -js/qdreader_cookie.js # 起点读书 QX Rewrite 脚本入口 -docs/qx-rewrite-lessons.md # 本次制作经验 -source/qdreader.upstream.js # 上游 QDReader 脚本备份 +conf/qdreader.conf # 起点读书 QX 远程重写订阅文本,参考 chickliu/qx 老仓库风格 +conf/kyapp_qinglong.conf # 快音 CK → 青龙 KYAPP 自动更新重写订阅 +js/qdreader_cookie.js # 起点读书 QX Rewrite 脚本入口 +js/kyapp_qinglong_update.js # 快音 CK 抓取并更新青龙 KYAPP +docs/qx-rewrite-lessons.md # 本次制作经验 +source/qdreader.upstream.js # 上游 QDReader 脚本备份 ``` ## 当前订阅地址 @@ -19,6 +21,12 @@ source/qdreader.upstream.js # 上游 QDReader 脚本备份 https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/conf/qdreader.conf ``` +快音 KYAPP → 青龙自动更新订阅地址: + +```text +https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/conf/kyapp_qinglong.conf +``` + ## 订阅内容 ```text diff --git a/conf/kyapp_qinglong.conf b/conf/kyapp_qinglong.conf new file mode 100644 index 0000000..d937529 --- /dev/null +++ b/conf/kyapp_qinglong.conf @@ -0,0 +1,6 @@ +hostname = *.kaixinyf.cn + +# 快音:抓取 CK 后自动更新青龙环境变量 KYAPP +# 配置说明见 docs/kyapp-qinglong.md +^https?:\/\/[^\/]+\.kaixinyf\.cn\/.*passport\/get_token url script-request-body https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/js/kyapp_qinglong_update.js +^https?:\/\/[^\/]+\.kaixinyf\.cn\/.*\/task\/get_task_page_info url script-request-header https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/js/kyapp_qinglong_update.js diff --git a/docs/kyapp-qinglong.md b/docs/kyapp-qinglong.md new file mode 100644 index 0000000..adcb6a4 --- /dev/null +++ b/docs/kyapp-qinglong.md @@ -0,0 +1,67 @@ +# 快音 KYAPP → 青龙环境变量自动更新(Quantumult X) + +## 订阅地址 + +```text +https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/conf/kyapp_qinglong.conf +``` + +## 功能 + +这个 QX 重写会监听快音接口: + +```text +*.kaixinyf.cn ... /passport/get_token +*.kaixinyf.cn ... /task/get_task_page_info +``` + +抓到 `device_id` 和 `token_body` 后,按青龙变量 `KYAPP` 所需格式写入: + +```json +[{"device_id":"...","token_body":"...","txAmount":3}] +``` + +`txAmount` 固定写 `3`。 + +## QX 变量 + +脚本需要以下变量才能直接更新青龙: + +```text +ql_host 青龙地址,例如 http://192.168.2.66:5700 +ql_client_id 青龙应用 client_id +ql_client_secret 青龙应用 client_secret +``` + +可选: + +```text +kyapp_seed 初始 KYAPP JSON 数组;如果青龙还没有 KYAPP,可以用它作为初始合并基线 +``` + +如果未配置青龙变量,脚本只会在 QX 本地保存 `kyapp_records`,不会主动同步青龙。 + +## 使用流程 + +1. QX 添加重写订阅: + + ```text + https://gitea.chickliu.fun/Hermes/qx/raw/branch/main/conf/kyapp_qinglong.conf + ``` + +2. 开启 MitM 并信任证书。 +3. 确认 hostname 包含: + + ```text + *.kaixinyf.cn + ``` + +4. 配置 QX 变量 `ql_host` / `ql_client_id` / `ql_client_secret`。 +5. 打开快音 App,触发登录/任务页接口。 +6. QX 通知显示 `已更新青龙 KYAPP` 即完成。 + +## 安全说明 + +- 仓库里不提交 CK 明文。 +- 脚本只请求你配置的青龙地址。 +- 重写脚本和订阅都使用 Gitea HTTPS raw 地址,避免原始第三方 HTTP 脚本被中间人篡改。 diff --git a/js/kyapp_qinglong_update.js b/js/kyapp_qinglong_update.js new file mode 100644 index 0000000..36b2eff --- /dev/null +++ b/js/kyapp_qinglong_update.js @@ -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() })