feat: add user commands, auto-detect browser, optimize performance

- Add user/user-posts/followers/following commands
- Add UserProfile model and GraphQL API methods
- Add print_user_profile and print_user_table formatters
- Auto-detect browser for cookies (Chrome → Edge → Firefox → Brave)
- Remove --browser option from all commands
- Remove cookie verification (v1.1 endpoints are gone)
- Use hardcoded fallback query IDs first (skip slow JS bundle scan)
- Update FEATURES from latest twitter-openapi config
- Fix user-posts: add required withVoice variable
- Add tweet URL links in feed output
- Add error handling to all user commands
This commit is contained in:
jackwener
2026-03-05 00:41:26 +08:00
parent 16752c3115
commit 7238b932ab
10 changed files with 770 additions and 353 deletions

View File

@@ -8,10 +8,21 @@ Supports:
from __future__ import annotations
import json
import logging
import os
import ssl
import subprocess
import sys
from typing import Dict, Optional
import urllib.request
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Public bearer token (same as in client.py)
_BEARER_TOKEN = (
"AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs"
"%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"
)
def load_from_env() -> Optional[Dict[str, str]]:
@@ -23,9 +34,63 @@ def load_from_env() -> Optional[Dict[str, str]]:
return None
def extract_from_browser(browser: str = "chrome") -> Optional[Dict[str, str]]:
def verify_cookies(auth_token, ct0):
# type: (str, str) -> Dict[str, Any]
"""Verify cookies by calling a Twitter API endpoint.
Tries multiple endpoints. Only raises on clear auth failures (401/403).
For other errors (404, network), returns empty dict (proceed without verification).
"""
# Endpoints to try, in order of preference
urls = [
"https://api.x.com/1.1/account/verify_credentials.json",
"https://x.com/i/api/1.1/account/settings.json",
]
headers = {
"Authorization": "Bearer %s" % _BEARER_TOKEN,
"Cookie": "auth_token=%s; ct0=%s" % (auth_token, ct0),
"X-Csrf-Token": ct0,
"X-Twitter-Active-User": "yes",
"X-Twitter-Auth-Type": "OAuth2Session",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
}
for url in urls:
req = urllib.request.Request(url)
for k, v in headers.items():
req.add_header(k, v)
ctx = ssl.create_default_context()
try:
with urllib.request.urlopen(req, context=ctx, timeout=3) as resp:
data = json.loads(resp.read().decode("utf-8"))
return {"screen_name": data.get("screen_name", "")}
except urllib.error.HTTPError as e:
if e.code in (401, 403):
raise RuntimeError(
"Cookie expired or invalid (HTTP %d). Please re-login to x.com in your browser." % e.code
)
# 404 or other — try next endpoint
logger.debug("Verification endpoint %s returned HTTP %d, trying next...", url, e.code)
continue
except Exception as e:
logger.debug("Verification endpoint %s failed: %s", url, e)
continue
# All endpoints failed with non-auth errors — proceed without verification
logger.info("Cookie verification skipped (no working endpoint), will verify on first API call")
return {}
def extract_from_browser() -> Optional[Dict[str, str]]:
"""Auto-extract cookies from local browser using browser-cookie3.
Tries browsers in order: Chrome -> Edge -> Firefox -> Brave.
Runs in a subprocess to avoid SQLite database lock issues when the
browser is running.
"""
@@ -37,40 +102,34 @@ except ImportError:
print(json.dumps({"error": "browser-cookie3 not installed"}))
sys.exit(1)
browser_funcs = {
"chrome": browser_cookie3.chrome,
"firefox": browser_cookie3.firefox,
"edge": browser_cookie3.edge,
"brave": browser_cookie3.brave,
}
browsers = [
("chrome", browser_cookie3.chrome),
("edge", browser_cookie3.edge),
("firefox", browser_cookie3.firefox),
("brave", browser_cookie3.brave),
]
browser_name = "%s"
fn = browser_funcs.get(browser_name)
if not fn:
print(json.dumps({"error": "Unsupported browser: " + browser_name}))
sys.exit(1)
for name, fn in browsers:
try:
jar = fn()
except Exception:
continue
result = {}
for cookie in jar:
domain = cookie.domain or ""
if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"):
if cookie.name == "auth_token":
result["auth_token"] = cookie.value
elif cookie.name == "ct0":
result["ct0"] = cookie.value
if "auth_token" in result and "ct0" in result:
result["browser"] = name
print(json.dumps(result))
sys.exit(0)
try:
jar = fn()
except Exception as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
result = {}
for cookie in jar:
domain = cookie.domain or ""
if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"):
if cookie.name == "auth_token":
result["auth_token"] = cookie.value
elif cookie.name == "ct0":
result["ct0"] = cookie.value
if "auth_token" in result and "ct0" in result:
print(json.dumps(result))
else:
print(json.dumps({"error": "Could not find auth_token and ct0 cookies. Make sure you are logged into x.com in " + browser_name + "."}))
sys.exit(1)
''' % browser
print(json.dumps({"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com."}))
sys.exit(1)
'''
try:
result = subprocess.run(
@@ -97,29 +156,33 @@ else:
data = json.loads(output)
if "error" in data:
return None
logger.info("Found cookies in %s", data.get("browser", "unknown"))
return {"auth_token": data["auth_token"], "ct0": data["ct0"]}
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError):
return None
def get_cookies(browser: str = "chrome") -> Dict[str, str]:
"""Get Twitter cookies. Priority: env vars -> browser extraction.
def get_cookies() -> Dict[str, str]:
"""Get Twitter cookies. Priority: env vars -> browser extraction (Chrome/Edge/Firefox/Brave).
Returns dict with 'auth_token' and 'ct0' keys.
Raises RuntimeError if no cookies found.
"""
cookies = None # type: Optional[Dict[str, str]]
# 1. Try environment variables
env_cookies = load_from_env()
if env_cookies:
return env_cookies
cookies = load_from_env()
if cookies:
logger.info("Loaded cookies from environment variables")
# 2. Try browser extraction
browser_cookies = extract_from_browser(browser)
if browser_cookies:
return browser_cookies
# 2. Try browser extraction (auto-detect)
if not cookies:
cookies = extract_from_browser()
raise RuntimeError(
"No Twitter cookies found.\n"
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n"
"Option 2: Make sure you are logged into x.com in your browser"
)
if not cookies:
raise RuntimeError(
"No Twitter cookies found.\n"
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n"
"Option 2: Make sure you are logged into x.com in your browser (Chrome/Edge/Firefox/Brave)"
)
return cookies

View File

@@ -1,16 +1,18 @@
"""CLI entry point for twitter-cli.
Usage:
twitter feed # full pipeline: fetch → filter → AI summarize
twitter feed --count 50 # custom fetch count
twitter feed --no-summary # skip AI summary
twitter feed # fetch home timeline → filter
twitter feed --max 50 # custom fetch count
twitter feed --no-filter # skip filtering
twitter feed --json # JSON output
twitter feed --browser firefox # specify browser for cookie extraction
twitter bookmarks # fetch bookmarks
twitter bookmarks --count 30
twitter feed --input tweets.json # summarize existing data
twitter favorite # fetch bookmarks
twitter favorite --max 30
twitter feed --input tweets.json # load existing data
twitter feed --output out.json # save filtered tweets
twitter post "Hello" # post a tweet
twitter reply ID "text" # reply to a tweet
twitter quote ID "text" # quote a tweet
twitter delete ID # delete a tweet
"""
from __future__ import annotations
@@ -33,10 +35,12 @@ from .filter import filter_tweets
from .formatter import (
print_filter_stats,
print_tweet_table,
print_user_profile,
print_user_table,
tweets_to_json,
)
from .models import Author, Metrics, Tweet, TweetMedia
from .summarizer import summarize
console = Console()
@@ -132,16 +136,14 @@ def cli(verbose):
# ===== Feed =====
@cli.command()
@click.option("--count", "-n", type=int, default=None, help="Number of tweets to fetch.")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--browser", "-b", default="chrome", help="Browser to extract cookies from.")
@click.option("--input", "-i", "input_file", type=str, default=None, help="Load tweets from JSON file.")
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save filtered tweets to JSON file.")
@click.option("--no-filter", is_flag=True, help="Skip filtering.")
@click.option("--no-summary", is_flag=True, help="Skip AI summary.")
def feed(count, as_json, browser, input_file, output_file, no_filter, no_summary):
# type: (int, bool, str, str, str, bool, bool) -> None
"""Fetch home timeline — full pipeline: fetch → filter → AI summarize."""
def feed(max_count, as_json, input_file, output_file, no_filter):
# type: (int, bool, str, str, bool) -> None
"""Fetch home timeline with filtering."""
config = load_config()
# Step 1: Get tweets
@@ -150,10 +152,10 @@ def feed(count, as_json, browser, input_file, output_file, no_filter, no_summary
tweets = _load_tweets_from_json(input_file)
console.print(" Loaded %d tweets" % len(tweets))
else:
fetch_count = count or config.get("fetch", {}).get("count", 50)
fetch_count = max_count or config.get("fetch", {}).get("count", 50)
console.print("\n🔐 Getting Twitter cookies...")
try:
cookies = get_cookies(browser)
cookies = get_cookies()
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
@@ -188,58 +190,33 @@ def feed(count, as_json, browser, input_file, output_file, no_filter, no_summary
print_tweet_table(filtered, console)
console.print()
# Step 3: AI Summary
if no_summary:
return
ai_config = config.get("ai", {})
if not ai_config.get("api_key"):
console.print(
"[yellow]⚠️ AI summary skipped: no API key configured.[/yellow]\n"
" Set ai.api_key in config.yaml or export AI_API_KEY=your_key"
)
return
try:
console.print("🤖 Calling AI (%s/%s)..." % (ai_config.get("provider", "openai"), ai_config.get("model", "")))
summary = summarize(filtered, ai_config)
console.print("\n" + "" * 50)
console.print("📝 AI Summary")
console.print("" * 50 + "\n")
console.print(summary)
console.print()
except Exception as e:
console.print("[red]❌ AI summary failed: %s[/red]" % e)
# ===== Bookmarks =====
# ===== Favorite =====
@cli.command()
@click.option("--count", "-n", type=int, default=None, help="Number of tweets to fetch.")
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max number of tweets to fetch.")
@click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
@click.option("--browser", "-b", default="chrome", help="Browser to extract cookies from.")
@click.option("--output", "-o", "output_file", type=str, default=None, help="Save tweets to JSON file.")
@click.option("--no-filter", is_flag=True, help="Skip filtering.")
@click.option("--no-summary", is_flag=True, help="Skip AI summary.")
def bookmarks(count, as_json, browser, output_file, no_filter, no_summary):
# type: (int, bool, str, str, bool, bool) -> None
"""Fetch bookmarked tweets."""
def favorite(max_count, as_json, output_file, no_filter):
# type: (int, bool, str, bool) -> None
"""Fetch bookmarked (favorite) tweets."""
config = load_config()
fetch_count = count or 50
fetch_count = max_count or 50
console.print("\n🔐 Getting Twitter cookies...")
try:
cookies = get_cookies(browser)
cookies = get_cookies()
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
client = TwitterClient(cookies["auth_token"], cookies["ct0"])
console.print("🔖 Fetching bookmarks (%d tweets)...\n" % fetch_count)
console.print("🔖 Fetching favorites (%d tweets)...\n" % fetch_count)
start = time.time()
tweets = client.fetch_bookmarks(fetch_count)
elapsed = time.time() - start
console.print("✅ Fetched %d bookmarks in %.1fs\n" % (len(tweets), elapsed))
console.print("✅ Fetched %d favorites in %.1fs\n" % (len(tweets), elapsed))
# Filter
if no_filter:
@@ -261,29 +238,204 @@ def bookmarks(count, as_json, browser, output_file, no_filter, no_summary):
click.echo(tweets_to_json(filtered))
return
print_tweet_table(filtered, console, title="🔖 Bookmarks — %d tweets" % len(filtered))
print_tweet_table(filtered, console, title="🔖 Favorites — %d tweets" % len(filtered))
console.print()
# AI Summary
if no_summary:
return
ai_config = config.get("ai", {})
if not ai_config.get("api_key"):
console.print(
"[yellow]⚠️ AI summary skipped: no API key configured.[/yellow]"
)
return
# ===== User =====
@cli.command()
@click.argument("screen_name")
def user(screen_name):
# type: (str,) -> None
"""View a user's profile. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@")
client = _get_client()
console.print("👤 Fetching user @%s..." % screen_name)
try:
console.print("🤖 Calling AI...")
summary = summarize(filtered, ai_config)
console.print("\n" + "" * 50)
console.print("📝 AI Summary")
console.print("" * 50 + "\n")
console.print(summary)
except Exception as e:
console.print("[red]❌ AI summary failed: %s[/red]" % e)
profile = client.fetch_user(screen_name)
console.print()
print_user_profile(profile, console)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
@cli.command("user-posts")
@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.")
def user_posts(screen_name, max_count, as_json):
# type: (str, int, bool) -> None
"""List a user's tweets. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@")
client = _get_client()
console.print("👤 Fetching @%s's profile..." % screen_name)
try:
profile = client.fetch_user(screen_name)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
console.print("📝 Fetching tweets (%d)...\n" % max_count)
start = time.time()
try:
tweets = client.fetch_user_tweets(profile.id, max_count)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
elapsed = time.time() - start
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
if as_json:
click.echo(tweets_to_json(tweets))
return
print_tweet_table(tweets, console, title="📝 @%s%d tweets" % (screen_name, len(tweets)))
console.print()
@cli.command()
@click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of users to show.")
def followers(screen_name, max_count):
# type: (str, int) -> None
"""List a user's followers. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@")
client = _get_client()
console.print("👤 Fetching @%s's profile..." % screen_name)
try:
profile = client.fetch_user(screen_name)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
console.print("👥 Fetching followers...\n")
try:
users = client.fetch_followers(profile.id, max_count)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
print_user_table(users, console, title="👥 @%s's followers — %d" % (screen_name, len(users)))
console.print()
@cli.command()
@click.argument("screen_name")
@click.option("--max", "-n", "max_count", type=int, default=20, help="Max number of users to show.")
def following(screen_name, max_count):
# type: (str, int) -> None
"""List users that someone follows. SCREEN_NAME is the @handle (without @)."""
screen_name = screen_name.lstrip("@")
client = _get_client()
console.print("👤 Fetching @%s's profile..." % screen_name)
try:
profile = client.fetch_user(screen_name)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
console.print("👥 Fetching following...\n")
try:
users = client.fetch_following(profile.id, max_count)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
print_user_table(users, console, title="👥 @%s follows — %d" % (screen_name, len(users)))
console.print()
# ===== Post / Reply / Quote / Delete =====
def _get_client():
# type: () -> TwitterClient
"""Helper to authenticate and create a TwitterClient."""
console.print("\n🔐 Getting Twitter cookies...")
try:
cookies = get_cookies()
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
return TwitterClient(cookies["auth_token"], cookies["ct0"])
@cli.command()
@click.argument("text")
def post(text):
# type: (str,) -> None
"""Post a new tweet."""
client = _get_client()
console.print("✏️ Posting tweet...")
try:
result = client.create_tweet(text)
tweet_id = result["tweet_id"]
console.print("\n[green]✅ Tweet posted![/green]")
console.print(" ID: %s" % tweet_id)
console.print(" URL: https://x.com/i/status/%s" % tweet_id)
console.print(' Text: "%s"' % result["text"][:100])
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
@cli.command()
@click.argument("tweet_id")
@click.argument("text")
def reply(tweet_id, text):
# type: (str, str) -> None
"""Reply to a tweet."""
client = _get_client()
console.print("💬 Replying to %s..." % tweet_id)
try:
result = client.create_tweet(text, reply_to=tweet_id)
new_id = result["tweet_id"]
console.print("\n[green]✅ Reply posted![/green]")
console.print(" ID: %s" % new_id)
console.print(" URL: https://x.com/i/status/%s" % new_id)
console.print(' Text: "%s"' % result["text"][:100])
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
@cli.command()
@click.argument("tweet_url")
@click.argument("text")
def quote(tweet_url, text):
# type: (str, str) -> None
"""Quote a tweet. TWEET_URL can be a full URL or tweet ID."""
# If user passes just an ID, convert to URL
if not tweet_url.startswith("http"):
tweet_url = "https://x.com/i/status/%s" % tweet_url
client = _get_client()
console.print("🔄 Quoting %s..." % tweet_url)
try:
result = client.create_tweet(text, quote_tweet_url=tweet_url)
new_id = result["tweet_id"]
console.print("\n[green]✅ Quote tweet posted![/green]")
console.print(" ID: %s" % new_id)
console.print(" URL: https://x.com/i/status/%s" % new_id)
console.print(' Text: "%s"' % result["text"][:100])
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
@cli.command()
@click.argument("tweet_id")
@click.confirmation_option(prompt="Are you sure you want to delete this tweet?")
def delete(tweet_id):
# type: (str,) -> None
"""Delete a tweet by ID."""
client = _get_client()
console.print("🗑️ Deleting tweet %s..." % tweet_id)
try:
client.delete_tweet(tweet_id)
console.print("\n[green]✅ Tweet deleted![/green]")
console.print(" ID: %s" % tweet_id)
except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e)
sys.exit(1)
if __name__ == "__main__":

View File

@@ -15,7 +15,7 @@ import ssl
import urllib.request
from typing import Any, Callable, Dict, List, Optional, Tuple
from .models import Author, Metrics, Tweet, TweetMedia
from .models import Author, Metrics, Tweet, TweetMedia, UserProfile
logger = logging.getLogger(__name__)
@@ -27,8 +27,14 @@ BEARER_TOKEN = (
# Last-resort fallback query IDs
FALLBACK_QUERY_IDS = {
"HomeTimeline": "HJFjzBgCs16TqxewQOeLNg",
"HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ",
"Bookmarks": "VFdMm9iVZxlU6hD86gfW_A",
"CreateTweet": "oB-5XsHNAbjvARJEc8CZFw",
"DeleteTweet": "VaenaVgh5q5ih7kvyVjgtg",
"UserByScreenName": "1VOOyvKkiI3FMmkeDNxM9A",
"UserTweets": "E3opETHurmVJflFsUBVuUQ",
"Followers": "IOh4aS6UdGWGJUYTqliQ7Q",
"Following": "zx6e-TLzRkeDO_a7p4b3JQ",
}
# Community-maintained API definition (auto-updated daily)
@@ -39,14 +45,20 @@ TWITTER_OPENAPI_URL = (
# Default features flags required by the GraphQL endpoint
FEATURES = {
"rweb_video_screen_enabled": False,
"profile_label_improvements_pcf_label_in_post_enabled": True,
"rweb_tipjar_consumption_enabled": True,
"responsive_web_graphql_exclude_directive_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": False,
"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,
@@ -54,13 +66,15 @@ FEATURES = {
"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": False,
"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,
"rweb_video_timestamps_enabled": True,
"longform_notetweets_rich_text_read_enabled": True,
"longform_notetweets_inline_media_enabled": True,
"responsive_web_grok_image_annotation_enabled": True,
"responsive_web_enhance_cards_enabled": False,
}
@@ -152,17 +166,18 @@ def _fetch_from_github(operation_name):
def _resolve_query_id(operation_name):
# type: (str) -> str
"""Resolve queryId using three-tier strategy: bundle scan -> GitHub -> fallback."""
"""Resolve queryId using three-tier strategy: fallback -> GitHub -> bundle scan."""
if operation_name in _cached_query_ids:
return _cached_query_ids[operation_name]
logger.info("Auto-detecting %s queryId...", operation_name)
# Tier 1: Hardcoded fallback (instant, no network)
fallback = FALLBACK_QUERY_IDS.get(operation_name)
if fallback:
logger.debug("Using fallback queryId for %s: %s", operation_name, fallback)
_cached_query_ids[operation_name] = fallback
return fallback
# Tier 1: JS bundle scan
_scan_bundles()
if operation_name in _cached_query_ids:
logger.info("Found %s queryId: %s", operation_name, _cached_query_ids[operation_name])
return _cached_query_ids[operation_name]
logger.info("Auto-detecting %s queryId (no fallback available)...", operation_name)
# Tier 2: GitHub
github_id = _fetch_from_github(operation_name)
@@ -170,12 +185,11 @@ def _resolve_query_id(operation_name):
_cached_query_ids[operation_name] = github_id
return github_id
# Tier 3: Hardcoded fallback
fallback = FALLBACK_QUERY_IDS.get(operation_name)
if fallback:
logger.info("Using hardcoded fallback queryId for %s: %s", operation_name, fallback)
_cached_query_ids[operation_name] = fallback
return fallback
# Tier 3: JS bundle scan
_scan_bundles()
if operation_name in _cached_query_ids:
logger.info("Found %s queryId: %s", operation_name, _cached_query_ids[operation_name])
return _cached_query_ids[operation_name]
raise RuntimeError(
'Cannot resolve queryId for "%s" — all detection methods failed' % operation_name
@@ -291,6 +305,260 @@ class TwitterClient:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500]))
def _api_post(self, url, payload):
# type: (str, Dict[str, Any]) -> Any
"""Make authenticated POST request to Twitter API."""
headers = self._build_headers()
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, method="POST")
for k, v in headers.items():
req.add_header(k, v)
ctx = _create_ssl_context()
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
body = resp.read().decode("utf-8")
return json.loads(body)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500]))
def create_tweet(self, text, reply_to=None, quote_tweet_url=None):
# type: (str, Optional[str], Optional[str]) -> Dict[str, Any]
"""Create a tweet, reply, or quote tweet.
Args:
text: Tweet text content.
reply_to: Tweet ID to reply to (optional).
quote_tweet_url: URL of tweet to quote (optional).
Returns:
Dict with tweet_id and text of the created tweet.
"""
query_id = _resolve_query_id("CreateTweet")
url = "https://x.com/i/api/graphql/%s/CreateTweet" % query_id
variables = {
"tweet_text": text,
"dark_request": False,
"media": {"media_entities": [], "possibly_sensitive": False},
"semantic_annotation_ids": [],
} # type: Dict[str, Any]
if reply_to:
variables["reply"] = {
"in_reply_to_tweet_id": reply_to,
"exclude_reply_user_ids": [],
}
if quote_tweet_url:
variables["attachment_url"] = quote_tweet_url
features = {
"communities_web_enable_tweet_community_results_fetch": True,
"c9s_tweet_anatomy_moderator_badge_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,
"creator_subscriptions_quote_tweet_preview_enabled": False,
"longform_notetweets_rich_text_read_enabled": True,
"longform_notetweets_inline_media_enabled": True,
"articles_preview_enabled": True,
"rweb_video_timestamps_enabled": True,
"rweb_tipjar_consumption_enabled": True,
"responsive_web_graphql_exclude_directive_enabled": True,
"verified_phone_label_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,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
"responsive_web_graphql_timeline_navigation_enabled": True,
"responsive_web_enhance_cards_enabled": False,
}
payload = {
"variables": variables,
"features": features,
"queryId": query_id,
}
data = self._api_post(url, payload)
# Parse response
result = _deep_get(data, "data", "create_tweet", "tweet_results", "result")
if not result:
errors = data.get("errors", [])
if errors:
raise RuntimeError("CreateTweet failed: %s" % errors[0].get("message", str(errors)))
raise RuntimeError("CreateTweet failed: unexpected response")
tweet_id = result.get("rest_id", "")
tweet_text = _deep_get(result, "legacy", "full_text") or text
return {"tweet_id": tweet_id, "text": tweet_text}
def delete_tweet(self, tweet_id):
# type: (str) -> bool
"""Delete a tweet by ID.
Returns:
True if deletion was successful.
"""
query_id = _resolve_query_id("DeleteTweet")
url = "https://x.com/i/api/graphql/%s/DeleteTweet" % query_id
payload = {
"variables": {"tweet_id": tweet_id, "dark_request": False},
"queryId": query_id,
}
data = self._api_post(url, payload)
# Check response
result = _deep_get(data, "data", "delete_tweet", "tweet_results")
if result is not None:
return True
errors = data.get("errors", [])
if errors:
raise RuntimeError("DeleteTweet failed: %s" % errors[0].get("message", str(errors)))
# Some successful deletions return empty result
return True
def fetch_user(self, screen_name):
# type: (str) -> UserProfile
"""Fetch user profile by screen name."""
query_id = _resolve_query_id("UserByScreenName")
variables = {
"screen_name": screen_name,
"withSafetyModeUserFields": True,
}
features = {
"hidden_profile_subscriptions_enabled": True,
"rweb_tipjar_consumption_enabled": True,
"responsive_web_graphql_exclude_directive_enabled": True,
"verified_phone_label_enabled": False,
"subscriptions_verification_info_is_identity_verified_enabled": True,
"subscriptions_verification_info_verified_since_enabled": True,
"highlights_tweets_tab_ui_enabled": True,
"responsive_web_twitter_article_notes_tab_enabled": True,
"subscriptions_feature_can_gift_premium": True,
"creator_subscriptions_tweet_preview_api_enabled": True,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
"responsive_web_graphql_timeline_navigation_enabled": True,
}
url = "https://x.com/i/api/graphql/%s/UserByScreenName?" % query_id
url += "variables=%s&features=%s" % (
urllib.request.quote(json.dumps(variables)),
urllib.request.quote(json.dumps(features)),
)
data = self._api_get(url)
result = _deep_get(data, "data", "user", "result")
if not result:
raise RuntimeError("User @%s not found" % screen_name)
legacy = result.get("legacy", {})
core = result.get("core", {})
return UserProfile(
id=result.get("rest_id", ""),
name=core.get("name") or legacy.get("name", ""),
screen_name=core.get("screen_name") or legacy.get("screen_name", screen_name),
bio=legacy.get("description", ""),
location=legacy.get("location", ""),
url=(
legacy.get("entities", {}).get("url", {}).get("urls", [{}])[0].get("expanded_url", "")
if legacy.get("entities", {}).get("url")
else ""
),
followers_count=legacy.get("followers_count", 0),
following_count=legacy.get("friends_count", 0),
tweets_count=legacy.get("statuses_count", 0),
likes_count=legacy.get("favourites_count", 0),
verified=bool(result.get("is_blue_verified") or legacy.get("verified", False)),
profile_image_url=legacy.get("profile_image_url_https", ""),
created_at=legacy.get("created_at", ""),
)
def fetch_user_tweets(self, user_id, count=20):
# type: (str, int) -> List[Tweet]
"""Fetch tweets posted by a user."""
query_id = _resolve_query_id("UserTweets")
return self._fetch_timeline(
query_id,
"UserTweets",
count,
lambda data: _deep_get(data, "data", "user", "result", "timeline_v2", "timeline", "instructions"),
extra_variables={
"userId": user_id,
"withQuickPromoteEligibilityTweetFields": True,
"withVoice": True,
"withV2Timeline": True,
},
)
def fetch_followers(self, user_id, count=20):
# type: (str, int) -> List[UserProfile]
"""Fetch user's followers."""
query_id = _resolve_query_id("Followers")
return self._fetch_user_list(query_id, "Followers", user_id, count)
def fetch_following(self, user_id, count=20):
# type: (str, int) -> List[UserProfile]
"""Fetch users that this user follows."""
query_id = _resolve_query_id("Following")
return self._fetch_user_list(query_id, "Following", user_id, count)
def _fetch_user_list(self, query_id, operation_name, user_id, count):
# type: (str, str, str, int) -> List[UserProfile]
"""Generic user list fetcher (followers/following)."""
variables = {
"userId": user_id,
"count": min(count, 50),
"includePromotedContent": False,
} # type: Dict[str, Any]
url = "https://x.com/i/api/graphql/%s/%s?" % (query_id, operation_name)
url += "variables=%s&features=%s" % (
urllib.request.quote(json.dumps(variables)),
urllib.request.quote(json.dumps(FEATURES)),
)
data = self._api_get(url)
users = [] # type: List[UserProfile]
instructions = _deep_get(data, "data", "user", "result", "timeline", "timeline", "instructions")
if not isinstance(instructions, list):
return users
for instruction in instructions:
entries = instruction.get("entries", [])
for entry in entries:
content = entry.get("content", {})
item_content = content.get("itemContent", {})
user_results = item_content.get("user_results", {}).get("result")
if not user_results:
continue
legacy = user_results.get("legacy", {})
core = user_results.get("core", {})
if not legacy:
continue
users.append(UserProfile(
id=user_results.get("rest_id", ""),
name=core.get("name") or legacy.get("name", ""),
screen_name=core.get("screen_name") or legacy.get("screen_name", ""),
bio=legacy.get("description", ""),
followers_count=legacy.get("followers_count", 0),
following_count=legacy.get("friends_count", 0),
verified=bool(user_results.get("is_blue_verified") or legacy.get("verified", False)),
profile_image_url=legacy.get("profile_image_url_https", ""),
))
return users[:count]
def _parse_timeline_response(self, data, get_instructions):
# type: (Any, Callable) -> Tuple[List[Tweet], Optional[str]]
"""Parse timeline GraphQL response into tweets + next cursor."""

View File

@@ -5,7 +5,6 @@ Uses a simple built-in YAML parser to avoid adding PyYAML as a dependency.
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Union
@@ -29,13 +28,6 @@ DEFAULT_CONFIG = {
"views_log": 0.5,
},
},
"ai": {
"provider": "openai",
"api_key": "",
"model": "doubao-seed-2.0-code",
"base_url": "https://ark.cn-beijing.volces.com/api/coding",
"language": "zh-CN",
},
} # type: Dict[str, Any]
@@ -161,15 +153,9 @@ def load_config(config_path=None):
# Ensure nested dicts exist
config.setdefault("fetch", DEFAULT_CONFIG["fetch"])
config.setdefault("filter", DEFAULT_CONFIG["filter"])
config.setdefault("ai", DEFAULT_CONFIG["ai"])
# Deep-copy filter weights if needed
if "filter" in config and "weights" not in config["filter"]:
config["filter"]["weights"] = dict(DEFAULT_CONFIG["filter"]["weights"])
# AI API key fallback to env var
ai = config.get("ai", {})
if not ai.get("api_key"):
ai["api_key"] = os.environ.get("AI_API_KEY", "")
return config

View File

@@ -10,7 +10,7 @@ from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from .models import Tweet
from .models import Tweet, UserProfile
def format_number(n):
@@ -69,6 +69,9 @@ def print_tweet_table(tweets, console=None, title=None):
qt_text = qt.text.replace("\n", " ")[:60]
text += "\n┌ @%s: %s" % (qt.author.screen_name, qt_text)
# Tweet link
text += "\n🔗 x.com/%s/status/%s" % (tweet.author.screen_name, tweet.id)
# Stats
stats = (
"❤️ %s 🔄 %s\n💬 %s 👁️ %s"
@@ -205,3 +208,82 @@ def tweets_to_json(tweets):
}
result.append(d)
return json.dumps(result, ensure_ascii=False, indent=2)
def print_user_profile(user, console=None):
# type: (UserProfile, Optional[Console]) -> None
"""Print user profile as a rich panel."""
if console is None:
console = Console()
verified = "" if user.verified else ""
header = "@%s%s (%s)" % (user.screen_name, verified, user.name)
lines = []
if user.bio:
lines.append(user.bio)
lines.append("")
if user.location:
lines.append("📍 %s" % user.location)
if user.url:
lines.append("🔗 %s" % user.url)
if user.location or user.url:
lines.append("")
lines.append(
"👥 %s followers · %s following · %s tweets · %s likes"
% (
format_number(user.followers_count),
format_number(user.following_count),
format_number(user.tweets_count),
format_number(user.likes_count),
)
)
if user.created_at:
lines.append("📅 Joined %s" % user.created_at)
lines.append("🔗 x.com/%s" % user.screen_name)
console.print(Panel(
"\n".join(lines),
title=header,
border_style="cyan",
expand=True,
))
def print_user_table(users, console=None, title=None):
# type: (List[UserProfile], Optional[Console], Optional[str]) -> None
"""Print a list of users as a rich table."""
if console is None:
console = Console()
if not title:
title = "👥 Users — %d" % len(users)
table = Table(title=title, show_lines=True, expand=True)
table.add_column("#", style="dim", width=3, justify="right")
table.add_column("User", style="cyan", width=20, no_wrap=True)
table.add_column("Bio", ratio=3)
table.add_column("Stats", style="green", width=22, no_wrap=True)
for i, user in enumerate(users):
verified = "" if user.verified else ""
user_text = "@%s%s\n%s" % (user.screen_name, verified, user.name)
bio = (user.bio or "").replace("\n", " ").strip()
if len(bio) > 100:
bio = bio[:97] + "..."
stats = (
"👥 %s followers\n📝 %s following"
% (
format_number(user.followers_count),
format_number(user.following_count),
)
)
table.add_row(str(i + 1), user_text, bio, stats)
console.print(table)

View File

@@ -50,3 +50,20 @@ class Tweet:
retweeted_by: Optional[str] = None
quoted_tweet: Optional[Tweet] = None
score: float = 0.0
@dataclass
class UserProfile:
id: str
name: str
screen_name: str
bio: str = ""
location: str = ""
url: str = ""
followers_count: int = 0
following_count: int = 0
tweets_count: int = 0
likes_count: int = 0
verified: bool = False
profile_image_url: str = ""
created_at: str = ""

View File

@@ -1,164 +0,0 @@
"""AI summarization module.
Supports OpenAI-compatible (doubao, deepseek, openai) and Anthropic APIs.
Uses urllib.request for zero extra dependencies.
"""
from __future__ import annotations
import json
import logging
import ssl
import urllib.request
from typing import Any, Dict, List
from .models import Tweet
logger = logging.getLogger(__name__)
SYSTEM_MESSAGE = "你是一个专业的 Twitter/X 信息流分析师,擅长提炼关键信息和发现趋势。"
def _build_prompt(tweets, language="zh-CN"):
# type: (List[Tweet], str) -> str
"""Build the summarization prompt."""
lines = []
for i, t in enumerate(tweets):
score_str = " [score: %.1f]" % t.score if t.score else ""
rt = " (RT by @%s)" % t.retweeted_by if t.is_retweet and t.retweeted_by else ""
media_str = ""
if t.media:
media_str = " [%s]" % ", ".join(m.type for m in t.media)
url_str = ""
if t.urls:
url_str = "\n Links: %s" % ", ".join(t.urls)
quoted = ""
if t.quoted_tweet:
qt = t.quoted_tweet
quoted = "\n Quoting @%s: %s..." % (qt.author.screen_name, qt.text[:100].replace("\n", " "))
text_preview = t.text.replace("\n", " ")[:300]
lines.append(
'%d. @%s (%s)%s%s\n'
' "%s"\n'
' ❤️%d 🔄%d 💬%d 🔖%d 👁️%d%s%s%s'
% (
i + 1, t.author.screen_name, t.author.name, rt, score_str,
text_preview,
t.metrics.likes, t.metrics.retweets, t.metrics.replies,
t.metrics.bookmarks, t.metrics.views,
media_str, url_str, quoted,
)
)
tweet_summaries = "\n\n".join(lines)
if language.startswith("zh"):
lang_inst = "请用中文输出。"
else:
lang_inst = "Please output in %s." % language
return (
"你是一个 Twitter/X 信息流分析师。请对以下 %d 条推文进行摘要总结。\n\n"
"要求:\n"
"1. 按主题分组AI & 编程、Crypto、工具推荐、生活观点等\n"
"2. 每组列出关键推文和核心观点,标注作者 @handle\n"
"3. 标注数据亮点(高赞/高收藏推文用 🔥 标记)\n"
"4. 最后用 2-3 句话总结今天 timeline 的整体趋势\n"
"5. %s\n\n"
"推文数据:\n\n%s"
) % (len(tweets), lang_inst, tweet_summaries)
def _call_openai(prompt, config):
# type: (str, Dict[str, Any]) -> str
"""Call OpenAI-compatible API."""
url = config.get("base_url", "").rstrip("/")
if not url.endswith("/chat/completions"):
if not url.endswith("/v1"):
url += "/v1"
url += "/chat/completions"
payload = json.dumps({
"model": config.get("model", ""),
"messages": [
{"role": "system", "content": SYSTEM_MESSAGE},
{"role": "user", "content": prompt},
],
"temperature": 0.3,
"max_tokens": 4096,
}).encode("utf-8")
req = urllib.request.Request(url, data=payload)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer %s" % config.get("api_key", ""))
ctx = ssl.create_default_context()
with urllib.request.urlopen(req, context=ctx, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8"))
choices = data.get("choices", [])
if choices:
return choices[0].get("message", {}).get("content", "")
return ""
def _call_anthropic(prompt, config):
# type: (str, Dict[str, Any]) -> str
"""Call Anthropic Messages API."""
url = config.get("base_url", "").rstrip("/")
if not url.endswith("/messages"):
if not url.endswith("/v1"):
url += "/v1"
url += "/messages"
payload = json.dumps({
"model": config.get("model", ""),
"system": SYSTEM_MESSAGE,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 4096,
}).encode("utf-8")
req = urllib.request.Request(url, data=payload)
req.add_header("Content-Type", "application/json")
req.add_header("x-api-key", config.get("api_key", ""))
req.add_header("anthropic-version", "2023-06-01")
ctx = ssl.create_default_context()
with urllib.request.urlopen(req, context=ctx, timeout=120) as resp:
data = json.loads(resp.read().decode("utf-8"))
content_blocks = data.get("content", [])
for block in content_blocks:
if block.get("type") == "text":
return block.get("text", "")
return ""
def summarize(tweets, config):
# type: (List[Tweet], Dict[str, Any]) -> str
"""Summarize tweets using the configured AI provider.
Config keys: provider, api_key, model, base_url, language
"""
api_key = config.get("api_key", "")
if not api_key:
raise RuntimeError(
"AI API key not configured.\n"
"Set ai.api_key in config.yaml or export AI_API_KEY=your_key"
)
if not tweets:
return "No tweets to summarize."
language = config.get("language", "zh-CN")
prompt = _build_prompt(tweets, language)
provider = config.get("provider", "openai")
logger.info("Calling AI (%s/%s)...", provider, config.get("model", ""))
if provider == "anthropic":
return _call_anthropic(prompt, config)
else:
return _call_openai(prompt, config)