mirror of
https://github.com/imputnet/cobalt.git
synced 2026-10-24 02:52:18 +00:00
fix(android): resolve authorized protected X posts
This commit is contained in:
parent
c0ce31e42a
commit
5f5aa4fd26
4 changed files with 169 additions and 27 deletions
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue