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:
84
README.md
84
README.md
@@ -1,6 +1,6 @@
|
||||
# Twitter CLI
|
||||
|
||||
从你的 Twitter/X 首页抓取推文,智能筛选高价值内容,AI 自动生成摘要。
|
||||
Twitter/X 命令行工具 — 读取 Timeline、管理推文。
|
||||
|
||||
**零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。
|
||||
|
||||
@@ -19,38 +19,70 @@ twitter feed
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 读取
|
||||
|
||||
```bash
|
||||
# 完整 pipeline:抓取 50 条 → 筛选 top 20 → AI 总结
|
||||
# 抓取首页 timeline
|
||||
twitter feed
|
||||
|
||||
# 自定义抓取条数
|
||||
twitter feed --count 50
|
||||
|
||||
# 只抓取 + 筛选,跳过 AI 总结
|
||||
twitter feed --no-summary
|
||||
|
||||
# JSON 输出(可重定向到文件)
|
||||
twitter feed --json > tweets.json
|
||||
|
||||
# 对已有数据做筛选 + 总结
|
||||
twitter feed --input tweets.json
|
||||
twitter feed --max 50
|
||||
|
||||
# 跳过筛选
|
||||
twitter feed --no-filter
|
||||
|
||||
# 指定浏览器
|
||||
twitter feed --browser firefox
|
||||
# JSON 输出
|
||||
twitter feed --json > tweets.json
|
||||
|
||||
# 从已有数据加载
|
||||
twitter feed --input tweets.json
|
||||
|
||||
|
||||
# 抓取收藏
|
||||
twitter bookmarks
|
||||
twitter bookmarks --count 30 --json
|
||||
twitter favorite
|
||||
twitter favorite --max 30 --json
|
||||
```
|
||||
|
||||
### 用户
|
||||
|
||||
```bash
|
||||
# 查看用户资料
|
||||
twitter user elonmusk
|
||||
|
||||
# 列出用户推文
|
||||
twitter user-posts elonmusk --max 20
|
||||
|
||||
# 查看粉丝
|
||||
twitter followers elonmusk --max 30
|
||||
|
||||
# 查看关注
|
||||
twitter following elonmusk --max 30
|
||||
```
|
||||
|
||||
### 发推
|
||||
|
||||
```bash
|
||||
# 发新推文
|
||||
twitter post "Hello World"
|
||||
|
||||
# 回复推文
|
||||
twitter reply <tweet_id> "这是回复内容"
|
||||
|
||||
# 引用转推(传 URL 或 ID 均可)
|
||||
twitter quote <tweet_url_or_id> "这是引用内容"
|
||||
|
||||
# 删除推文(会有确认提示)
|
||||
twitter delete <tweet_id>
|
||||
|
||||
# 跳过删除确认
|
||||
twitter delete <tweet_id> --yes
|
||||
```
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
抓取 (GraphQL API) → 筛选 (Engagement Score) → AI 总结
|
||||
50 条 top 20 按主题分组
|
||||
抓取 (GraphQL API) → 筛选 (Engagement Score)
|
||||
50 条 top 20
|
||||
```
|
||||
|
||||
### 筛选算法
|
||||
@@ -62,10 +94,6 @@ score = 1.0 × likes + 3.0 × retweets + 2.0 × replies
|
||||
+ 5.0 × bookmarks + 0.5 × log10(views)
|
||||
```
|
||||
|
||||
### AI 总结
|
||||
|
||||
支持 **OpenAI-compatible**(doubao / deepseek / openai)和 **Anthropic**(Claude)两种 API 格式。
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config.yaml`:
|
||||
@@ -83,18 +111,11 @@ filter:
|
||||
replies: 2.0
|
||||
bookmarks: 5.0
|
||||
views_log: 0.5
|
||||
|
||||
ai:
|
||||
provider: "openai" # "openai" or "anthropic"
|
||||
api_key: "" # 或设置环境变量 AI_API_KEY
|
||||
model: "doubao-seed-2.0-code"
|
||||
base_url: "https://ark.cn-beijing.volces.com/api/coding"
|
||||
language: "zh-CN"
|
||||
```
|
||||
|
||||
### Cookie 配置
|
||||
|
||||
**方式 1:自动提取**(推荐) — 确保 Chrome 已登录 x.com,程序自动通过 `browser-cookie3` 读取。
|
||||
**方式 1:自动提取**(推荐) — 确保浏览器已登录 x.com,程序自动通过 `browser-cookie3` 按 Chrome → Edge → Firefox → Brave 顺序尝试读取。
|
||||
|
||||
**方式 2:环境变量** — 设置:
|
||||
|
||||
@@ -111,10 +132,9 @@ export TWITTER_CT0=your_ct0
|
||||
twitter_cli/
|
||||
├── __init__.py # 版本信息
|
||||
├── cli.py # CLI 入口 (click)
|
||||
├── client.py # Twitter GraphQL API Client
|
||||
├── client.py # Twitter GraphQL API Client (GET + POST)
|
||||
├── auth.py # Cookie 提取 (env / browser-cookie3)
|
||||
├── filter.py # Engagement scoring + 筛选
|
||||
├── summarizer.py # AI 总结 (OpenAI + Anthropic)
|
||||
├── formatter.py # Rich 终端输出 + JSON
|
||||
├── config.py # YAML 配置加载
|
||||
└── models.py # 数据模型 (dataclass)
|
||||
|
||||
@@ -13,10 +13,3 @@ filter:
|
||||
replies: 2.0
|
||||
bookmarks: 5.0
|
||||
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"
|
||||
|
||||
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
|
||||
[project]
|
||||
name = "twitter-cli"
|
||||
version = "0.1.0"
|
||||
description = "A CLI for Twitter/X — feed, bookmarks, filtering, AI summary"
|
||||
description = "A CLI for Twitter/X — feed, bookmarks, tweet, filtering"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.8"
|
||||
|
||||
@@ -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,25 +102,18 @@ 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,
|
||||
}
|
||||
|
||||
browser_name = "%s"
|
||||
fn = browser_funcs.get(browser_name)
|
||||
if not fn:
|
||||
print(json.dumps({"error": "Unsupported browser: " + browser_name}))
|
||||
sys.exit(1)
|
||||
browsers = [
|
||||
("chrome", browser_cookie3.chrome),
|
||||
("edge", browser_cookie3.edge),
|
||||
("firefox", browser_cookie3.firefox),
|
||||
("brave", browser_cookie3.brave),
|
||||
]
|
||||
|
||||
for name, fn in browsers:
|
||||
try:
|
||||
jar = fn()
|
||||
except Exception as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
result = {}
|
||||
for cookie in jar:
|
||||
domain = cookie.domain or ""
|
||||
@@ -64,13 +122,14 @@ for cookie in jar:
|
||||
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))
|
||||
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(0)
|
||||
|
||||
print(json.dumps({"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com."}))
|
||||
sys.exit(1)
|
||||
''' % browser
|
||||
'''
|
||||
|
||||
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()
|
||||
|
||||
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"
|
||||
"Option 2: Make sure you are logged into x.com in your browser (Chrome/Edge/Firefox/Brave)"
|
||||
)
|
||||
|
||||
return cookies
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user