fix: use qdreader sign gateway path

This commit is contained in:
2026-05-17 18:17:16 +08:00
parent 9d8021e1e4
commit 3a7f85a412
4 changed files with 116 additions and 14 deletions
+47 -5
View File
@@ -16,7 +16,7 @@
//[priority: 600]
//[cron: 15 7 * * *]
//[param: {"required":false,"key":"hermes_qidian.qdreader_cookie_json","bool":false,"placeholder":"{\"uid\":\"cookie\"}","name":"QDReader Cookie JSON","desc":"机器人交互维护,JSON Map 格式;兼容读取旧 otto bucket"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz,可替换为兼容 /sign 的网关"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz/qdreader","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz/qdreader;插件会自动追加 /sign,可替换为兼容 /sign 的网关"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_retry_count","bool":false,"placeholder":"1","name":"请求失败重试次数","desc":"默认 1,最大 3,仅对网络异常/非业务异常重试"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_cron_silent","bool":true,"placeholder":"false","name":"定时无账号时静默","desc":"true 时定时触发且没有账号不回复"}]
@@ -30,7 +30,7 @@ var RETRY_KEY = "qdreader_retry_count"
var CRON_SILENT_KEY = "qdreader_cron_silent"
var LAST_RESULT_KEY = "qdreader_last_result_json"
var LAST_RUN_KEY = "qdreader_last_run_json"
var DEFAULT_SIGN_API = "https://api.120399.xyz"
var DEFAULT_SIGN_API = "https://api.120399.xyz/qdreader"
var QIDIAN_BASE = "https://h5.if.qidian.com"
var CHECKIN_PATH = "/argus/api/v3/checkin/checkin"
@@ -241,7 +241,18 @@ function httpReq(opt) {
throw new Error("当前 Autman 环境没有 request/$request 方法,无法发起签到请求")
}
function parseResp(res) {
var body = res && (res.body !== undefined ? res.body : (res.data !== undefined ? res.data : res))
if (res === undefined || res === null) return null
var body = null
if (res && typeof res === "object") {
if (res.body !== undefined) body = res.body
else if (res.data !== undefined) body = res.data
else if (res.content !== undefined) body = res.content
else if (res.responseText !== undefined) body = res.responseText
else if (res.statusCode !== undefined || res.status !== undefined || res.headers !== undefined) body = ""
else body = res
} else {
body = res
}
if (typeof body === "string") return safeJsonParse(body, body)
return body
}
@@ -260,7 +271,7 @@ function requestWithRetry(opt, retryCount) {
}
function postJson(url, data) {
var retry = getInt(RETRY_KEY, 1, 0, 3)
return parseResp(requestWithRetry({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }, retry))
return parseResp(requestWithRetry({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify(data) }, retry))
}
function normalizeSignHeaders(signed) {
if (typeof signed === "string") signed = safeJsonParse(signed, {})
@@ -272,13 +283,44 @@ function normalizeSignHeaders(signed) {
if (!ok || !isObject(headers)) throw new Error("签名失败:" + JSON.stringify(signed).slice(0, 500))
return headers
}
function signBaseCandidates(rawBase) {
rawBase = String(rawBase || DEFAULT_SIGN_API).replace(/\/+$/, "")
var out = [rawBase]
if (/^https?:\/\/api\.120399\.xyz$/i.test(rawBase)) out.push(rawBase + "/qdreader")
if (/^https?:\/\/api\.120399\.xyz\/QDReader$/i.test(rawBase)) out.push(rawBase.replace(/\/QDReader$/i, "/qdreader"))
var seen = {}
return out.filter(function (x) { if (seen[x]) return false; seen[x] = true; return true })
}
function signErrorRetriable(e) {
var msg = e && e.message ? e.message : String(e || "")
return /签名网关无返回|Cannot\s+POST|404|Not\s+Found/i.test(msg)
}
function isLikelyEmptyHttpResponse(x) {
return !x || (typeof x === "string" && !trim(x))
}
function requestSignHeaders(rawBase, payload) {
var bases = signBaseCandidates(rawBase)
var lastErr = null
for (var i = 0; i < bases.length; i++) {
try {
var signed = postJson(bases[i] + "/sign", payload)
if (i + 1 < bases.length && isLikelyEmptyHttpResponse(signed)) throw new Error("签名网关无返回")
return normalizeSignHeaders(signed)
} catch (e) {
lastErr = e
debug("QDReader sign gateway " + bases[i] + "/sign failed: " + (e && e.message ? e.message : String(e)))
if (!signErrorRetriable(e)) break
}
}
throw lastErr || new Error("签名网关无返回")
}
function qidianRequest(path, method, data, cookie) {
method = String(method || "GET").toUpperCase()
var info = normalizeCookie(cookie)
if (!info.uid) throw new Error("Cookie 缺少 uid/QDHeader")
var signApi = String(cfgGet(SIGN_API_KEY, DEFAULT_SIGN_API)).replace(/\/+$/, "")
var payload = { qdh: info.qdh, qdheader: info.qdheader, url: QIDIAN_BASE + path, method: method, data: data || {}, uid: info.uid, guid: info.guid, qimei: info.qimei, qimei36: info.qimei36 }
var headers = normalizeSignHeaders(postJson(signApi + "/sign", payload))
var headers = requestSignHeaders(signApi, payload)
headers.Cookie = info.cookie
if (!headers["User-Agent"]) headers["User-Agent"] = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/QDReaderiOS/5.9.456/728/QDReaderAppstore"
var req = { url: QIDIAN_BASE + path, method: lower(method), dataType: "json", timeOut: 10000, headers: headers }
+4 -3
View File
@@ -119,10 +119,11 @@ set(key, value)
1. 从 Cookie 解析账号标识与签名参数:
- 优先解析 `QDHeader` 第一段有效数字 uid
- 其次读取 `uid/userid/userId/UserId`
- `QDHeader` 第一段为 `(null)` 或无效,回退 `ywguid/gwguid/guid`,与 QX 抓包脚本保持一致
- `QDHeader` 第一段为 `(null)` 或无效,继续检查 `QDHeader` 第 12 段的真实 `UserId`
- 再读取 `uid/userid/userId/UserId`
- 最后才回退 `ywguid/gwguid/guid`,与 QX 抓包脚本保持一致;
- 同时提取 `qdh/QDHeader/guid/qimei/qimei36` 供签名网关使用。
2. `POST <qdreader_sign_api>/sign` 获取请求签名头。
2. `POST <qdreader_sign_api>/sign` 获取请求签名头。默认签名网关基址是 `https://api.120399.xyz/qdreader`;若旧配置仍写成 `https://api.120399.xyz`,插件会在根 `/sign` 无返回/404 时自动回退到 `/qdreader/sign`
3. `POST https://h5.if.qidian.com/argus/api/v3/checkin/checkin` 完成签到。
4. 写入单账号最后结果 `qdreader_last_result_json`,包括 `ok/msg/time/source/runId`
5. 写入批次日志 `qdreader_last_run_json`,包括触发来源、开始/结束时间、成功/失败/总数和每账号结果;即使账号执行异常也会记录失败日志。
+47 -5
View File
@@ -16,7 +16,7 @@
//[priority: 600]
//[cron: 15 7 * * *]
//[param: {"required":false,"key":"hermes_qidian.qdreader_cookie_json","bool":false,"placeholder":"{\"uid\":\"cookie\"}","name":"QDReader Cookie JSON","desc":"机器人交互维护,JSON Map 格式;兼容读取旧 otto bucket"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz,可替换为兼容 /sign 的网关"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_sign_api","bool":false,"placeholder":"https://api.120399.xyz/qdreader","name":"QDReader 签名网关","desc":"默认 https://api.120399.xyz/qdreader;插件会自动追加 /sign,可替换为兼容 /sign 的网关"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_retry_count","bool":false,"placeholder":"1","name":"请求失败重试次数","desc":"默认 1,最大 3,仅对网络异常/非业务异常重试"}]
//[param: {"required":false,"key":"hermes_qidian.qdreader_cron_silent","bool":true,"placeholder":"false","name":"定时无账号时静默","desc":"true 时定时触发且没有账号不回复"}]
@@ -30,7 +30,7 @@ var RETRY_KEY = "qdreader_retry_count"
var CRON_SILENT_KEY = "qdreader_cron_silent"
var LAST_RESULT_KEY = "qdreader_last_result_json"
var LAST_RUN_KEY = "qdreader_last_run_json"
var DEFAULT_SIGN_API = "https://api.120399.xyz"
var DEFAULT_SIGN_API = "https://api.120399.xyz/qdreader"
var QIDIAN_BASE = "https://h5.if.qidian.com"
var CHECKIN_PATH = "/argus/api/v3/checkin/checkin"
@@ -241,7 +241,18 @@ function httpReq(opt) {
throw new Error("当前 Autman 环境没有 request/$request 方法,无法发起签到请求")
}
function parseResp(res) {
var body = res && (res.body !== undefined ? res.body : (res.data !== undefined ? res.data : res))
if (res === undefined || res === null) return null
var body = null
if (res && typeof res === "object") {
if (res.body !== undefined) body = res.body
else if (res.data !== undefined) body = res.data
else if (res.content !== undefined) body = res.content
else if (res.responseText !== undefined) body = res.responseText
else if (res.statusCode !== undefined || res.status !== undefined || res.headers !== undefined) body = ""
else body = res
} else {
body = res
}
if (typeof body === "string") return safeJsonParse(body, body)
return body
}
@@ -260,7 +271,7 @@ function requestWithRetry(opt, retryCount) {
}
function postJson(url, data) {
var retry = getInt(RETRY_KEY, 1, 0, 3)
return parseResp(requestWithRetry({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }, retry))
return parseResp(requestWithRetry({ url: url, method: "post", dataType: "json", timeOut: 10000, headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify(data) }, retry))
}
function normalizeSignHeaders(signed) {
if (typeof signed === "string") signed = safeJsonParse(signed, {})
@@ -272,13 +283,44 @@ function normalizeSignHeaders(signed) {
if (!ok || !isObject(headers)) throw new Error("签名失败:" + JSON.stringify(signed).slice(0, 500))
return headers
}
function signBaseCandidates(rawBase) {
rawBase = String(rawBase || DEFAULT_SIGN_API).replace(/\/+$/, "")
var out = [rawBase]
if (/^https?:\/\/api\.120399\.xyz$/i.test(rawBase)) out.push(rawBase + "/qdreader")
if (/^https?:\/\/api\.120399\.xyz\/QDReader$/i.test(rawBase)) out.push(rawBase.replace(/\/QDReader$/i, "/qdreader"))
var seen = {}
return out.filter(function (x) { if (seen[x]) return false; seen[x] = true; return true })
}
function signErrorRetriable(e) {
var msg = e && e.message ? e.message : String(e || "")
return /签名网关无返回|Cannot\s+POST|404|Not\s+Found/i.test(msg)
}
function isLikelyEmptyHttpResponse(x) {
return !x || (typeof x === "string" && !trim(x))
}
function requestSignHeaders(rawBase, payload) {
var bases = signBaseCandidates(rawBase)
var lastErr = null
for (var i = 0; i < bases.length; i++) {
try {
var signed = postJson(bases[i] + "/sign", payload)
if (i + 1 < bases.length && isLikelyEmptyHttpResponse(signed)) throw new Error("签名网关无返回")
return normalizeSignHeaders(signed)
} catch (e) {
lastErr = e
debug("QDReader sign gateway " + bases[i] + "/sign failed: " + (e && e.message ? e.message : String(e)))
if (!signErrorRetriable(e)) break
}
}
throw lastErr || new Error("签名网关无返回")
}
function qidianRequest(path, method, data, cookie) {
method = String(method || "GET").toUpperCase()
var info = normalizeCookie(cookie)
if (!info.uid) throw new Error("Cookie 缺少 uid/QDHeader")
var signApi = String(cfgGet(SIGN_API_KEY, DEFAULT_SIGN_API)).replace(/\/+$/, "")
var payload = { qdh: info.qdh, qdheader: info.qdheader, url: QIDIAN_BASE + path, method: method, data: data || {}, uid: info.uid, guid: info.guid, qimei: info.qimei, qimei36: info.qimei36 }
var headers = normalizeSignHeaders(postJson(signApi + "/sign", payload))
var headers = requestSignHeaders(signApi, payload)
headers.Cookie = info.cookie
if (!headers["User-Agent"]) headers["User-Agent"] = "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/QDReaderiOS/5.9.456/728/QDReaderAppstore"
var req = { url: QIDIAN_BASE + path, method: lower(method), dataType: "json", timeOut: 10000, headers: headers }
+18 -1
View File
@@ -95,7 +95,7 @@ test('manual sign and cron sign both call sign gateway then qidian checkin', ()
const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }) };
const manual = runPlugin({ content: '启点签到', store: { ...store } });
assert.equal(manual.calls.length, 2);
assert.match(manual.calls[0].url, /api\.120399\.xyz\/sign$/);
assert.match(manual.calls[0].url, /api\.120399\.xyz\/qdreader\/sign$/);
assert.match(manual.calls[1].url, /h5\.if\.qidian\.com\/argus\/api\/v3\/checkin\/checkin$/);
assert.match(manual.replies[0], /✅ 12\*\*\*45/);
assert.match(manual.replies[0], /记录时间:/);
@@ -137,6 +137,23 @@ test('reads legacy otto bucket but writes new hermes_qidian bucket', () => {
assert.equal(JSON.parse(r.bucketStore['hermes_qidian.qdreader_remark_json'])['12345'], '主号');
});
test('legacy default sign api base falls back to qdreader path when root /sign is empty', () => {
const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }), qdreader_sign_api: 'https://api.120399.xyz' };
const r = runPlugin({
content: '启点签到',
store,
requests: [
{ statusCode: 404, body: '' },
{ body: { code: 200, data: { 'QD-Sign': 'sig2' } } },
{ statusCode: 200, body: { code: 0, msg: '签到成功' } },
],
});
assert.equal(r.calls.length, 3);
assert.match(r.calls[0].url, /api\.120399\.xyz\/sign$/);
assert.match(r.calls[1].url, /api\.120399\.xyz\/qdreader\/sign$/);
assert.match(r.replies[0], /✅ 12\*\*\*45/);
});
test('qidian request retries transient request errors', () => {
const store = { qdreader_cookie_json: JSON.stringify({ '12345': cookie }), qdreader_retry_count: '2' };
const r = runPlugin({