fix(android): resolve authorized protected X posts

This commit is contained in:
wolfmanster 2026-08-20 13:07:04 +08:00
parent c0ce31e42a
commit 5f5aa4fd26
4 changed files with 169 additions and 27 deletions

View file

@ -12,6 +12,7 @@ import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.ActivityCallback
import com.xmedia.archive.XLoginActivity
import com.xmedia.archive.data.JobStatus
import com.xmedia.archive.repository.ArchiveRepository
import com.xmedia.archive.resolver.XAuthSessionStore
import com.xmedia.archive.service.DownloadForegroundService
@ -213,7 +214,16 @@ class LocalArchivePlugin : Plugin() {
call.reject("该任务无法${if (action == "cancel") "取消" else "重试"}")
return@launch
}
if (action == "retry") DownloadForegroundService.start(context)
if (action == "retry" && !DownloadForegroundService.start(context)) {
repository.getJob(id)?.let { job ->
repository.update(job.copy(
status = JobStatus.FAILED.name.lowercase(),
error = "系统暂不允许后台启动下载;请解锁设备并保持应用在前台后重试",
))
}
call.reject("请解锁设备并保持应用在前台后重试")
return@launch
}
val result = repository.jobJson(id)
if (result == null) call.reject("任务不存在") else call.resolve(JSObject(result.toString()))
}

View file

@ -2,6 +2,7 @@ package com.xmedia.archive.resolver
import android.media.MediaExtractor
import android.media.MediaFormat
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
@ -135,7 +136,16 @@ class XPostResolver(
repeat(2) { attempt ->
if (signal()) throw CancellationException("任务已取消")
val url = GRAPHQL_URL.toHttpUrl().newBuilder()
.addQueryParameter("variables", JSONObject().put("focalTweetId", tweetId).put("with_rux_injections", false).put("rankingMode", "Relevance").put("includePromotedContent", true).put("withCommunity", true).toString())
.addQueryParameter("variables", JSONObject()
.put("focalTweetId", tweetId)
.put("with_rux_injections", false)
.put("rankingMode", "Relevance")
.put("includePromotedContent", true)
.put("withCommunity", true)
.put("withQuickPromoteEligibilityTweetFields", true)
.put("withBirdwatchNotes", true)
.put("withVoice", true)
.toString())
.addQueryParameter("features", GRAPHQL_FEATURES)
.addQueryParameter("fieldToggles", GRAPHQL_FIELD_TOGGLES)
.build()
@ -154,25 +164,54 @@ class XPostResolver(
return GraphqlOutcome.Unavailable
}
val root = JSONObject(response.body?.string() ?: "{}")
val result = findTweetResult(root, tweetId) ?: return GraphqlOutcome.Unavailable
val result = findTweetResult(root, tweetId) ?: run {
logWarning("TweetDetail response has no focal result: ${graphqlErrorSummary(root)}")
return GraphqlOutcome.Unavailable
}
unavailableOutcome(result)?.let { outcome ->
logWarning("TweetDetail unavailable result: typename=${result.optString("__typename", "missing")}, outcome=$outcome")
return if (session != null && outcome == GraphqlOutcome.RequiresSession) GraphqlOutcome.SessionRejected else outcome
}
val base = if (result.optString("__typename") == "TweetWithVisibilityResults") result.optJSONObject("tweet") ?: result else result
val legacy = base.optJSONObject("legacy") ?: return GraphqlOutcome.Unavailable
val legacy = base.optJSONObject("legacy") ?: run {
logGraphqlShape("missing tweet legacy", result, base)
return GraphqlOutcome.Unavailable
}
val media = legacy.optJSONObject("extended_entities")?.optJSONArray("media")
?: legacy.optJSONObject("entities")?.optJSONArray("media")
val parsedMedia = parseMedia(media, tweetId)
if (parsedMedia.isEmpty()) return GraphqlOutcome.Unavailable
val user = base.optJSONObject("core")?.optJSONObject("user_results")?.optJSONObject("result")?.optJSONObject("legacy")
?: return GraphqlOutcome.Unavailable
val username = user.optString("screen_name")
val userId = user.optString("id_str")
if (user.optBoolean("protected", false) && session == null) return GraphqlOutcome.Protected
if (username.isBlank() || userId.isBlank()) return GraphqlOutcome.Unavailable
if (parsedMedia.isEmpty()) {
logGraphqlShape("missing supported media", result, base, legacy)
return GraphqlOutcome.Unavailable
}
val userResult = base.optJSONObject("core")?.optJSONObject("user_results")?.optJSONObject("result")
if (userResult == null) {
logGraphqlShape("missing user result", result, base)
return GraphqlOutcome.Unavailable
}
// X is migrating user identity fields from `legacy` to the newer split
// core/avatar/privacy/rest_id shape. Accept both because TweetDetail may return
// either shape depending on account and feature switches.
val userLegacy = userResult.optJSONObject("legacy")
val userCore = userResult.optJSONObject("core")
val username = userLegacy?.optString("screen_name").orEmpty()
.ifBlank { userCore?.optString("screen_name").orEmpty() }
val userId = userLegacy?.optString("id_str").orEmpty()
.ifBlank { userResult.optString("rest_id") }
val isProtected = userLegacy?.optBoolean("protected", false) == true ||
userResult.optJSONObject("privacy")?.optBoolean("protected", false) == true
if (isProtected && session == null) return GraphqlOutcome.Protected
if (username.isBlank() || userId.isBlank()) {
logGraphqlShape("missing user identity", result, base, userResult, userLegacy, userCore)
return GraphqlOutcome.Unavailable
}
return GraphqlOutcome.Resolved(ResolvedPost(tweetId, "https://x.com/i/status/$tweetId", ResolvedMetadata(
user.optString("name", username), username, userId,
user.optString("profile_image_url_https", "").replace("_normal.", "_400x400."),
userLegacy?.optString("name").orEmpty().ifBlank { userCore?.optString("name").orEmpty() }.ifBlank { username },
username,
userId,
userLegacy?.optString("profile_image_url_https").orEmpty()
.ifBlank { userResult.optJSONObject("avatar")?.optString("image_url").orEmpty() }
.replace("_normal.", "_400x400."),
legacy.optString("full_text", legacy.optString("text", "")), legacy.optString("lang", "und"),
legacy.optString("created_at", ""),
), parsedMedia))
@ -189,6 +228,8 @@ class XPostResolver(
.add("X-Twitter-Client-Language", "en")
.add("X-Twitter-Active-User", "yes")
.add("Accept-Language", "en")
.add("Accept", "application/json")
.add("Content-Type", "application/json")
if (session == null) {
headers.add("X-Guest-Token", requireNotNull(guestToken))
headers.add("Cookie", "guest_id=v1:${requireNotNull(guestToken)}")
@ -208,6 +249,34 @@ class XPostResolver(
return if (reason == "NsfwLoggedOut" || text.startsWith("Age-restricted")) GraphqlOutcome.RequiresSession else GraphqlOutcome.Unavailable
}
/** Returns only X error codes/messages; response data and session values are never logged. */
private fun graphqlErrorSummary(root: JSONObject): String {
val errors = root.optJSONArray("errors") ?: return "no GraphQL errors"
return buildList {
for (index in 0 until errors.length()) {
val error = errors.optJSONObject(index) ?: continue
val code = error.optString("code", error.optString("kind", "unknown"))
val message = error.optString("message", "unknown")
.replace(Regex("\\s+"), " ")
.take(160)
add("$code: $message")
}
}.joinToString(" | ").ifBlank { "empty GraphQL errors" }
}
private fun logGraphqlShape(stage: String, vararg objects: JSONObject?) {
val shapes = objects.mapIndexed { index, value ->
val keys = value?.keys()?.asSequence()?.toList()?.sorted()?.joinToString(",").orEmpty()
"$index={${keys.take(600)}}"
}.joinToString(" ")
logWarning("TweetDetail $stage: $shapes")
}
// android.util.Log is unavailable in local JVM tests; diagnostics must never affect resolving.
private fun logWarning(message: String) {
runCatching { Log.w(LOG_TAG, message) }
}
private sealed interface GraphqlOutcome {
data class Resolved(val post: ResolvedPost) : GraphqlOutcome
data object RequiresSession : GraphqlOutcome
@ -361,12 +430,15 @@ class XPostResolver(
}
companion object {
private const val LOG_TAG = "XPostResolver"
private const val USER_AGENT = "Mozilla/5.0 (Android) AppleWebKit/537.36 Chrome/131 Mobile Safari/537.36"
private const val GRAPHQL_URL = "https://api.x.com/graphql/4Siu98E55GquhG52zHdY5w/TweetDetail"
private const val GUEST_TOKEN_URL = "https://api.x.com/1.1/guest/activate.json"
private const val BEARER = "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
private const val GRAPHQL_FEATURES = "{\"responsive_web_graphql_timeline_navigation_enabled\":true,\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":false,\"tweet_awards_web_tipping_enabled\":false,\"longform_notetweets_consumption_enabled\":true,\"responsive_web_enhance_cards_enabled\":false}"
private const val GRAPHQL_FIELD_TOGGLES = "{\"withArticleRichContentState\":true,\"withArticlePlainText\":false}"
// Keep this request contract aligned with cobalt's current Twitter resolver. X returns
// HTTP 200 plus an empty timeline when required feature flags are omitted.
private const val GRAPHQL_FEATURES = "{\"rweb_video_screen_enabled\":false,\"payments_enabled\":false,\"rweb_xchat_enabled\":false,\"profile_label_improvements_pcf_label_in_post_enabled\":true,\"rweb_tipjar_consumption_enabled\":true,\"verified_phone_label_enabled\":false,\"creator_subscriptions_tweet_preview_api_enabled\":true,\"responsive_web_graphql_timeline_navigation_enabled\":true,\"responsive_web_graphql_skip_user_profile_image_extensions_enabled\":false,\"premium_content_api_read_enabled\":false,\"communities_web_enable_tweet_community_results_fetch\":true,\"c9s_tweet_anatomy_moderator_badge_enabled\":true,\"responsive_web_grok_analyze_button_fetch_trends_enabled\":false,\"responsive_web_grok_analyze_post_followups_enabled\":true,\"responsive_web_jetfuel_frame\":true,\"responsive_web_grok_share_attachment_enabled\":true,\"articles_preview_enabled\":true,\"responsive_web_edit_tweet_api_enabled\":true,\"graphql_is_translatable_rweb_tweet_is_translatable_enabled\":true,\"view_counts_everywhere_api_enabled\":true,\"longform_notetweets_consumption_enabled\":true,\"responsive_web_twitter_article_tweet_consumption_enabled\":true,\"tweet_awards_web_tipping_enabled\":false,\"responsive_web_grok_show_grok_translated_post\":false,\"responsive_web_grok_analysis_button_from_backend\":true,\"creator_subscriptions_quote_tweet_preview_enabled\":false,\"freedom_of_speech_not_reach_fetch_enabled\":true,\"standardized_nudges_misinfo\":true,\"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled\":true,\"longform_notetweets_rich_text_read_enabled\":true,\"longform_notetweets_inline_media_enabled\":true,\"responsive_web_grok_image_annotation_enabled\":true,\"responsive_web_grok_imagine_annotation_enabled\":true,\"responsive_web_grok_community_note_auto_translation_is_enabled\":false,\"responsive_web_enhance_cards_enabled\":false}"
private const val GRAPHQL_FIELD_TOGGLES = "{\"withArticleRichContentState\":true,\"withArticlePlainText\":false,\"withGrokAnalyze\":false,\"withDisallowedReplyControls\":false}"
private val RESOLUTION_PATTERN = Regex("(?<!\\d)(\\d{2,5})x(\\d{2,5})(?!\\d)", RegexOption.IGNORE_CASE)
private val RESOLUTION_QUERY_NAMES = setOf("resolution", "dimensions", "dimension", "size")
@Volatile private var cachedGuestToken: String? = null

View file

@ -230,9 +230,13 @@ class DownloadForegroundService : Service() {
private const val MAX_CONCURRENCY = 2
private val canceledJobs = ConcurrentHashMap.newKeySet<String>()
fun start(context: android.content.Context) {
/** Returns false when Android temporarily forbids foreground-service starts. */
fun start(context: android.content.Context): Boolean = try {
val intent = Intent(context, DownloadForegroundService::class.java)
androidx.core.content.ContextCompat.startForegroundService(context, intent)
true
} catch (_: RuntimeException) {
false
}
fun cancel(id: String) {

View file

@ -76,6 +76,36 @@ class XPostResolverTest {
assertEquals("auth_token=auth-value; ct0=csrf-value", headers["Cookie"])
assertEquals("csrf-value", headers["X-Csrf-Token"])
assertEquals("OAuth2Session", headers["X-Twitter-Auth-Type"])
assertEquals("application/json", headers["Content-Type"])
}
@Test
fun usesTheCurrentCobaltTweetDetailRequestContract() {
var graphqlRequest: Request? = null
val resolver = resolverFor { request ->
when {
request.url.host == "cdn.syndication.twimg.com" -> jsonResponse(request, "{}", 503)
request.url.encodedPath.endsWith("guest/activate.json") -> jsonResponse(request, "{\"guest_token\":\"guest-token\"}")
else -> {
graphqlRequest = request
jsonResponse(request, resolvedTweet("123"))
}
}
}
resolver.resolve("123")
val variables = JSONObject(graphqlRequest?.url?.queryParameter("variables").orEmpty())
val features = JSONObject(graphqlRequest?.url?.queryParameter("features").orEmpty())
val fieldToggles = JSONObject(graphqlRequest?.url?.queryParameter("fieldToggles").orEmpty())
assertTrue(variables.getBoolean("withQuickPromoteEligibilityTweetFields"))
assertTrue(variables.getBoolean("withBirdwatchNotes"))
assertTrue(variables.getBoolean("withVoice"))
assertTrue(features.length() >= 30)
assertTrue(features.getBoolean("communities_web_enable_tweet_community_results_fetch"))
assertFalse(features.getBoolean("responsive_web_grok_show_grok_translated_post"))
assertFalse(fieldToggles.getBoolean("withGrokAnalyze"))
assertFalse(fieldToggles.getBoolean("withDisallowedReplyControls"))
}
@Test
@ -198,6 +228,24 @@ class XPostResolverTest {
assertTrue(authorizedRequestMade)
}
@Test
fun parsesTheModernUserShapeForAnAuthorizedProtectedPost() {
val resolver = resolverFor { request ->
when {
request.url.encodedPath.endsWith("guest/activate.json") -> jsonResponse(request, "{\"guest_token\":\"guest-token\"}")
request.header("X-Twitter-Auth-Type") == "OAuth2Session" -> jsonResponse(request, resolvedTweet("123", protected = true, modernUser = true))
else -> jsonResponse(request, protectedTweet("123"))
}
}
val post = resolver.resolve("123")
assertEquals("example", post.metadata.username)
assertEquals("Example", post.metadata.authorName)
assertEquals("1", post.metadata.userId)
assertEquals("https://pbs.twimg.com/profile_400x400.jpg", post.metadata.avatarUrl)
}
@Test
fun retriesAnUnavailableGuestResultWithTheAuthorizedSession() {
var authorizedRequestMade = false
@ -311,21 +359,29 @@ class XPostResolverTest {
.put("media_url_https", "https://pbs.twimg.com/media/example.jpg")))
.toString()
private fun resolvedTweet(id: String, protected: Boolean = false): String = tweetEnvelope(id, JSONObject()
.put("__typename", "Tweet")
.put("legacy", JSONObject()
.put("full_text", "hello")
.put("lang", "en")
.put("created_at", "2025-01-01T00:00:00Z")
.put("extended_entities", JSONObject().put("media", JSONArray().put(JSONObject()
.put("type", "photo")
.put("media_url_https", "https://pbs.twimg.com/media/example.jpg")))))
.put("core", JSONObject().put("user_results", JSONObject().put("result", JSONObject().put("legacy", JSONObject()
private fun resolvedTweet(id: String, protected: Boolean = false, modernUser: Boolean = false): String {
val user = if (modernUser) JSONObject()
.put("rest_id", "1")
.put("core", JSONObject().put("name", "Example").put("screen_name", "example"))
.put("avatar", JSONObject().put("image_url", "https://pbs.twimg.com/profile_normal.jpg"))
.put("privacy", JSONObject().put("protected", protected))
else JSONObject().put("legacy", JSONObject()
.put("name", "Example")
.put("screen_name", "example")
.put("id_str", "1")
.put("protected", protected)
.put("profile_image_url_https", ""))))))
.put("profile_image_url_https", ""))
return tweetEnvelope(id, JSONObject()
.put("__typename", "Tweet")
.put("legacy", JSONObject()
.put("full_text", "hello")
.put("lang", "en")
.put("created_at", "2025-01-01T00:00:00Z")
.put("extended_entities", JSONObject().put("media", JSONArray().put(JSONObject()
.put("type", "photo")
.put("media_url_https", "https://pbs.twimg.com/media/example.jpg")))))
.put("core", JSONObject().put("user_results", JSONObject().put("result", user))))
}
private fun tweetEnvelope(id: String, result: JSONObject): String {
val entry = JSONObject()