按 HAR 抓包对齐原版协议: 补 api.php 取配置 + 识别业务码 403 + 按试用配额限源

依据用户提供的 HAR(e.ac19.cn, Reqable): 原版客户端是三步
  1) POST /api.php  action=get_config&device_id=<mac>  (表单 + Dalvik UA)
  2) GET /i.php          参数与 App 完全一致
  3) GET /music/do.php   参数与 App 完全一致, 返回真源 CDN 地址

发现并修复三处差异:
1. 我们从未调 api.php(原版取址前必调)
   → KtvJsBridge.rawCleanPost(): 干净 POST + Content-Type 表单 + Dalvik UA
     (UA 由本机 Build.VERSION.RELEASE/MODEL/ID 拼出, 与 HAR 同形态)
   → SongUrlResolver.ensureConfig(): 缓存 30 分钟, 桥不可用则跳过
   → SongApiClient 注入 POST 通道
2. 风控拒绝的真实形态是 HTTP 200 + 业务码 403
   {"code":403,"msg":"请求被拒绝:检测到异常行为"} —— 不是 HTTP 403
   → i.php / do.php 两处业务码 403 都置 sawForbidden(诊断不再误判为"没有地址")
3. 无视试用配额: api.php 给出 trial_times=2 / trial_duration=5
   → 一次取址最多尝试 trial_times 个源(原先 9 个源全扫会把额度瞬间打光)

请求头分工与 HAR 一致: i.php/do.php 只有 Host+Accept 的干净 GET;
仅 api.php 带表单 Content-Type 与 Dalvik UA。
日志: 远程一条 SOURCE-CONFIG trial_enabled=… trial_times=…; 原始 JSON/卡密提示只写本地。

本机实测: api.php 返回与 HAR 相同配置; do.php 返回 code=403 检测到异常行为
(本出口 IP 处于风控/未授权: network_verification=true + 卡密机制, 额度按 IP 计)。

版本号按规则 3: 1.5.8(40) → 1.5.9(41); 按要求未打包
验证: compileReleaseKotlin + 单元测试(9 例全过)
This commit is contained in:
zijunchiang
2026-10-08 03:56:59 +08:00
parent 745a180258
commit 4576a89f9a
4 changed files with 108 additions and 4 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 40 versionCode 41
versionName "1.5.8" versionName "1.5.9"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -216,6 +216,29 @@ class KtvJsBridge(context: Context) {
return NativeBridge().request("GET", url, null, headers, timeoutMs) return NativeBridge().request("GET", url, null, headers, timeoutMs)
} }
/**
* **干净 POST**(原版客户端调 `api.php` 取配置的形态,2026-10 依据 HAR 实测补上)。
*
* HAR 实测原版:`POST http://<host>/api.php`,
* `Content-Type: application/x-www-form-urlencoded`,
* UA 为 `Dalvik/2.1.0 (Linux; U; Android 12; SM-G9730 Build/…)`,body 为
* `action=get_config&device_id=<mac>`;而 `i.php`/`do.php` 仍是**只有 Host+Accept**
* 的干净 GET([rawCleanGet])。这里按同样分工实现。
*/
fun rawCleanPost(url: String, form: String, timeoutMs: Int = 8_000): String {
val headers = JSONObject()
.put("Accept", "*/*")
.put("Content-Type", "application/x-www-form-urlencoded")
.put("User-Agent", dalvikUserAgent())
.toString()
return NativeBridge().request("POST", url, form, headers, timeoutMs)
}
/** 原版客户端形态的 UA(Dalvik + 本机 Android 版本/型号/Build)。 */
private fun dalvikUserAgent(): String =
"Dalvik/2.1.0 (Linux; U; Android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.MODEL} Build/${android.os.Build.ID})"
fun isDestroyed(): Boolean = destroyed fun isDestroyed(): Boolean = destroyed
/** /**
@@ -27,6 +27,8 @@ object SongApiClient {
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致), // 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。 // 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) } SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
// api.php 取配置走 POST(原版形态);桥不可用时为 null,此时跳过配置拉取。
SongUrlResolver.installPost { url, form -> bridge?.rawCleanPost(url, form) }
bridge?.init { ok -> bridge?.init { ok ->
ready = ok ready = ok
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。 // 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
@@ -46,6 +46,9 @@ object SongUrlResolver {
*/ */
private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L
/** `api.php` 配置缓存时长(原版每次启动/长时间后取一次即可)。 */
private const val CONFIG_TTL_MS = 30 * 60_000L
private val DEFAULT_HOSTS = listOf( private val DEFAULT_HOSTS = listOf(
// 与其他入口同一后台、同一协议;2026-10 实测唯一仍下发真源 CDN 地址的入口,放首位。 // 与其他入口同一后台、同一协议;2026-10 实测唯一仍下发真源 CDN 地址的入口,放首位。
"http://gz.ac16.vip", "http://gz.ac16.vip",
@@ -65,6 +68,26 @@ object SongUrlResolver {
@Volatile private var fetch: ((String) -> String?)? = null @Volatile private var fetch: ((String) -> String?)? = null
/** 干净 POST 通道(`api.php` 取配置用;未注入时跳过配置拉取)。 */
@Volatile private var post: ((String, String) -> String?)? = null
/**
* 源站配置(原版客户端取址前会 POST `api.php` 的 `action=get_config` 拿到)。
*
* 实测(HAR 2026-10-08):`{"trial_enabled":true,"trial_times":2,"trial_duration":5,
* "network_verification":true,"heartbeat_interval":3600,"dialog_title":"请输入商家提供的正确卡密"}`
* —— **试用额度只有 2 次 / 5 分钟**,所以取址**不能把 9 个源全扫一遍**(会把额度瞬间打光,
* 之后就一直 `code=403 检测到异常行为`)。
*/
private data class SourceConfig(
val trialTimes: Int,
val trialDurationMin: Int,
val networkVerification: Boolean,
)
@Volatile private var config: SourceConfig? = null
@Volatile private var configAt: Long = 0L
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN @Volatile private var sn: String = KtvJsBridge.DEVICE_SN
/** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */ /** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */
@@ -112,6 +135,47 @@ object SongUrlResolver {
} }
} }
/** 注入"干净 POST"通道(`api.php` 取配置)。 */
fun installPost(post: (String, String) -> String?) {
this.post = post
}
/**
* 拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。
*
* 用途:① 日志里给出"是否启用试用/试用次数/时长/是否需要卡密"这些**结论**;
* ② 按 `trial_times` 限制一次取址尝试的源数量,避免把试用额度瞬间耗尽。
*/
private fun ensureConfig(host: String, mac: String) {
if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return
val channel = post ?: return
val form = "action=get_config&device_id=$mac"
val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
val status = envelope.optInt("status")
if (status !in 200..299) {
MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status")
return
}
val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return
val c = body.optJSONObject("config") ?: body
val times = c.optInt("trial_times", 0)
val dur = c.optInt("trial_duration", 0)
val verify = c.optBoolean("network_verification", false)
config = SourceConfig(times, dur, verify)
configAt = System.currentTimeMillis()
// 远程只留一条"结论";完整 JSON 与卡密提示只写本地。
MiliLog.info(
"SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " +
"trial_times=$times trial_duration=${dur}min network_verification=$verify " +
"heartbeat=${c.optInt("heartbeat_interval", 0)}s",
)
MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}")
if (verify && c.optString("dialog_title").isNotEmpty()) {
MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}")
}
}
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */ /** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? { fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
if (musicno.isBlank()) return null if (musicno.isBlank()) return null
@@ -131,7 +195,15 @@ object SongUrlResolver {
sawForbidden = false sawForbidden = false
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn) val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
val hosts = synchronized(hostOrder) { hostOrder.toList() } val allHosts = synchronized(hostOrder) { hostOrder.toList() }
// 原版取址前会先 POST api.php 取配置;这里顺带用它限制一次取址的源数量
// (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。
ensureConfig(allHosts.firstOrNull().orEmpty(), mac)
val maxHosts = config?.trialTimes?.takeIf { it > 0 }?.coerceAtMost(allHosts.size) ?: allHosts.size
if (maxHosts < allHosts.size) {
MiliLog.local("NATIVE-SONG 按试用额度限制本次取址源数=$maxHosts(共 ${allHosts.size} 个)")
}
val hosts = allHosts.take(maxHosts)
var lastDetail = "no-source" var lastDetail = "no-source"
var adOnlyCount = 0 // 返回了地址但被判为广告/测试资源的源数 var adOnlyCount = 0 // 返回了地址但被判为广告/测试资源的源数
var otherFailCount = 0 // token 失败 / 无地址 / 网络异常的源数 var otherFailCount = 0 // token 失败 / 无地址 / 网络异常的源数
@@ -216,7 +288,9 @@ object SongUrlResolver {
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN" val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
val sign = md5(params + APP_KEY) val sign = md5(params + APP_KEY)
val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null
if (json.optInt("code") != 200) { val tokenCode = json.optInt("code")
if (tokenCode == 403) sawForbidden = true // 业务码 403 = 风控(见 do.php 同处注释)
if (tokenCode != 200) {
MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}") MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}")
return null return null
} }
@@ -240,6 +314,11 @@ object SongUrlResolver {
val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null
val code = json.optInt("code") val code = json.optInt("code")
if (code != 200) { if (code != 200) {
// ⚠️ 源站的"风控拒绝"是 **HTTP 200 + 业务码 403**(body:
// `{"code":403,"msg":"请求被拒绝:检测到异常行为"}`,2026-10-08 实测),
// 不是 HTTP 403 —— 以前只按 HTTP 状态判断,这类响应被当成"没有地址",
// 日志里看不出真实原因。
if (code == 403) sawForbidden = true
MiliLog.localWarn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}") MiliLog.localWarn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}")
return null return null
} }