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 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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,40 +102,34 @@ 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"
|
for name, fn in browsers:
|
||||||
fn = browser_funcs.get(browser_name)
|
try:
|
||||||
if not fn:
|
jar = fn()
|
||||||
print(json.dumps({"error": "Unsupported browser: " + browser_name}))
|
except Exception:
|
||||||
sys.exit(1)
|
continue
|
||||||
|
result = {}
|
||||||
|
for cookie in jar:
|
||||||
|
domain = cookie.domain or ""
|
||||||
|
if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"):
|
||||||
|
if cookie.name == "auth_token":
|
||||||
|
result["auth_token"] = cookie.value
|
||||||
|
elif cookie.name == "ct0":
|
||||||
|
result["ct0"] = cookie.value
|
||||||
|
if "auth_token" in result and "ct0" in result:
|
||||||
|
result["browser"] = name
|
||||||
|
print(json.dumps(result))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
try:
|
print(json.dumps({"error": "No Twitter cookies found in any browser. Make sure you are logged into x.com."}))
|
||||||
jar = fn()
|
sys.exit(1)
|
||||||
except Exception as e:
|
'''
|
||||||
print(json.dumps({"error": str(e)}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
result = {}
|
|
||||||
for cookie in jar:
|
|
||||||
domain = cookie.domain or ""
|
|
||||||
if domain.endswith(".x.com") or domain.endswith(".twitter.com") or domain in ("x.com", "twitter.com", ".x.com", ".twitter.com"):
|
|
||||||
if cookie.name == "auth_token":
|
|
||||||
result["auth_token"] = cookie.value
|
|
||||||
elif cookie.name == "ct0":
|
|
||||||
result["ct0"] = cookie.value
|
|
||||||
|
|
||||||
if "auth_token" in result and "ct0" in result:
|
|
||||||
print(json.dumps(result))
|
|
||||||
else:
|
|
||||||
print(json.dumps({"error": "Could not find auth_token and ct0 cookies. Make sure you are logged into x.com in " + browser_name + "."}))
|
|
||||||
sys.exit(1)
|
|
||||||
''' % browser
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
raise RuntimeError(
|
if not cookies:
|
||||||
"No Twitter cookies found.\n"
|
raise RuntimeError(
|
||||||
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n"
|
"No Twitter cookies found.\n"
|
||||||
"Option 2: Make sure you are logged into x.com in your browser"
|
"Option 1: Set TWITTER_AUTH_TOKEN and TWITTER_CT0 environment variables\n"
|
||||||
)
|
"Option 2: Make sure you are logged into x.com in your browser (Chrome/Edge/Firefox/Brave)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return cookies
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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