Files
cv-cat dc078b248b catbus:一个命令操作小红书、抖音、TikTok、B 站、快手、微博、闲鱼、淘宝、京东、X
10 个平台的 web 端全部用 TypeScript 移植自 cv-cat 的开源项目,请求与上游逐字节对拍。
统一的命令语法与 JSON 输出、按 平台 × 端 × 账号 隔离的登录态、给 AI Agent 用的技能与 llms.txt。
2026-10-06 19:24:55 +08:00

418 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""x 的对拍数据:用上游 XApis 的代码构造请求(只用假凭证),记录请求序列。
运行:.golden/x/Scripts/python scripts/golden/x/gen.py
依赖:uv pip install curl_cffi requests
XCTID 素材固定用上游的 static/transaction_l1.json(ClientTransaction.from_cached),
不走 from_network(它会去抓 /i/flow/login,对拍时不能发网络);catbus 也只用这份缓存。
web 端不支持游客态(AGENTS 5.2),所以没有游客用例。
"""
import base64
import hashlib
import shutil
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import catbus_golden as g # noqa: E402
out = g.setup('x', 'XApis')
from builder.auth import XAuth # noqa: E402
from utils.transaction import ClientTransaction # noqa: E402
from utils.article_util import markdown_to_content_state, split_title # noqa: E402
from utils.x_util import tweet_weight # noqa: E402
from x_apis.x_api import XAPI, graphql_get # noqa: E402
from x_apis.x_article_api import XArticleAPI # noqa: E402
from x_apis.x_dm_api import XChatAPI # noqa: E402
from x_apis.x_media_api import XMediaAPI # noqa: E402
from x_apis.x_write_api import XWriteAPI # noqa: E402
# 只有假值
COOKIES = 'auth_token=fakeauthtoken000000000000000000000000000000; ct0=fakect0000000000000000000000000000000000000000; twid=u%3D10001; lang=en'
TWEET_ID = '1585341984679469056'
USER_ID = '44196397'
MEDIA_ID = '1790000000000000100'
ARTICLE_ID = '1790000000000000200'
PNG = bytes.fromhex('89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489') + b'\x00fake-png\xff'
MP4 = b'\x00\x00\x00\x18ftypmp42' + b'fake-video-' * 8
# 权重 > 280 的正文:141 个汉字(每个算 2),网页端会按 Premium 长推(CreateNoteTweet)发
LONG_TEXT = '长' * 131 + ' https://example.com/a-very-long-url-that-counts-as-23 ' + '文' * 10
USER = {
'__typename': 'User', 'rest_id': USER_ID,
'core': {'created_at': 'Tue Jun 02 20:12:29 +0000 2009', 'name': 'Elon Musk', 'screen_name': 'elonmusk'},
'avatar': {'image_url': 'https://pbs.twimg.com/profile_images/1/fake_normal.jpg'},
'profile_bio': {'description': '简介 & bio'},
'relationship_counts': {'followers': 241701251, 'following': 1412},
'tweet_counts': {'media_tweets': 4724, 'tweets': 109119},
'action_counts': {'favorites_count': 249608},
}
ME = {'__typename': 'User', 'rest_id': '10001', 'core': {'name': '测试账号', 'screen_name': 'catbus_test'}}
PEER = {'__typename': 'User', 'rest_id': '20002', 'core': {'name': 'Peer', 'screen_name': 'peer_user'}}
PHOTO = {'type': 'photo', 'id_str': '900', 'media_url_https': 'https://pbs.twimg.com/media/fake.jpg', 'original_info': {'width': 1200, 'height': 800}}
VIDEO = {
'type': 'video', 'id_str': '901', 'media_url_https': 'https://pbs.twimg.com/ext_tw_video_thumb/901/pu/img/thumb.jpg',
'original_info': {'width': 1920, 'height': 1080},
'video_info': {'duration_millis': 9301, 'variants': [
{'content_type': 'application/x-mpegURL', 'url': 'https://video.twimg.com/ext_tw_video/901/pu/pl/x.m3u8'},
{'bitrate': 256000, 'content_type': 'video/mp4', 'url': 'https://video.twimg.com/ext_tw_video/901/pu/vid/480x270/low.mp4'},
{'bitrate': 2176000, 'content_type': 'video/mp4', 'url': 'https://video.twimg.com/ext_tw_video/901/pu/vid/1280x720/high.mp4'},
]},
}
def tweet(tid, text, user=USER, media=None, reply_to=None, views='12345'):
legacy = {
'id_str': tid, 'full_text': text, 'created_at': 'Wed Oct 26 18:45:58 +0000 2022', 'lang': 'zh',
'favorite_count': 11, 'reply_count': 22, 'bookmark_count': 33, 'retweet_count': 44, 'quote_count': 55,
'user_id_str': user['rest_id'],
}
if reply_to:
legacy['in_reply_to_status_id_str'] = reply_to
if media:
legacy['entities'] = {'media': media[:1]}
legacy['extended_entities'] = {'media': media}
return {'__typename': 'Tweet', 'rest_id': tid, 'core': {'user_results': {'result': user}},
'views': {'count': views, 'state': 'EnabledWithCount'}, 'legacy': legacy}
def tweet_entry(tid, result):
return {'entryId': f'tweet-{tid}', 'sortIndex': tid, 'content': {
'entryType': 'TimelineTimelineItem', '__typename': 'TimelineTimelineItem',
'itemContent': {'itemType': 'TimelineTweet', '__typename': 'TimelineTweet', 'tweet_results': {'result': result}}}}
def cursor_entry(kind, value):
return {'entryId': f'cursor-{kind.lower()}-{value}', 'sortIndex': '0', 'content': {
'entryType': 'TimelineTimelineCursor', '__typename': 'TimelineTimelineCursor', 'value': value, 'cursorType': kind}}
# 用户主页时间线:置顶 + 一条带图 + 一条带视频(包在 TweetWithVisibilityResults 里)+ 广告 + 游标
USER_TIMELINE = {'data': {'user': {'result': {'__typename': 'User', 'timeline': {'timeline': {'instructions': [
{'type': 'TimelineClearCache'},
{'type': 'TimelinePinEntry', 'entry': tweet_entry('1001', tweet('1001', '置顶推文'))},
{'type': 'TimelineAddEntries', 'entries': [
tweet_entry('1002', tweet('1002', '带图 https://t.co/x', media=[PHOTO])),
tweet_entry('1003', {'__typename': 'TweetWithVisibilityResults', 'tweet': tweet('1003', '带视频', media=[VIDEO])}),
{'entryId': 'promoted-tweet-1004', 'sortIndex': '1', 'content': {'itemContent': {'tweet_results': {'result': tweet('1004', '广告')}}}},
cursor_entry('Top', 'CURSOR_TOP'),
cursor_entry('Bottom', 'CURSOR_NEXT'),
]},
]}}}}}}
# 推文详情:焦点推文 + 一个对话串(直接回复 + 楼中楼 + 展开更多)+ 底部游标
DETAIL = {'data': {'threaded_conversation_with_injections_v2': {'instructions': [{'type': 'TimelineAddEntries', 'entries': [
tweet_entry(TWEET_ID, tweet(TWEET_ID, '焦点推文', media=[VIDEO])),
{'entryId': 'conversationthread-2001', 'sortIndex': '2', 'content': {
'entryType': 'TimelineTimelineModule', '__typename': 'TimelineTimelineModule', 'items': [
{'entryId': 'conversationthread-2001-tweet-2001', 'item': {'itemContent': {
'itemType': 'TimelineTweet', 'tweet_results': {'result': tweet('2001', '@elonmusk 第一条回复', user=PEER, reply_to=TWEET_ID)}}}},
{'entryId': 'conversationthread-2001-tweet-2002', 'item': {'itemContent': {
'itemType': 'TimelineTweet', 'tweet_results': {'result': tweet('2002', '@peer_user 楼中楼', reply_to='2001')}}}},
{'entryId': 'conversationthread-2001-cursor-showmore-1', 'item': {'itemContent': {
'itemType': 'TimelineTimelineCursor', 'value': 'SHOWMORE', 'cursorType': 'ShowMore'}}},
]}},
cursor_entry('Bottom', 'DETAIL_NEXT'),
]}]}}}
def grid_item(tid, result):
return {'entryId': f'search-grid-0-tweet-{tid}', 'item': {'itemContent': {
'itemType': 'TimelineTweet', '__typename': 'TimelineTweet', 'tweet_results': {'result': result}}}}
# 媒体搜索(product=Media):首页是 search-grid-0 宫格模块,翻页用 TimelineAddToModule 往模块里追加
SEARCH_MEDIA = {'data': {'search_by_raw_query': {'search_timeline': {'timeline': {'instructions': [
{'type': 'TimelineAddEntries', 'entries': [
{'entryId': 'search-grid-0', 'sortIndex': '2', 'content': {
'entryType': 'TimelineTimelineModule', '__typename': 'TimelineTimelineModule', 'displayType': 'VerticalGrid', 'items': [
grid_item('1002', tweet('1002', '带图', media=[PHOTO])),
grid_item('1003', {'__typename': 'TweetWithVisibilityResults', 'tweet': tweet('1003', '带视频', media=[VIDEO])}),
]}},
cursor_entry('Top', 'MEDIA_TOP'),
cursor_entry('Bottom', 'MEDIA_NEXT'),
]},
]}}}}}
SEARCH_MEDIA_MORE = {'data': {'search_by_raw_query': {'search_timeline': {'timeline': {'instructions': [
{'type': 'TimelineAddToModule', 'moduleEntryId': 'search-grid-0', 'moduleItems': [
grid_item('1005', tweet('1005', '又一张图', media=[PHOTO])),
]},
{'type': 'TimelineReplaceEntry', 'entry_id_to_replace': 'cursor-bottom-MEDIA_NEXT', 'entry': cursor_entry('Bottom', 'MEDIA_NEXT_2')},
]}}}}}
# X Chat 收件箱:participants_results 只有 rest_id、没有资料时,用 GetUsersByIdsForXChat 补
BARE_INBOX = {'data': {'get_initial_chat_page': {'__typename': 'XChatInboxPage', 'items': [
{'conversation_detail': {'conversation_id': '10001:20002', 'participants_results': [{'rest_id': '10001'}, {'rest_id': '20002'}]}},
{'conversation_detail': {'conversation_id': '10001:30003', 'participants_results': [{'rest_id': '10001'}, {'rest_id': '30003'}]}},
]}}}
MEMBERS = {'data': {'get_member_results': {'results': [
{'__typename': 'XChatMemberResult', 'id': '20002', 'status': 'Active', 'member_results': {'rest_id': '20002', 'result': PEER}},
{'__typename': 'XChatMemberResult', 'id': '30003', 'status': 'Active', 'member_results': {'rest_id': '30003', 'result': {
'__typename': 'User', 'rest_id': '30003', 'core': {'name': '第三人', 'screen_name': 'third_user'}}}},
]}}}
def article_payload(key, **extra):
"""ArticleEntity* 的响应:实体里带 rest_id 和 relay 全局 id(base64("ArticleEntity:<rest_id>"))。"""
entity = {'rest_id': ARTICLE_ID, 'id': base64.b64encode(f'ArticleEntity:{ARTICLE_ID}'.encode()).decode(), **extra}
return {'data': {key: entity}}
state = {'status': 0}
def respond(req):
url = req['url']
if 'upload.x.com' in url:
if 'command=INIT' in url:
return {'media_id': int(MEDIA_ID), 'media_id_string': MEDIA_ID, 'expires_after_secs': 86399}
if 'command=APPEND' in url:
# 线上是 204;测试侧的 Response 不能构造带 body 的 204,这里用 200 空 body
return {'status': 200, 'body': ''}
if 'command=FINALIZE' in url:
if 'video' in state.get('kind', ''):
return {'media_id_string': MEDIA_ID, 'size': len(MP4), 'processing_info': {'state': 'pending', 'check_after_secs': 1}}
return {'media_id_string': MEDIA_ID, 'size': len(PNG), 'image': {'image_type': 'image/png', 'w': 1, 'h': 1}}
if 'command=STATUS' in url:
state['status'] += 1
done = state['status'] >= 2
return {'media_id_string': MEDIA_ID, 'processing_info': {'state': 'succeeded' if done else 'in_progress', 'check_after_secs': 2, 'progress_percent': 100 if done else 40},
'video': {'video_type': 'video/mp4'}}
if 'media/metadata/create' in url:
return {'status': 200, 'body': ''}
if '/UserByScreenName' in url or '/UserByRestId' in url:
return {'data': {'user': {'result': USER}}}
if '/TweetResultByRestId' in url:
return {'data': {'tweetResult': {'result': tweet(TWEET_ID, '视频推文 https://t.co/v', media=[VIDEO])}}}
if '/TweetDetail' in url:
return DETAIL
if '/UserTweets' in url or '/UserOriginalsTimeline' in url:
return USER_TIMELINE
if '/Viewer' in url:
return {'data': {'viewer': {'user_results': {'result': ME}}}}
if '/CreateTweet' in url or '/CreateNoteTweet' in url:
# 每次发推给一个新 id(3001、3002……),thread 里后一条回复前一条
state['tweets'] = state.get('tweets', 0) + 1
tid = str(3000 + state['tweets'])
media = [PHOTO] if 'media_entities":[{' in (req['body'] or '') else None
created = tweet(tid, 'hello', user=ME, media=media, views=None)
if '/CreateNoteTweet' in url:
created['note_tweet'] = {'is_expandable': True, 'note_tweet_results': {'result': {'id': 'Tm90ZVR3ZWV0OjE=', 'text': LONG_TEXT}}}
return {'data': {'notetweet_create': {'tweet_results': {'result': created}}}}
return {'data': {'create_tweet': {'tweet_results': {'result': created}}}}
if '/SearchTimeline' in url and '"product":"Media"' in (req['body'] or ''):
return SEARCH_MEDIA_MORE if '"cursor":' in req['body'] else SEARCH_MEDIA
if '/ArticleEntityDraftCreate' in url:
return {'data': {'articleentity_draft_create': {'article_entity_results': {'result': article_payload('x')['data']['x']}}}}
if '/ArticleEntityUpdateTitle' in url:
return article_payload('articleentity_update_title', title='文章标题')
if '/ArticleEntityUpdateContent' in url:
return article_payload('articleentity_update_content')
if '/ArticleEntityUpdateCoverMedia' in url:
return article_payload('articleentity_update_cover_media')
if '/ArticleEntityPublish' in url:
return {'data': {'articleentity_publish': {'article_entity_results': {'result': {
'rest_id': ARTICLE_ID, 'lifecycle_state': {'lifecycle': 'Published'},
'metadata': {'tweet_results': {'result': {'rest_id': '4001'}}}}}}}}
if '/ArticleEntityDelete' in url:
return {'data': {'articleentity_delete': 'Done'}}
if '/GetUsersByIdsForXChat' in url:
return MEMBERS
if '/GetInitialXChatPageQuery' in url and state.get('kind') == 'chat_list_members':
return BARE_INBOX
if '/GetInitialXChatPageQuery' in url:
return {'data': {'get_initial_chat_page': {'__typename': 'XChatInboxPage', 'items': [
{'conversation_detail': {'conversation_id': '10001:20002', 'participants_results': [
{'rest_id': '10001', 'result': ME}, {'rest_id': '20002', 'result': PEER}]},
'latest_message_events': ['CAESBGZha2U=']},
{'conversation_detail': {'conversation_id': 'g1790000000', 'participants_results': [
{'rest_id': '10001', 'result': ME}, {'rest_id': '20002', 'result': PEER}],
'group_metadata': {'group_name': '群聊', 'updated_at_msec': '1790000000123'}}},
]}}}
if '/GetConversationPageQuery' in url:
return {'data': {'get_conversation_page': {'encoded_message_events': ['CAESBGZha2U=', 'CAISBGZha2U='], 'has_more': False}}}
return {'data': {}}
def logged():
auth = XAuth().prepare_auth(COOKIES)
auth._transaction = ClientTransaction.from_cached()
return auth
def case(name, fn, **input):
state.clear()
state['status'] = 0
state['kind'] = name
g.case(out, name, fn, input=input, respond=respond)
def L(fn):
return lambda: fn(logged())
tmp = Path(tempfile.mkdtemp(prefix='catbus-x-'))
(tmp / 'photo.png').write_bytes(PNG)
(tmp / 'clip.mp4').write_bytes(MP4)
b64 = lambda b: base64.b64encode(b).decode() # noqa: E731
# ---------------------------------------------------------------- XCTID 纯算(固定时间与随机字节)
case('xctid', lambda: [ClientTransaction.from_cached().generate('GET', '/i/api/graphql/KybxDj9RrADIITXlGG8kpw/UserByScreenName', time_now=t, random_byte=b)
for t, b in ((107075600, 0), (107075600, 58), (1, 255))])
# ---------------------------------------------------------------- 命令流程:user items 先按用户名查 rest_id
def user_items():
auth = logged()
return XAPI.get_user_post_note(auth, XAPI.get_user_id(auth, '@ElonMusk'))
case('user_items', user_items, user='@ElonMusk')
# ---------------------------------------------------------------- 读接口
case('tweet_detail', L(lambda a: XAPI.get_work_comments(a, TWEET_ID)), item=TWEET_ID)
case('tweet_detail_cursor', L(lambda a: XAPI.get_work_info(a, f'https://twitter.com/elonmusk/status/{TWEET_ID}?s=20', cursor='DETAIL_NEXT')),
item=TWEET_ID, cursor='DETAIL_NEXT')
case('tweet_detail_referrer', L(lambda a: XAPI.get_work_info(a, TWEET_ID, referrer='home')), item=TWEET_ID, referrer='home')
case('tweet_result', L(lambda a: XAPI.get_work_result(a, TWEET_ID)), item=TWEET_ID)
case('search_top', L(lambda a: XAPI.search_work(a, '猫 & dog #tag')), q='猫 & dog #tag')
case('search_latest_cursor', L(lambda a: XAPI.search_work(a, 'python', cursor='SEARCH_NEXT', product='Latest')), q='python', cursor='SEARCH_NEXT')
case('search_people', L(lambda a: XAPI.search_work(a, 'elon', product='People')), q='elon')
case('user_info', L(lambda a: XAPI.get_user_info(a, 'https://x.com/ElonMusk')), user='https://x.com/ElonMusk')
case('user_by_id', L(lambda a: graphql_get(a, 'UserByRestId', {'userId': USER_ID, 'withGrokTranslatedBio': True},
referer=f'https://x.com/i/user/{USER_ID}')), user=USER_ID)
case('user_originals', L(lambda a: XAPI.get_user_post_note(a, USER_ID)), user=USER_ID)
case('user_originals_cursor', L(lambda a: XAPI.get_user_post_note(a, USER_ID, cursor='CURSOR_NEXT')), user=USER_ID, cursor='CURSOR_NEXT')
case('user_tweets', L(lambda a: XAPI.get_user_post_note(a, USER_ID, operation='UserTweets')), user=USER_ID)
case('home', L(lambda a: XAPI.get_home_timeline(a)))
case('home_cursor', L(lambda a: XAPI.get_home_timeline(a, cursor='HOME_NEXT')), cursor='HOME_NEXT')
case('home_seen', L(lambda a: XAPI.get_home_timeline(a, seen_tweet_ids=['1001', '1002'])), seen=['1001', '1002'])
case('viewer', L(lambda a: XAPI.get_viewer(a)))
# ---------------------------------------------------------------- 写接口
case('create_tweet', L(lambda a: XWriteAPI.create_tweet(a, '你好 "x" & <b>\n第二行')), text='你好 "x" & <b>\n第二行')
case('create_tweet_reply', L(lambda a: XWriteAPI.create_tweet(a, 'reply', reply_to='https://x.com/a/status/20')), text='reply', reply_to='20')
case('create_tweet_media', L(lambda a: XWriteAPI.create_tweet(a, 'pics', media_ids=['111', '222'], quote_url='https://x.com/a/status/20')),
text='pics', media=['111', '222'], quote='https://x.com/a/status/20')
case('delete_tweet', L(lambda a: XWriteAPI.delete_tweet(a, TWEET_ID)), item=TWEET_ID)
case('favorite', L(lambda a: XWriteAPI.favorite_tweet(a, TWEET_ID)), item=TWEET_ID)
case('unfavorite', L(lambda a: XWriteAPI.unfavorite_tweet(a, TWEET_ID)), item=TWEET_ID)
case('retweet', L(lambda a: XWriteAPI.create_retweet(a, TWEET_ID)), item=TWEET_ID)
case('unretweet', L(lambda a: XWriteAPI.delete_retweet(a, TWEET_ID)), item=TWEET_ID)
case('bookmark', L(lambda a: XWriteAPI.create_bookmark(a, TWEET_ID)), item=TWEET_ID)
case('unbookmark', L(lambda a: XWriteAPI.delete_bookmark(a, TWEET_ID)), item=TWEET_ID)
case('follow', L(lambda a: XWriteAPI.follow_user(a, USER_ID)), user=USER_ID)
case('unfollow', L(lambda a: XWriteAPI.unfollow_user(a, USER_ID)), user=USER_ID)
# ---------------------------------------------------------------- 媒体上传
case('media_init', L(lambda a: XMediaAPI.init(a, 3, 'image/jpeg')), size=3, type='image/jpeg')
case('media_init_video', L(lambda a: XMediaAPI.init(a, 1000, 'video/mp4')), size=1000, type='video/mp4')
case('media_append', L(lambda a: XMediaAPI.append(a, MEDIA_ID, b'abc', 0) and None), media=MEDIA_ID, chunk='abc', index=0)
case('media_finalize', L(lambda a: XMediaAPI.finalize(a, MEDIA_ID, original_md5=hashlib.md5(b'abc').hexdigest())), media=MEDIA_ID)
case('media_status', L(lambda a: XMediaAPI.status(a, MEDIA_ID)), media=MEDIA_ID)
case('media_meta', L(lambda a: XMediaAPI.metadata_create(a, MEDIA_ID) and None), media=MEDIA_ID)
case('media_upload_image', L(lambda a: XMediaAPI.upload(a, str(tmp / 'photo.png'))), filename='photo.png', data=b64(PNG))
case('media_upload_video', L(lambda a: XMediaAPI.upload(a, str(tmp / 'clip.mp4'))), filename='clip.mp4', data=b64(MP4))
# ---------------------------------------------------------------- 私信(X Chat)
case('chat_initial', L(lambda a: XChatAPI.get_initial_chat_page(a)))
case('chat_conversation', L(lambda a: XChatAPI.get_conversation_page(a, '10001:20002')), conversation='10001:20002')
# ---------------------------------------------------------------- 命令流程:发推 = 传图 + CreateTweet
case('post_tweet', L(lambda a: XWriteAPI.post_tweet(a, 'hello', images=[str(tmp / 'photo.png')])[2]),
text='hello', filename='photo.png', data=b64(PNG))
# ---------------------------------------------------------------- 长推 / thread(上游 3fe6ea7)
# twitter-text v3 权重:拉丁 1、CJK / emoji 2,链接一律 23;emoji 的 ZWJ / 变体选择符 / 肤色不单独计数
WEIGHT_TEXTS = [
'', 'hello', '你好', 'a' * 280, 'a' * 281, '中' * 140, '中' * 141,
'看这个 https://example.com/path?q=1,好的', 'HTTPS://EXAMPLE.COM/A 和 http://t.co/x。',
'👍', '👍🏽', '👨\u200d👩\u200d👧', '❤️', '1\ufe0f\u20e3', 'é', '\u2013\u2018\u2032\u2014', '\u3000全角\uff01',
'mixed 中文 🎉 text https://a.b/c\u3001next',
]
case('tweet_weight', lambda: [tweet_weight(t) for t in WEIGHT_TEXTS], texts=WEIGHT_TEXTS)
case('create_note_tweet', L(lambda a: XWriteAPI.create_note_tweet(a, LONG_TEXT, reply_to='20', quote_url='https://x.com/a/status/21')),
text=LONG_TEXT, reply_to='20', quote='https://x.com/a/status/21')
# 命令流程:正文超过 280 权重时自动改走 CreateNoteTweet
case('post_long_tweet', L(lambda a: XWriteAPI.post_tweet(a, LONG_TEXT)[2]), text=LONG_TEXT)
# 命令流程:thread,第一条带图,第二条超长(自动长推),之后每条回复上一条
THREAD = ['第一条', LONG_TEXT, '第三条']
case('post_thread', L(lambda a: XWriteAPI.post_thread(a, THREAD, images=[[str(tmp / 'photo.png')]])),
texts=THREAD, filename='photo.png', data=b64(PNG))
# 命令流程:comment add = 回复推文,超过 280 权重时同样自动长推(上游 main.py post --reply-to)
case('comment_add_long', L(lambda a: XWriteAPI.post_tweet(a, LONG_TEXT, reply_to=TWEET_ID)[2]), text=LONG_TEXT, item=TWEET_ID)
# 命令流程:引用推文。--quote 给的是 ID 时先查出推文的链接,再作为 quote_url(attachment_url)发推
case('post_quote', L(lambda a: [XAPI.get_work_result(a, TWEET_ID),
XWriteAPI.post_tweet(a, 'quote', quote_url=f'https://x.com/elonmusk/status/{TWEET_ID}')[2]]),
text='quote', quote=TWEET_ID)
# ---------------------------------------------------------------- 媒体搜索(product=Media)
case('search_media', L(lambda a: XAPI.search_work(a, 'cat', product='Media')), q='cat')
case('search_media_cursor', L(lambda a: XAPI.search_work(a, 'cat', cursor='MEDIA_NEXT', product='Media')), q='cat', cursor='MEDIA_NEXT')
# ---------------------------------------------------------------- 文章(Article)
MARKDOWN = """# 文章标题
第一段 **粗体** 和 *斜体*,还有 ~~删除线~~ 与 [链接](https://example.com)。
emoji 👍👨\u200d👩\u200d👧 之后的 **BOLD** 仍按码点计 offset
**粗 *斜*** 嵌套,\\*转义\\*,**[粗链接](https://b.c)**
## 小标题
- 无序一
* 无序二
1. 有序一
2) 有序二
> 引用 *斜*
---
![图](photo.png)
```python
print("hi")
```
最后一行"""
MARKDOWN_NO_TITLE = "正文第一行\n\n### 三级标题\n+ 加号列表\n***\n~~~\ncode\n~~~"
# 正则边界:未闭合 / 相邻记号、链接里嵌粗体、六级标题、两位数序号、> 后不带空格、带 title 的图片、
# 行首尾的 Python 空白(\x1c、\u3000)、\r\n 换行、行内的 \r 与 \u2028
MARKDOWN_EDGE = ('###### 六级标题\r\n**未闭合 *a*b* ~~x~~~~y~~ [**粗**链接](https://e.f/g?h=1) `代码`\r\n'
'10. 十\n>紧贴引用\n![有标题](pic.png "标题")\n\x1c\u3000前后空白\u3000\n'
'行内\r回车与\u2028分隔 **a\\*b** *c\\*d* \\[不是链接](x)\n- \n-\n#不是标题\n``` \n未收尾的围栏')
MEDIA_IDS = iter(['9001', '9002', '9003'])
def content_states():
upload = lambda path: next(MEDIA_IDS) # noqa: E731
title, body = split_title(MARKDOWN)
return {
'split': [title, body],
'split_none': list(split_title(MARKDOWN_NO_TITLE)),
'state': markdown_to_content_state(body, upload_image=upload),
'state_no_title': markdown_to_content_state(MARKDOWN_NO_TITLE),
'state_edge': markdown_to_content_state(MARKDOWN_EDGE, upload_image=upload),
}
case('article_markdown', content_states, markdown=MARKDOWN, markdown_no_title=MARKDOWN_NO_TITLE, markdown_edge=MARKDOWN_EDGE)
STATE = {'blocks': [{'data': {}, 'text': '正文', 'key': 'abcde', 'type': 'unstyled', 'entity_ranges': [], 'inline_style_ranges': []}], 'entity_map': []}
case('article_draft', L(lambda a: XArticleAPI.create_draft(a)))
case('article_title', L(lambda a: XArticleAPI.update_title(a, ARTICLE_ID, '标题 "x" & <b>')), article=ARTICLE_ID, title='标题 "x" & <b>')
case('article_content', L(lambda a: XArticleAPI.update_content(a, ARTICLE_ID, STATE)), article=ARTICLE_ID, state=STATE)
case('article_cover', L(lambda a: XArticleAPI.update_cover(a, ARTICLE_ID, MEDIA_ID)), article=ARTICLE_ID, media=MEDIA_ID)
case('article_publish', L(lambda a: XArticleAPI.publish(a, ARTICLE_ID)), article=ARTICLE_ID)
case('article_delete', L(lambda a: XArticleAPI.delete(a, ARTICLE_ID)), article=ARTICLE_ID)
case('article_upload_image', L(lambda a: XArticleAPI.upload_image(a, str(tmp / 'photo.png'))), filename='photo.png', data=b64(PNG))
# 命令流程:Markdown → 传正文插图 → 建草稿 → 标题 → 正文 → 传封面 → 设封面 → 发布
case('post_article', L(lambda a: XArticleAPI.post_article(a, MARKDOWN, cover='photo.png', publish=True, base_dir=str(tmp))),
markdown=MARKDOWN, filename='photo.png', data=b64(PNG))
# ---------------------------------------------------------------- 私信成员资料
case('chat_members', L(lambda a: XChatAPI.get_users_by_ids(a, ['20002', '30003'])), ids=['20002', '30003'])
# 命令流程:收件箱的参与者只有 rest_id 时,再查一次成员资料
case('chat_list_members', L(lambda a: [XChatAPI.get_initial_chat_page(a), XChatAPI.get_users_by_ids(a, ['20002', '30003'])]))
shutil.rmtree(tmp, ignore_errors=True)