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

@@ -1,6 +1,6 @@
# Twitter CLI # Twitter CLI
从你的 Twitter/X 首页抓取推文智能筛选高价值内容AI 自动生成摘要 Twitter/X 命令行工具 — 读取 Timeline、管理推文
**零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。 **零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。
@@ -19,38 +19,70 @@ twitter feed
## 使用方式 ## 使用方式
### 读取
```bash ```bash
# 完整 pipeline抓取 50 条 → 筛选 top 20 → AI 总结 # 抓取首页 timeline
twitter feed twitter feed
# 自定义抓取条数 # 自定义抓取条数
twitter feed --count 50 twitter feed --max 50
# 只抓取 + 筛选,跳过 AI 总结
twitter feed --no-summary
# JSON 输出(可重定向到文件)
twitter feed --json > tweets.json
# 对已有数据做筛选 + 总结
twitter feed --input tweets.json
# 跳过筛选 # 跳过筛选
twitter feed --no-filter twitter feed --no-filter
# 指定浏览器 # JSON 输出
twitter feed --browser firefox twitter feed --json > tweets.json
# 从已有数据加载
twitter feed --input tweets.json
# 抓取收藏 # 抓取收藏
twitter bookmarks twitter favorite
twitter bookmarks --count 30 --json 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 ## Pipeline
``` ```
抓取 (GraphQL API) → 筛选 (Engagement Score) → AI 总结 抓取 (GraphQL API) → 筛选 (Engagement Score)
50 条 top 20 按主题分组 50 条 top 20
``` ```
### 筛选算法 ### 筛选算法
@@ -62,10 +94,6 @@ score = 1.0 × likes + 3.0 × retweets + 2.0 × replies
+ 5.0 × bookmarks + 0.5 × log10(views) + 5.0 × bookmarks + 0.5 × log10(views)
``` ```
### AI 总结
支持 **OpenAI-compatible**doubao / deepseek / openai**Anthropic**Claude两种 API 格式。
## 配置 ## 配置
编辑 `config.yaml` 编辑 `config.yaml`
@@ -83,18 +111,11 @@ filter:
replies: 2.0 replies: 2.0
bookmarks: 5.0 bookmarks: 5.0
views_log: 0.5 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 配置 ### Cookie 配置
**方式 1自动提取**(推荐) — 确保 Chrome 已登录 x.com程序自动通过 `browser-cookie3` 读取。 **方式 1自动提取**(推荐) — 确保浏览器已登录 x.com程序自动通过 `browser-cookie3` 按 Chrome → Edge → Firefox → Brave 顺序尝试读取。
**方式 2环境变量** — 设置: **方式 2环境变量** — 设置:
@@ -111,10 +132,9 @@ export TWITTER_CT0=your_ct0
twitter_cli/ twitter_cli/
├── __init__.py # 版本信息 ├── __init__.py # 版本信息
├── cli.py # CLI 入口 (click) ├── cli.py # CLI 入口 (click)
├── client.py # Twitter GraphQL API Client ├── client.py # Twitter GraphQL API Client (GET + POST)
├── auth.py # Cookie 提取 (env / browser-cookie3) ├── auth.py # Cookie 提取 (env / browser-cookie3)
├── filter.py # Engagement scoring + 筛选 ├── filter.py # Engagement scoring + 筛选
├── summarizer.py # AI 总结 (OpenAI + Anthropic)
├── formatter.py # Rich 终端输出 + JSON ├── formatter.py # Rich 终端输出 + JSON
├── config.py # YAML 配置加载 ├── config.py # YAML 配置加载
└── models.py # 数据模型 (dataclass) └── models.py # 数据模型 (dataclass)

View File

@@ -13,10 +13,3 @@ filter:
replies: 2.0 replies: 2.0
bookmarks: 5.0 bookmarks: 5.0
views_log: 0.5 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"

View File

@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "twitter-cli" name = "twitter-cli"
version = "0.1.0" 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" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"
requires-python = ">=3.8" requires-python = ">=3.8"

View File

@@ -8,10 +8,21 @@ Supports:
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
import ssl
import subprocess import subprocess
import sys 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]]: def load_from_env() -> Optional[Dict[str, str]]:
@@ -23,9 +34,63 @@ def load_from_env() -> Optional[Dict[str, str]]:
return None 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. """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 Runs in a subprocess to avoid SQLite database lock issues when the
browser is running. browser is running.
""" """
@@ -37,25 +102,18 @@ except ImportError:
print(json.dumps({"error": "browser-cookie3 not installed"})) print(json.dumps({"error": "browser-cookie3 not installed"}))
sys.exit(1) sys.exit(1)
browser_funcs = { browsers = [
"chrome": browser_cookie3.chrome, ("chrome", browser_cookie3.chrome),
"firefox": browser_cookie3.firefox, ("edge", browser_cookie3.edge),
"edge": browser_cookie3.edge, ("firefox", browser_cookie3.firefox),
"brave": browser_cookie3.brave, ("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: try:
jar = fn() jar = fn()
except Exception as e: except Exception:
print(json.dumps({"error": str(e)})) continue
sys.exit(1)
result = {} result = {}
for cookie in jar: for cookie in jar:
domain = cookie.domain or "" domain = cookie.domain or ""
@@ -64,13 +122,14 @@ for cookie in jar:
result["auth_token"] = cookie.value result["auth_token"] = cookie.value
elif cookie.name == "ct0": elif cookie.name == "ct0":
result["ct0"] = cookie.value result["ct0"] = cookie.value
if "auth_token" in result and "ct0" in result: if "auth_token" in result and "ct0" in result:
result["browser"] = name
print(json.dumps(result)) print(json.dumps(result))
else: sys.exit(0)
print(json.dumps({"error": "Could not find auth_token and ct0 cookies. Make sure you are logged into x.com in " + browser_name + "."}))
print(json.dumps({"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com."}))
sys.exit(1) sys.exit(1)
''' % browser '''
try: try:
result = subprocess.run( result = subprocess.run(
@@ -97,29 +156,33 @@ else:
data = json.loads(output) data = json.loads(output)
if "error" in data: if "error" in data:
return None return None
logger.info("Found cookies in %s", data.get("browser", "unknown"))
return {"auth_token": data["auth_token"], "ct0": data["ct0"]} return {"auth_token": data["auth_token"], "ct0": data["ct0"]}
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError): except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, FileNotFoundError):
return None return None
def get_cookies(browser: str = "chrome") -> Dict[str, str]: def get_cookies() -> Dict[str, str]:
"""Get Twitter cookies. Priority: env vars -> browser extraction. """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. Raises RuntimeError if no cookies found.
""" """
cookies = None # type: Optional[Dict[str, str]]
# 1. Try environment variables # 1. Try environment variables
env_cookies = load_from_env() cookies = load_from_env()
if env_cookies: if cookies:
return env_cookies logger.info("Loaded cookies from environment variables")
# 2. Try browser extraction # 2. Try browser extraction (auto-detect)
browser_cookies = extract_from_browser(browser) if not cookies:
if browser_cookies: cookies = extract_from_browser()
return browser_cookies
if not cookies:
raise RuntimeError( raise RuntimeError(
"No Twitter cookies found.\n" "No Twitter cookies found.\n"
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\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

View File

@@ -1,16 +1,18 @@
"""CLI entry point for twitter-cli. """CLI entry point for twitter-cli.
Usage: Usage:
twitter feed # full pipeline: fetch → filter → AI summarize twitter feed # fetch home timeline → filter
twitter feed --count 50 # custom fetch count twitter feed --max 50 # custom fetch count
twitter feed --no-summary # skip AI summary
twitter feed --no-filter # skip filtering twitter feed --no-filter # skip filtering
twitter feed --json # JSON output twitter feed --json # JSON output
twitter feed --browser firefox # specify browser for cookie extraction twitter favorite # fetch bookmarks
twitter bookmarks # fetch bookmarks twitter favorite --max 30
twitter bookmarks --count 30 twitter feed --input tweets.json # load existing data
twitter feed --input tweets.json # summarize existing data
twitter feed --output out.json # save filtered tweets 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 from __future__ import annotations
@@ -33,10 +35,12 @@ from .filter import filter_tweets
from .formatter import ( from .formatter import (
print_filter_stats, print_filter_stats,
print_tweet_table, print_tweet_table,
print_user_profile,
print_user_table,
tweets_to_json, tweets_to_json,
) )
from .models import Author, Metrics, Tweet, TweetMedia from .models import Author, Metrics, Tweet, TweetMedia
from .summarizer import summarize
console = Console() console = Console()
@@ -132,16 +136,14 @@ def cli(verbose):
# ===== Feed ===== # ===== Feed =====
@cli.command() @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("--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("--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("--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-filter", is_flag=True, help="Skip filtering.")
@click.option("--no-summary", is_flag=True, help="Skip AI summary.") def feed(max_count, as_json, input_file, output_file, no_filter):
def feed(count, as_json, browser, input_file, output_file, no_filter, no_summary): # type: (int, bool, str, str, bool) -> None
# type: (int, bool, str, str, str, bool, bool) -> None """Fetch home timeline with filtering."""
"""Fetch home timeline — full pipeline: fetch → filter → AI summarize."""
config = load_config() config = load_config()
# Step 1: Get tweets # 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) tweets = _load_tweets_from_json(input_file)
console.print(" Loaded %d tweets" % len(tweets)) console.print(" Loaded %d tweets" % len(tweets))
else: 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...") console.print("\n🔐 Getting Twitter cookies...")
try: try:
cookies = get_cookies(browser) cookies = get_cookies()
except RuntimeError as e: except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e) console.print("[red]❌ %s[/red]" % e)
sys.exit(1) 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) print_tweet_table(filtered, console)
console.print() console.print()
# Step 3: AI Summary
if no_summary:
return
ai_config = config.get("ai", {}) # ===== Favorite =====
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 =====
@cli.command() @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("--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("--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-filter", is_flag=True, help="Skip filtering.")
@click.option("--no-summary", is_flag=True, help="Skip AI summary.") def favorite(max_count, as_json, output_file, no_filter):
def bookmarks(count, as_json, browser, output_file, no_filter, no_summary): # type: (int, bool, str, bool) -> None
# type: (int, bool, str, str, bool, bool) -> None """Fetch bookmarked (favorite) tweets."""
"""Fetch bookmarked tweets."""
config = load_config() config = load_config()
fetch_count = count or 50 fetch_count = max_count or 50
console.print("\n🔐 Getting Twitter cookies...") console.print("\n🔐 Getting Twitter cookies...")
try: try:
cookies = get_cookies(browser) cookies = get_cookies()
except RuntimeError as e: except RuntimeError as e:
console.print("[red]❌ %s[/red]" % e) console.print("[red]❌ %s[/red]" % e)
sys.exit(1) sys.exit(1)
client = TwitterClient(cookies["auth_token"], cookies["ct0"]) 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() start = time.time()
tweets = client.fetch_bookmarks(fetch_count) tweets = client.fetch_bookmarks(fetch_count)
elapsed = time.time() - start 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 # Filter
if no_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)) click.echo(tweets_to_json(filtered))
return 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() console.print()
# AI Summary
if no_summary:
return
ai_config = config.get("ai", {}) # ===== User =====
if not ai_config.get("api_key"):
console.print(
"[yellow]⚠️ AI summary skipped: no API key configured.[/yellow]"
)
return
@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: try:
console.print("🤖 Calling AI...") profile = client.fetch_user(screen_name)
summary = summarize(filtered, ai_config) console.print()
console.print("\n" + "" * 50) print_user_profile(profile, console)
console.print("📝 AI Summary") except RuntimeError as e:
console.print("" * 50 + "\n") console.print("[red]❌ %s[/red]" % e)
console.print(summary) sys.exit(1)
except Exception as e:
console.print("[red]❌ AI summary failed: %s[/red]" % e)
@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__": if __name__ == "__main__":

View File

@@ -15,7 +15,7 @@ import ssl
import urllib.request import urllib.request
from typing import Any, Callable, Dict, List, Optional, Tuple 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__) logger = logging.getLogger(__name__)
@@ -27,8 +27,14 @@ BEARER_TOKEN = (
# Last-resort fallback query IDs # Last-resort fallback query IDs
FALLBACK_QUERY_IDS = { FALLBACK_QUERY_IDS = {
"HomeTimeline": "HJFjzBgCs16TqxewQOeLNg", "HomeTimeline": "c-CzHF1LboFilMpsx4ZCrQ",
"Bookmarks": "VFdMm9iVZxlU6hD86gfW_A", "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) # Community-maintained API definition (auto-updated daily)
@@ -39,14 +45,20 @@ TWITTER_OPENAPI_URL = (
# Default features flags required by the GraphQL endpoint # Default features flags required by the GraphQL endpoint
FEATURES = { FEATURES = {
"rweb_video_screen_enabled": False,
"profile_label_improvements_pcf_label_in_post_enabled": True,
"rweb_tipjar_consumption_enabled": True, "rweb_tipjar_consumption_enabled": True,
"responsive_web_graphql_exclude_directive_enabled": True,
"verified_phone_label_enabled": False, "verified_phone_label_enabled": False,
"creator_subscriptions_tweet_preview_api_enabled": True, "creator_subscriptions_tweet_preview_api_enabled": True,
"responsive_web_graphql_timeline_navigation_enabled": True, "responsive_web_graphql_timeline_navigation_enabled": True,
"responsive_web_graphql_skip_user_profile_image_extensions_enabled": False, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": False,
"premium_content_api_read_enabled": False,
"communities_web_enable_tweet_community_results_fetch": True, "communities_web_enable_tweet_community_results_fetch": True,
"c9s_tweet_anatomy_moderator_badge_enabled": 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, "articles_preview_enabled": True,
"responsive_web_edit_tweet_api_enabled": True, "responsive_web_edit_tweet_api_enabled": True,
"graphql_is_translatable_rweb_tweet_is_translatable_enabled": True, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": True,
@@ -54,13 +66,15 @@ FEATURES = {
"longform_notetweets_consumption_enabled": True, "longform_notetweets_consumption_enabled": True,
"responsive_web_twitter_article_tweet_consumption_enabled": True, "responsive_web_twitter_article_tweet_consumption_enabled": True,
"tweet_awards_web_tipping_enabled": False, "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, "creator_subscriptions_quote_tweet_preview_enabled": False,
"freedom_of_speech_not_reach_fetch_enabled": True, "freedom_of_speech_not_reach_fetch_enabled": True,
"standardized_nudges_misinfo": True, "standardized_nudges_misinfo": True,
"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": 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_rich_text_read_enabled": True,
"longform_notetweets_inline_media_enabled": True, "longform_notetweets_inline_media_enabled": True,
"responsive_web_grok_image_annotation_enabled": True,
"responsive_web_enhance_cards_enabled": False, "responsive_web_enhance_cards_enabled": False,
} }
@@ -152,17 +166,18 @@ def _fetch_from_github(operation_name):
def _resolve_query_id(operation_name): def _resolve_query_id(operation_name):
# type: (str) -> str # 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: if operation_name in _cached_query_ids:
return _cached_query_ids[operation_name] 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 logger.info("Auto-detecting %s queryId (no fallback available)...", operation_name)
_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]
# Tier 2: GitHub # Tier 2: GitHub
github_id = _fetch_from_github(operation_name) github_id = _fetch_from_github(operation_name)
@@ -170,12 +185,11 @@ def _resolve_query_id(operation_name):
_cached_query_ids[operation_name] = github_id _cached_query_ids[operation_name] = github_id
return github_id return github_id
# Tier 3: Hardcoded fallback # Tier 3: JS bundle scan
fallback = FALLBACK_QUERY_IDS.get(operation_name) _scan_bundles()
if fallback: if operation_name in _cached_query_ids:
logger.info("Using hardcoded fallback queryId for %s: %s", operation_name, fallback) logger.info("Found %s queryId: %s", operation_name, _cached_query_ids[operation_name])
_cached_query_ids[operation_name] = fallback return _cached_query_ids[operation_name]
return fallback
raise RuntimeError( raise RuntimeError(
'Cannot resolve queryId for "%s" — all detection methods failed' % operation_name '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") body = e.read().decode("utf-8", errors="replace")
raise RuntimeError("Twitter API error %d: %s" % (e.code, body[:500])) 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): def _parse_timeline_response(self, data, get_instructions):
# type: (Any, Callable) -> Tuple[List[Tweet], Optional[str]] # type: (Any, Callable) -> Tuple[List[Tweet], Optional[str]]
"""Parse timeline GraphQL response into tweets + next cursor.""" """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 from __future__ import annotations
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
@@ -29,13 +28,6 @@ DEFAULT_CONFIG = {
"views_log": 0.5, "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] } # type: Dict[str, Any]
@@ -161,15 +153,9 @@ def load_config(config_path=None):
# Ensure nested dicts exist # Ensure nested dicts exist
config.setdefault("fetch", DEFAULT_CONFIG["fetch"]) config.setdefault("fetch", DEFAULT_CONFIG["fetch"])
config.setdefault("filter", DEFAULT_CONFIG["filter"]) config.setdefault("filter", DEFAULT_CONFIG["filter"])
config.setdefault("ai", DEFAULT_CONFIG["ai"])
# Deep-copy filter weights if needed # Deep-copy filter weights if needed
if "filter" in config and "weights" not in config["filter"]: if "filter" in config and "weights" not in config["filter"]:
config["filter"]["weights"] = dict(DEFAULT_CONFIG["filter"]["weights"]) 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 return config

View File

@@ -10,7 +10,7 @@ from rich.panel import Panel
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from .models import Tweet from .models import Tweet, UserProfile
def format_number(n): def format_number(n):
@@ -69,6 +69,9 @@ def print_tweet_table(tweets, console=None, title=None):
qt_text = qt.text.replace("\n", " ")[:60] qt_text = qt.text.replace("\n", " ")[:60]
text += "\n┌ @%s: %s" % (qt.author.screen_name, qt_text) 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
stats = ( stats = (
"❤️ %s 🔄 %s\n💬 %s 👁️ %s" "❤️ %s 🔄 %s\n💬 %s 👁️ %s"
@@ -205,3 +208,82 @@ def tweets_to_json(tweets):
} }
result.append(d) result.append(d)
return json.dumps(result, ensure_ascii=False, indent=2) 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 retweeted_by: Optional[str] = None
quoted_tweet: Optional[Tweet] = None quoted_tweet: Optional[Tweet] = None
score: float = 0.0 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)