feat: add search and likes commands
- Add 'twitter search' command with --type (Top/Latest/Photos/Videos), --max, --json, --filter - Add 'twitter likes' command to view tweets liked by a user - Add SearchTimeline and Likes GraphQL operations with fallback queryIds - Update README with new command examples (EN + CN)
This commit is contained in:
17
README.md
17
README.md
@@ -19,7 +19,8 @@ A terminal-first CLI for Twitter/X: read timelines, bookmarks, and user profiles
|
|||||||
|
|
||||||
- Timeline: fetch `for-you` and `following` feeds
|
- Timeline: fetch `for-you` and `following` feeds
|
||||||
- Bookmarks: list saved tweets from your account
|
- Bookmarks: list saved tweets from your account
|
||||||
- User lookup: fetch user profile and recent tweets
|
- Search: find tweets by keyword with Top/Latest/Photos/Videos tabs
|
||||||
|
- User lookup: fetch user profile, tweets, and likes
|
||||||
- JSON output: export feed/bookmarks/user tweets for scripting
|
- JSON output: export feed/bookmarks/user tweets for scripting
|
||||||
- Optional scoring filter: rank tweets by engagement weights
|
- Optional scoring filter: rank tweets by engagement weights
|
||||||
- Cookie auth: use browser cookies or environment variables
|
- Cookie auth: use browser cookies or environment variables
|
||||||
@@ -67,9 +68,15 @@ twitter feed --input tweets.json
|
|||||||
twitter favorite
|
twitter favorite
|
||||||
twitter favorite --max 30 --json
|
twitter favorite --max 30 --json
|
||||||
|
|
||||||
|
# Search
|
||||||
|
twitter search "Claude Code"
|
||||||
|
twitter search "AI agent" -t Latest --max 50
|
||||||
|
twitter search "机器学习" --json
|
||||||
|
|
||||||
# User
|
# User
|
||||||
twitter user elonmusk
|
twitter user elonmusk
|
||||||
twitter user-posts elonmusk --max 20
|
twitter user-posts elonmusk --max 20
|
||||||
|
twitter likes elonmusk --max 30
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
@@ -198,7 +205,8 @@ After installation, OpenClaw can call `twitter-cli` commands directly.
|
|||||||
|
|
||||||
- 时间线读取:支持 `for-you` 和 `following`
|
- 时间线读取:支持 `for-you` 和 `following`
|
||||||
- 收藏读取:查看账号书签推文
|
- 收藏读取:查看账号书签推文
|
||||||
- 用户查询:查看用户资料和用户推文
|
- 搜索:按关键词搜索推文,支持 Top/Latest/Photos/Videos
|
||||||
|
- 用户查询:查看用户资料、推文和点赞
|
||||||
- JSON 输出:便于脚本处理
|
- JSON 输出:便于脚本处理
|
||||||
- 可选筛选:按 engagement score 排序
|
- 可选筛选:按 engagement score 排序
|
||||||
- Cookie 认证:支持环境变量和浏览器自动提取
|
- Cookie 认证:支持环境变量和浏览器自动提取
|
||||||
@@ -228,9 +236,14 @@ twitter feed --filter
|
|||||||
# 收藏
|
# 收藏
|
||||||
twitter favorite
|
twitter favorite
|
||||||
|
|
||||||
|
# 搜索
|
||||||
|
twitter search "Claude Code"
|
||||||
|
twitter search "AI agent" -t Latest --max 50
|
||||||
|
|
||||||
# 用户
|
# 用户
|
||||||
twitter user elonmusk
|
twitter user elonmusk
|
||||||
twitter user-posts elonmusk --max 20
|
twitter user-posts elonmusk --max 20
|
||||||
|
twitter likes elonmusk --max 30
|
||||||
```
|
```
|
||||||
|
|
||||||
### 认证说明
|
### 认证说明
|
||||||
|
|||||||
@@ -244,5 +244,81 @@ def user_posts(screen_name, max_count, as_json):
|
|||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
SEARCH_PRODUCTS = ["Top", "Latest", "Photos", "Videos"]
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("query")
|
||||||
|
@click.option(
|
||||||
|
"--type",
|
||||||
|
"-t",
|
||||||
|
"product",
|
||||||
|
type=click.Choice(SEARCH_PRODUCTS, case_sensitive=False),
|
||||||
|
default="Top",
|
||||||
|
help="Search tab: Top, Latest, Photos, or Videos.",
|
||||||
|
)
|
||||||
|
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.")
|
||||||
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||||
|
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
|
||||||
|
def search(query, product, max_count, as_json, do_filter):
|
||||||
|
# type: (str, str, int, bool, bool) -> None
|
||||||
|
"""Search tweets by QUERY string."""
|
||||||
|
config = load_config()
|
||||||
|
try:
|
||||||
|
fetch_count = _resolve_fetch_count(max_count, 20)
|
||||||
|
client = _get_client(config)
|
||||||
|
console.print("🔍 Searching '%s' (%s, %d tweets)...\n" % (query, product, fetch_count))
|
||||||
|
start = time.time()
|
||||||
|
tweets = client.fetch_search(query, fetch_count, product)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
console.print("✅ Found %d tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
console.print("[red]❌ %s[/red]" % exc)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filtered = _apply_filter(tweets, do_filter, config)
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(tweets_to_json(filtered))
|
||||||
|
return
|
||||||
|
|
||||||
|
print_tweet_table(filtered, console, title="🔍 '%s' — %d tweets" % (query, len(filtered)))
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.argument("screen_name")
|
||||||
|
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of tweets to fetch.")
|
||||||
|
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
|
||||||
|
@click.option("--filter", "do_filter", is_flag=True, help="Enable score-based filtering.")
|
||||||
|
def likes(screen_name, max_count, as_json, do_filter):
|
||||||
|
# type: (str, int, bool, bool) -> None
|
||||||
|
"""Show tweets liked by a user. SCREEN_NAME is the @handle (without @)."""
|
||||||
|
screen_name = screen_name.lstrip("@")
|
||||||
|
config = load_config()
|
||||||
|
try:
|
||||||
|
fetch_count = _resolve_fetch_count(max_count, 20)
|
||||||
|
client = _get_client(config)
|
||||||
|
console.print("👤 Fetching @%s's profile..." % screen_name)
|
||||||
|
profile = client.fetch_user(screen_name)
|
||||||
|
console.print("❤️ Fetching likes (%d)...\n" % fetch_count)
|
||||||
|
start = time.time()
|
||||||
|
tweets = client.fetch_user_likes(profile.id, fetch_count)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
console.print("✅ Fetched %d liked tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
console.print("[red]❌ %s[/red]" % exc)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filtered = _apply_filter(tweets, do_filter, config)
|
||||||
|
|
||||||
|
if as_json:
|
||||||
|
click.echo(tweets_to_json(filtered))
|
||||||
|
return
|
||||||
|
|
||||||
|
print_tweet_table(filtered, console, title="❤️ @%s likes — %d tweets" % (screen_name, len(filtered)))
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cli()
|
cli()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ FALLBACK_QUERY_IDS = {
|
|||||||
"Bookmarks": "VFdMm9iVZxlU6hD86gfW_A",
|
"Bookmarks": "VFdMm9iVZxlU6hD86gfW_A",
|
||||||
"UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A",
|
"UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A",
|
||||||
"UserTweets": "E3opETHurmVJflFsUBVuUQ",
|
"UserTweets": "E3opETHurmVJflFsUBVuUQ",
|
||||||
|
"SearchTimeline": "VhUd6vHVmLBcw0uX-6jMLA",
|
||||||
|
"Likes": "lIDpu_NWL7_VhimGGt0o6A",
|
||||||
}
|
}
|
||||||
|
|
||||||
TWITTER_OPENAPI_URL = (
|
TWITTER_OPENAPI_URL = (
|
||||||
@@ -312,6 +314,44 @@ class TwitterClient:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def fetch_user_likes(self, user_id, count=20):
|
||||||
|
# type: (str, int) -> List[Tweet]
|
||||||
|
"""Fetch tweets liked by a user."""
|
||||||
|
return self._fetch_timeline(
|
||||||
|
"Likes",
|
||||||
|
count,
|
||||||
|
lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"),
|
||||||
|
extra_variables={
|
||||||
|
"userId": user_id,
|
||||||
|
"includePromotedContent": False,
|
||||||
|
"withClientEventToken": False,
|
||||||
|
"withBirdwatchNotes": False,
|
||||||
|
"withVoice": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_search(self, query, count=20, product="Top"):
|
||||||
|
# type: (str, int, str) -> List[Tweet]
|
||||||
|
"""Search tweets by query.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string.
|
||||||
|
count: Max number of tweets to return.
|
||||||
|
product: Search tab — "Top", "Latest", "People", "Photos", "Videos".
|
||||||
|
"""
|
||||||
|
return self._fetch_timeline(
|
||||||
|
"SearchTimeline",
|
||||||
|
count,
|
||||||
|
lambda data: _deep_get(
|
||||||
|
data, "data", "search_by_raw_query", "search_timeline", "timeline", "instructions",
|
||||||
|
),
|
||||||
|
extra_variables={
|
||||||
|
"rawQuery": query,
|
||||||
|
"querySource": "typed_query",
|
||||||
|
"product": product,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None):
|
def _fetch_timeline(self, operation_name, count, get_instructions, extra_variables=None):
|
||||||
# type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]]) -> List[Tweet]
|
# type: (str, int, Callable[[Any], Any], Optional[Dict[str, Any]]) -> List[Tweet]
|
||||||
"""Generic timeline fetcher with pagination and deduplication."""
|
"""Generic timeline fetcher with pagination and deduplication."""
|
||||||
|
|||||||
Reference in New Issue
Block a user