Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

829 lines
30 KiB
Python

"""
X (Twitter) app state accessor.
"""
from __future__ import annotations
import json
import random
from functools import cached_property
from pathlib import Path
from typing import Any
from bench_env.task.base import BaseApp
_REPO_ROOT = Path(__file__).resolve().parents[3]
_X_DATA_DIR = _REPO_ROOT / "apps" / "X" / "data"
_X_USERS_JSON_PATH = _X_DATA_DIR / "users.json"
_X_POSTS_JSON_PATH = _X_DATA_DIR / "posts.json"
_X_USERS_JSON_CACHE: dict[str, dict[str, Any]] | None = None
_X_POSTS_JSON_CACHE: list[dict[str, Any]] | None = None
_X_POSTS_BY_ID_CACHE: dict[str, dict[str, Any]] | None = None
def _preview_text(text: Any, *, limit: int = 40) -> str:
plain = str(text or "").strip().replace("\n", " ")
if len(plain) <= limit:
return plain
return plain[: limit - 3] + "..."
def _pick_keyword(text: Any) -> str:
plain = str(text or "").strip().replace("\n", " ")
if not plain:
return ""
words = plain.split()
if len(words) >= 3:
return " ".join(words[: min(5, len(words))])
return plain[: min(8, len(plain))]
def _load_users_json() -> dict[str, Any]:
"""
加载 X 应用公共用户表。
"""
global _X_USERS_JSON_CACHE
if _X_USERS_JSON_CACHE is not None:
return _X_USERS_JSON_CACHE
try:
with _X_USERS_JSON_PATH.open("r", encoding="utf-8") as f:
users = json.load(f) or {}
_X_USERS_JSON_CACHE = users if isinstance(users, dict) else {}
except Exception:
_X_USERS_JSON_CACHE = {}
return _X_USERS_JSON_CACHE
def _load_posts_json() -> list[dict[str, Any]]:
"""
加载 X 应用公共推文表。
"""
global _X_POSTS_JSON_CACHE
if _X_POSTS_JSON_CACHE is not None:
return _X_POSTS_JSON_CACHE
try:
with _X_POSTS_JSON_PATH.open("r", encoding="utf-8") as f:
posts = json.load(f) or []
_X_POSTS_JSON_CACHE = posts if isinstance(posts, list) else []
except Exception:
_X_POSTS_JSON_CACHE = []
return _X_POSTS_JSON_CACHE
def _load_posts_by_id() -> dict[str, dict[str, Any]]:
global _X_POSTS_BY_ID_CACHE
if _X_POSTS_BY_ID_CACHE is not None:
return _X_POSTS_BY_ID_CACHE
_X_POSTS_BY_ID_CACHE = {
post.get("id"): post
for post in _load_posts_json()
if isinstance(post, dict) and post.get("id")
}
return _X_POSTS_BY_ID_CACHE
X_POST_CHANGES = ["x.posts", "x.user.postIds"]
class X(BaseApp):
"""
X state accessor.
Usage:
x = X(input.apps["x"])
x.view_posts()
x.conversations
x.users
"""
@staticmethod
def _from_env_state(env_state: dict[str, Any]) -> "X":
return X(BaseApp.get_by_path(env_state, "apps.x", {}) or {})
@property
def posts(self) -> list[dict[str, Any]]:
"""只读便捷别名;语义等同 view_posts()。"""
return self.view_posts()
def state_user(self) -> dict[str, Any]:
user = self.get("user") or {}
return user if isinstance(user, dict) else {}
@property
def state_posts(self) -> dict[str, Any]:
posts = self.get("posts") or {}
return posts if isinstance(posts, dict) else {}
@property
def base_posts(self) -> list[dict[str, Any]]:
return _load_posts_json()
def base_post(self, post_id: str) -> dict[str, Any] | None:
return _load_posts_by_id().get(post_id)
def state_post(self, post_id: str) -> dict[str, Any] | None:
value = self.state_posts.get(post_id)
return value if isinstance(value, dict) else None
def state_post_entities(self) -> list[dict[str, Any]]:
return [post for post in self.state_posts.values() if isinstance(post, dict)]
def view_post(self, post_id: str) -> dict[str, Any] | None:
table = self.state_posts
# runtime overlay: dict 覆盖 base; None 表示 tombstone (隐藏 base)
if post_id in table:
value = table[post_id]
if not isinstance(value, dict):
return None
return self._with_relationship_derived_stats(value)
base = self.base_post(post_id)
if not base:
return None
return self._with_relationship_derived_stats(base)
def _runtime_comment_counts(self) -> dict[str, int]:
counts: dict[str, int] = {}
for item in self.state_post_entities():
thread_id = item.get("threadId")
if thread_id:
counts[thread_id] = counts.get(thread_id, 0) + 1
return counts
def _with_relationship_derived_stats(
self,
post: dict[str, Any],
*,
liked_ids: set[str] | None = None,
retweeted_ids: set[str] | None = None,
comment_counts: dict[str, int] | None = None,
) -> dict[str, Any]:
stats = post.get("stats")
if not isinstance(stats, dict):
return post
post_id = post.get("id")
liked_ids = liked_ids if liked_ids is not None else self.liked_post_ids
retweeted_ids = retweeted_ids if retweeted_ids is not None else self.retweeted_post_ids
comment_counts = comment_counts if comment_counts is not None else self._runtime_comment_counts()
runtime_comments = comment_counts.get(post_id, 0)
likes_delta = 1 if post_id in liked_ids else 0
retweets_delta = 1 if post_id in retweeted_ids else 0
if not runtime_comments and not likes_delta and not retweets_delta:
return post
return {
**post,
"stats": {
**stats,
"comments": max(0, int(stats.get("comments") or 0) + runtime_comments),
"likes": max(0, int(stats.get("likes") or 0) + likes_delta),
"retweets": max(0, int(stats.get("retweets") or 0) + retweets_delta),
},
}
def view_posts(self) -> list[dict[str, Any]]:
table = self.state_posts
tombstones = {
post_id
for post_id, value in table.items()
if value is None
}
liked_ids = self.liked_post_ids
retweeted_ids = self.retweeted_post_ids
comment_counts = self._runtime_comment_counts()
combined = []
for _post_id, value in table.items():
if value is None or not isinstance(value, dict):
continue
combined.append(value)
combined.extend(
post for post in self.base_posts if post.get("id") not in tombstones
)
seen: set[str] = set()
out: list[dict[str, Any]] = []
for post in combined:
pid = post.get("id")
if not pid or pid in seen:
continue
seen.add(pid)
out.append(
self._with_relationship_derived_stats(
post,
liked_ids=liked_ids,
retweeted_ids=retweeted_ids,
comment_counts=comment_counts,
)
)
return out
def resolved_posts(self) -> list[dict[str, Any]]:
out = self.view_posts()
by_id = {post.get("id"): post for post in out}
retweet_shells = []
emitted: set[str] = set()
for post_id in reversed(list(self.state_user().get("retweetedPostIds") or [])):
if not post_id or post_id in emitted:
continue
source = by_id.get(post_id)
if not source:
continue
retweet_shells.append(
{
"id": f"retweet_{source.get('id')}",
"authorId": self.get("user.id"),
"content": "",
"time": "刚刚",
"stats": source.get("stats") or {"comments": 0, "retweets": 0, "likes": 0, "views": 0},
"retweetedPostId": source.get("id"),
}
)
emitted.add(post_id)
return [*retweet_shells, *out]
@property
def conversations(self) -> list[dict[str, Any]]:
return self.get_list("conversations")
@cached_property
def users(self) -> dict[str, Any]:
"""
Base users + me user 的合并视图。Instance 生命周期内只构建一次。
Contract: X instance 一旦构造, 其 _state 不应被替换 (BaseApp 的常规用法是
每次 task judge 创建新 instance, 不复用)。若调用方违反该约定, 此 cache 会
固化为旧 me user 快照。
"""
me = self.state_user()
base = _load_users_json()
if isinstance(me, dict) and me.get("id"):
return {**base, str(me["id"]): me}
return base
@property
def followed_user_ids(self) -> set[str]:
return {uid for uid in (self.state_user().get("followedUserIds") or []) if uid}
@property
def liked_post_ids(self) -> set[str]:
return {pid for pid in (self.state_user().get("likedPostIds") or []) if pid}
@property
def bookmarked_post_ids(self) -> set[str]:
return {pid for pid in (self.state_user().get("bookmarkedPostIds") or []) if pid}
@property
def retweeted_post_ids(self) -> set[str]:
return {pid for pid in (self.state_user().get("retweetedPostIds") or []) if pid}
@property
def user_post_ids(self) -> set[str]:
return {pid for pid in (self.state_user().get("postIds") or []) if pid}
@property
def user_reply_ids(self) -> set[str]:
return {pid for pid in (self.state_user().get("replyIds") or []) if pid}
def get_new_ids(self, now_list: list[dict[str, Any]], init_list: list[dict[str, Any]]) -> set[str]:
init_ids = {item.get("id") for item in init_list if item.get("id")}
return {
item.get("id")
for item in now_list
if item.get("id") and item.get("id") not in init_ids
}
def find_user_by_id(self, user_id: str) -> dict[str, Any] | None:
return self.users.get(user_id)
def get_user_handle(self, user_id: str) -> str:
"""返回 @-prefixed handle (data contract: user.id 不带 @, handle = '@' + id)。"""
if not self.find_user_by_id(user_id):
return "@unknown"
return f"@{user_id}"
def find_user_id_by_handle(self, handle: str) -> str | None:
"""传入 '@xxx' 或 'xxx'; 返回 user.id (不带 @)。Case-sensitive 精确匹配。"""
if not isinstance(handle, str):
return None
target = handle.lstrip("@")
return target if target and target in self.users else None
def find_post_by_id(self, post_id: str) -> dict[str, Any] | None:
return self.view_post(post_id)
def find_conversation_by_id(self, conversation_id: str) -> dict[str, Any] | None:
return next(
(c for c in self.conversations if c.get("id") == conversation_id),
None,
)
def new_posts_vs_init(self) -> list[dict[str, Any]]:
if not self.has_init:
raise ValueError("Init state required for X post diff")
current_posts = self.state_post_entities()
init_posts = self.init.state_post_entities()
base_ids = {
post.get("id")
for post in self.base_posts
if isinstance(post, dict) and post.get("id")
}
new_ids = self.get_new_ids(current_posts, init_posts) - base_ids
new_post_ids = self.new_user_post_ids()
new_reply_ids = self.new_user_reply_ids()
out: list[dict[str, Any]] = []
for post in current_posts:
post_id = post.get("id")
if post_id not in new_ids:
continue
if post.get("threadId"):
if post_id not in new_reply_ids:
continue
elif post_id not in new_post_ids:
continue
out.append(post)
return out
def new_followed_user_ids(self) -> set[str]:
if not self.has_init:
raise ValueError("Init state required for X follow diff")
return self.followed_user_ids - self.init.followed_user_ids
def new_liked_post_ids(self) -> set[str]:
if not self.has_init:
raise ValueError("Init state required for X like diff")
return self.liked_post_ids - self.init.liked_post_ids
def new_bookmarked_post_ids(self) -> set[str]:
if not self.has_init:
raise ValueError("Init state required for X bookmark diff")
return self.bookmarked_post_ids - self.init.bookmarked_post_ids
def new_retweeted_post_ids(self) -> set[str]:
if not self.has_init:
raise ValueError("Init state required for X retweet diff")
return self.retweeted_post_ids - self.init.retweeted_post_ids
def new_user_post_ids(self) -> set[str]:
if not self.has_init:
raise ValueError("Init state required for X post index diff")
return self.user_post_ids - self.init.user_post_ids
def new_user_reply_ids(self) -> set[str]:
if not self.has_init:
raise ValueError("Init state required for X reply index diff")
return self.user_reply_ids - self.init.user_reply_ids
def new_messages_in_conversation(self, conversation_id: str) -> list[dict[str, Any]]:
if not self.has_init:
raise ValueError("Init state required for X conversation diff")
current = self.find_conversation_by_id(conversation_id)
initial = self.init.find_conversation_by_id(conversation_id)
assert initial is not None, f"Conversation '{conversation_id}' not found in init state"
assert current is not None, f"Conversation '{conversation_id}' not found in current state"
current_messages = list(current.get("messages") or [])
initial_messages = list(initial.get("messages") or [])
return current_messages[len(initial_messages):]
def _interaction_check(
self,
added_ids: set[str],
*,
keyword: str,
field: str,
action_label: str,
) -> dict[str, Any]:
keyword_lower = str(keyword or "").lower().strip()
assert keyword_lower, "keyword must not be empty"
assert any(keyword_lower in str(post.get("content") or "").lower() for post in self.init.view_posts()), (
f"Keyword '{keyword}' does not match any init X posts"
)
matched = [
{"id": post.get("id"), "content": post.get("content")}
for post in self.view_posts()
if post.get("id") in added_ids
and keyword_lower in str(post.get("content") or "").lower()
]
return {
"field": field,
"expected": f"新增{action_label}的推文内容包含 {keyword!r}",
"actual": matched or list(added_ids),
"passed": bool(matched),
}
def check_new_post_contains(
self,
*keywords: str,
field: str = "x_post",
) -> dict[str, Any]:
actual = ""
matched = False
for post in self.new_posts_vs_init():
text = str(post.get("content") or "")
if all(keyword in text for keyword in keywords):
actual = text
matched = True
break
return {
"field": field,
"expected": f"new X post with {list(keywords)}",
"actual": actual or "(none)",
"passed": matched,
}
def check_created_quoted_post(
self,
post_id: str,
content: str,
*,
field: str = "quoted_post_created",
) -> dict[str, Any]:
target_post_id = post_id
target_content = str(content or "").lower().strip()
assert self.init.find_post_by_id(post_id) is not None, f"Post '{post_id}' not found in init state"
assert target_content, "content must not be empty"
matched = [
{
"id": post.get("id"),
"quotedPostId": post.get("quotedPostId"),
"content": post.get("content"),
}
for post in self.new_posts_vs_init()
if post.get("quotedPostId") == target_post_id
and target_content in str(post.get("content") or "").lower()
]
return {
"field": field,
"expected": {"quotedPostId": post_id, "content_contains": content},
"actual": matched or [
{
"id": post.get("id"),
"quotedPostId": post.get("quotedPostId"),
"content": post.get("content"),
}
for post in self.new_posts_vs_init()
],
"passed": bool(matched),
}
def check_sent_dm(
self,
conversation_id: str,
content: str,
*,
field: str = "dm_sent",
) -> dict[str, Any]:
target_content = str(content or "").strip()
assert target_content, "content must not be empty"
me_user_id = str(self.get("user.id") or "")
new_messages = self.new_messages_in_conversation(conversation_id)
actual_tail = [
{"senderId": message.get("senderId"), "content": message.get("content")}
for message in new_messages[-3:]
]
# 必须是当前用户发的, 且最后一条内容匹配。避免对方刚好发了同样文本时巧合 pass。
last = new_messages[-1] if new_messages else None
passed = bool(last) \
and str(last.get("senderId") or "") == me_user_id \
and str(last.get("content") or "").strip() == target_content
return {
"field": field,
"expected": {"senderId": me_user_id, "content": target_content},
"actual": actual_tail,
"passed": passed,
}
def check_bookmarked_post_for_keyword(
self,
keyword: str,
*,
field: str = "bookmarked_post",
) -> dict[str, Any]:
return self._interaction_check(
self.new_bookmarked_post_ids(),
keyword=keyword,
field=field,
action_label="书签",
)
def check_liked_post_for_keyword(
self,
keyword: str,
*,
field: str = "liked_post",
) -> dict[str, Any]:
return self._interaction_check(
self.new_liked_post_ids(),
keyword=keyword,
field=field,
action_label="点赞",
)
def check_followed_user(
self,
user_handle: str,
*,
field: str = "followed_user",
) -> dict[str, Any]:
user_id = self.init.find_user_id_by_handle(user_handle)
assert user_id is not None, f"Handle '{user_handle}' not found in init state"
assert user_id not in self.init.followed_user_ids, (
f"User '{user_handle}' was already followed in init state"
)
new_follows = self.new_followed_user_ids()
return {
"field": field,
"expected": user_handle,
"actual": list(new_follows),
"passed": user_id in new_follows,
}
def check_liked_post_by_user(
self,
user_handle: str,
*,
field: str = "liked_post_by_user",
) -> dict[str, Any]:
user_id = self.init.find_user_id_by_handle(user_handle)
assert user_id is not None, f"Handle '{user_handle}' not found in init state"
matched = [
{"id": post.get("id"), "content": post.get("content")}
for post in self.view_posts()
if post.get("id") in self.new_liked_post_ids()
and post.get("authorId") == user_id
]
return {
"field": field,
"expected": f"新增点赞来自 {user_handle}",
"actual": matched or list(self.new_liked_post_ids()),
"passed": bool(matched),
}
def check_replied_to_post(
self,
post_id: str,
reply_content: str,
*,
field: str = "reply_created",
) -> dict[str, Any]:
target_post_id = post_id
target_content = str(reply_content or "").lower().strip()
assert self.init.find_post_by_id(post_id) is not None, f"Post '{post_id}' not found in init state"
assert target_content, "reply_content must not be empty"
matched = [
{"id": post.get("id"), "threadId": post.get("threadId"), "content": post.get("content")}
for post in self.new_posts_vs_init()
if post.get("threadId") == target_post_id
and target_content in str(post.get("content") or "").lower()
]
return {
"field": field,
"expected": {"threadId": post_id, "content_contains": reply_content},
"actual": matched or [
{"id": post.get("id"), "threadId": post.get("threadId"), "content": post.get("content")}
for post in self.new_posts_vs_init()
],
"passed": bool(matched),
}
def check_retweeted_post(
self,
post_id: str,
*,
field: str = "post_retweeted",
) -> dict[str, Any]:
assert self.init.find_post_by_id(post_id) is not None, f"Post '{post_id}' not found in init state"
new_retweets = self.new_retweeted_post_ids()
return {
"field": field,
"expected": post_id,
"actual": list(new_retweets),
"passed": post_id in new_retweets,
}
def check_created_post(
self,
content: str,
*,
field: str = "post_created",
) -> dict[str, Any]:
target_content = str(content or "").lower().strip()
assert target_content, "content must not be empty"
matched = [
{"id": post.get("id"), "content": post.get("content")}
for post in self.new_posts_vs_init()
if not post.get("threadId")
and target_content in str(post.get("content") or "").lower()
]
return {
"field": field,
"expected": content,
"actual": matched or [
{"id": post.get("id"), "content": post.get("content")}
for post in self.new_posts_vs_init()
if not post.get("threadId")
],
"passed": bool(matched),
}
def check_replied_to_new_post(
self,
original_content: str,
reply_content: str,
*,
field: str = "reply_to_new_post",
) -> dict[str, Any]:
original_lower = str(original_content or "").lower().strip()
reply_lower = str(reply_content or "").lower().strip()
assert original_lower, "original_content must not be empty"
assert reply_lower, "reply_content must not be empty"
new_top_level_posts = [
post
for post in self.new_posts_vs_init()
if not post.get("threadId")
and original_lower in str(post.get("content") or "").lower()
]
if not new_top_level_posts:
return {
"field": field,
"expected": {"original_content": original_content, "reply_content": reply_content},
"actual": "原始新帖不存在",
"passed": False,
}
original_post_id = new_top_level_posts[0].get("id")
matched = [
{"id": post.get("id"), "threadId": post.get("threadId"), "content": post.get("content")}
for post in self.new_posts_vs_init()
if post.get("threadId") == original_post_id
and reply_lower in str(post.get("content") or "").lower()
]
return {
"field": field,
"expected": {"original_content": original_content, "reply_content": reply_content},
"actual": matched or [
{"id": post.get("id"), "threadId": post.get("threadId"), "content": post.get("content")}
for post in self.new_posts_vs_init()
if post.get("threadId") == original_post_id
],
"passed": bool(matched),
}
@staticmethod
def _sample_post_reference_impl(
env_state: dict[str, Any],
rng: random.Random,
*,
exclude_retweeted: bool = False,
profile_rank_limit: int = 40,
) -> dict[str, str]:
app = X._from_env_state(env_state)
posts = app.view_posts()
if not posts:
raise RuntimeError("未找到可采样的 X 推文目标")
already_retweeted = app.retweeted_post_ids if exclude_retweeted else set()
# Build per-author rank: only posts within the first `profile_rank_limit`
# positions for their author are eligible — guarantees the post is visible
# near the top of the author's profile page without heavy scrolling.
author_rank: dict[str, int] = {}
eligible: list[dict[str, Any]] = []
for post in posts:
pid = post.get("id")
if not pid:
continue
if not str(post.get("content") or "").strip():
continue
if post.get("threadId"):
continue
if pid in already_retweeted:
continue
author_id = str(post.get("authorId") or "")
rank = author_rank.get(author_id, 0) + 1
author_rank[author_id] = rank
if rank <= profile_rank_limit:
eligible.append(post)
if not eligible:
raise RuntimeError("未找到可采样的 X 推文目标")
rng.shuffle(eligible)
for post in eligible:
author_handle = app.get_user_handle(str(post.get("authorId")))
if author_handle == "@unknown":
continue
return {
"post_id": str(post.get("id")),
"author_handle": author_handle,
"post_preview": _preview_text(post.get("content")),
}
raise RuntimeError("未找到可采样的 X 推文目标")
@staticmethod
def sample_post_reference(env_state: dict[str, Any], rng: random.Random) -> dict[str, str]:
return X._sample_post_reference_impl(env_state, rng, exclude_retweeted=False)
@staticmethod
def sample_unretweeted_post_reference(
env_state: dict[str, Any], rng: random.Random
) -> dict[str, str]:
return X._sample_post_reference_impl(env_state, rng, exclude_retweeted=True)
@staticmethod
def sample_conversation_reference(env_state: dict[str, Any], rng: random.Random) -> dict[str, str]:
app = X._from_env_state(env_state)
candidates = []
for conversation in app.conversations:
conversation_id = str(conversation.get("id") or "").strip()
participant_id = str(conversation.get("participantId") or "").strip()
messages = list(conversation.get("messages") or [])
handle = app.get_user_handle(participant_id)
if not conversation_id or not messages or handle == "@unknown":
continue
candidates.append(
{
"conversation_id": conversation_id,
"participant_handle": handle,
"last_message_preview": _preview_text(messages[-1].get("content"), limit=32),
}
)
if not candidates:
raise RuntimeError("未找到可采样的 X 私信会话")
return rng.choice(candidates)
@staticmethod
def sample_search_keyword(env_state: dict[str, Any], rng: random.Random) -> dict[str, str]:
app = X._from_env_state(env_state)
keywords = []
seen: set[str] = set()
for post in app.view_posts():
keyword = _pick_keyword(post.get("content"))
keyword_lower = keyword.lower()
if not keyword or keyword_lower in seen:
continue
seen.add(keyword_lower)
keywords.append(keyword)
if not keywords:
raise RuntimeError("未找到可采样的 X 搜索关键词")
return {"keyword": rng.choice(keywords)}
@staticmethod
def sample_search_keyword_pair(env_state: dict[str, Any], rng: random.Random) -> dict[str, str]:
app = X._from_env_state(env_state)
keywords = []
seen: set[str] = set()
for post in app.view_posts():
keyword = _pick_keyword(post.get("content"))
keyword_lower = keyword.lower()
if not keyword or keyword_lower in seen:
continue
seen.add(keyword_lower)
keywords.append(keyword)
if len(keywords) >= 8:
break
if len(keywords) < 2:
raise RuntimeError("未找到两个不同的 X 搜索关键词")
picked = rng.sample(keywords, 2)
return {"keyword1": picked[0], "keyword2": picked[1]}
@staticmethod
def sample_follow_target(env_state: dict[str, Any], rng: random.Random) -> dict[str, str]:
"""
从可见 post 池里收集 "未关注 + 有 handle" 的作者作为候选, 均匀随机抽一个。
要求未关注是因为任务模板是 "先关注 X 再点赞", check_followed_user 查
new_followed_user_ids 增量, 已关注的用户无法形成增量。
"""
app = X._from_env_state(env_state)
followed = app.followed_user_ids
candidates: list[dict[str, str]] = []
seen_authors: set[str] = set()
for post in app.view_posts():
author_id = post.get("authorId")
if not author_id or author_id in followed or author_id in seen_authors:
continue
seen_authors.add(author_id)
user = app.find_user_by_id(author_id)
if not user:
continue
candidates.append({
"user_handle": f"@{author_id}",
"user_name": str(user.get("name") or "某位用户").strip() or "某位用户",
})
if not candidates:
raise RuntimeError("未找到可采样的 X 关注目标")
return rng.choice(candidates)