按 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:
+2
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user