twitter-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ["**"]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Setup uv
17
+ uses: astral-sh/setup-uv@v6
18
+
19
+ - name: Setup Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install dependencies
25
+ run: uv sync --extra dev
26
+
27
+ - name: Lint
28
+ run: uv run ruff check .
29
+
30
+ - name: Test
31
+ run: uv run pytest -q
@@ -0,0 +1,34 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ verify:
11
+ uses: ./.github/workflows/ci.yml
12
+
13
+ publish:
14
+ needs: verify
15
+ runs-on: ubuntu-latest
16
+ environment: pypi
17
+ permissions:
18
+ id-token: write
19
+
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.12"
26
+
27
+ - name: Setup uv
28
+ uses: astral-sh/setup-uv@v6
29
+
30
+ - name: Build package
31
+ run: uv build
32
+
33
+ - name: Publish to PyPI
34
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .env
8
+ *.json
9
+ !config.yaml
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: twitter-cli
3
+ Version: 0.1.0
4
+ Summary: A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal
5
+ Project-URL: Homepage, https://github.com/jackwener/twitter-cli
6
+ Project-URL: Repository, https://github.com/jackwener/twitter-cli
7
+ Project-URL: Issues, https://github.com/jackwener/twitter-cli/issues
8
+ Author-email: jackwener <jakevingoo@gmail.com>
9
+ License-Expression: Apache-2.0
10
+ Keywords: cli,feed,timeline,twitter,x
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.8
24
+ Requires-Dist: browser-cookie3>=0.19
25
+ Requires-Dist: click>=8.0
26
+ Requires-Dist: pyyaml>=6.0
27
+ Requires-Dist: rich>=13.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.8; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # Twitter CLI
34
+
35
+ Twitter/X 命令行工具 — 读取 Timeline、书签和用户信息。
36
+
37
+ **零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。
38
+
39
+ ## 安装
40
+
41
+ ```bash
42
+ # 推荐:uv tool(更快,隔离环境)
43
+ uv tool install twitter-cli
44
+
45
+ # 其次:pipx
46
+ pipx install twitter-cli
47
+ ```
48
+
49
+ 从源码安装:
50
+
51
+ ```bash
52
+ git clone git@github.com:jackwener/twitter-cli.git
53
+ cd twitter-cli
54
+ uv sync
55
+ ```
56
+
57
+ ## Quick Start
58
+
59
+ ```bash
60
+ # 运行(自动从 Chrome 提取 Cookie)
61
+ twitter feed
62
+ ```
63
+
64
+ 首次运行确保 Chrome 已登录 x.com。
65
+
66
+ ## 使用方式
67
+
68
+ ### 读取
69
+
70
+ ```bash
71
+ # 抓取首页 timeline(For You 算法推荐)
72
+ twitter feed
73
+
74
+ # 抓取关注的人的 timeline(Following 时间线)
75
+ twitter feed -t following
76
+
77
+ # 自定义抓取条数
78
+ twitter feed --max 50
79
+
80
+ # 开启筛选(按 score 排序过滤)
81
+ twitter feed --filter
82
+
83
+ # JSON 输出
84
+ twitter feed --json > tweets.json
85
+
86
+ # 从已有数据加载
87
+ twitter feed --input tweets.json
88
+
89
+
90
+ # 抓取收藏
91
+ twitter favorite
92
+ twitter favorite --max 30 --json
93
+ ```
94
+
95
+ ### 用户
96
+
97
+ ```bash
98
+ # 查看用户资料
99
+ twitter user elonmusk
100
+
101
+ # 列出用户推文
102
+ twitter user-posts elonmusk --max 20
103
+ ```
104
+
105
+ ## Pipeline
106
+
107
+ ```
108
+ 抓取 (GraphQL API) → 筛选 (Engagement Score)
109
+ 50 条 top 20
110
+ ```
111
+
112
+ ### 筛选算法
113
+
114
+ 加权评分公式,收藏权重最高(代表"值得回看"):
115
+
116
+ ```
117
+ score = 1.0 × likes + 3.0 × retweets + 2.0 × replies
118
+ + 5.0 × bookmarks + 0.5 × log10(views)
119
+ ```
120
+
121
+ ## 配置
122
+
123
+ 编辑 `config.yaml`:
124
+
125
+ ```yaml
126
+ fetch:
127
+ count: 50
128
+
129
+ filter:
130
+ mode: "topN" # "topN" | "score" | "all"
131
+ topN: 20
132
+ weights:
133
+ likes: 1.0
134
+ retweets: 3.0
135
+ replies: 2.0
136
+ bookmarks: 5.0
137
+ views_log: 0.5
138
+ ```
139
+
140
+ ### Cookie 配置
141
+
142
+ **方式 1:自动提取**(推荐) — 确保浏览器已登录 x.com,程序自动通过 `browser-cookie3` 按 Chrome → Edge → Firefox → Brave 顺序尝试读取。
143
+
144
+ **方式 2:环境变量** — 设置:
145
+
146
+ ```bash
147
+ export TWITTER_AUTH_TOKEN=your_auth_token
148
+ export TWITTER_CT0=your_ct0
149
+ ```
150
+
151
+ 可通过 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 浏览器插件导出。
152
+
153
+ ## 项目结构
154
+
155
+ ```
156
+ twitter_cli/
157
+ ├── __init__.py # 版本信息
158
+ ├── cli.py # CLI 入口 (click)
159
+ ├── client.py # Twitter GraphQL API Client (GET)
160
+ ├── auth.py # Cookie 提取 (env / browser-cookie3)
161
+ ├── filter.py # Engagement scoring + 筛选
162
+ ├── formatter.py # Rich 终端输出 + JSON
163
+ ├── config.py # YAML 配置加载
164
+ ├── serialization.py # Tweet JSON <-> dataclass
165
+ └── models.py # 数据模型 (dataclass)
166
+ ```
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ # Install development tools
172
+ uv sync --extra dev
173
+
174
+ # Run tests
175
+ uv run pytest
176
+
177
+ # Lint
178
+ uv run ruff check .
179
+ ```
180
+
181
+ ## 注意事项
182
+
183
+ - 使用 Cookie 登录存在被平台检测的风险,建议使用**专用小号**
184
+ - Cookie 只存在本地,不上传不外传
185
+ - GraphQL `queryId` 会从 Twitter 前端 JS 自动检测,无需手动维护
@@ -0,0 +1,153 @@
1
+ # Twitter CLI
2
+
3
+ Twitter/X 命令行工具 — 读取 Timeline、书签和用户信息。
4
+
5
+ **零 API Key** — 使用浏览器 Cookie 认证,免费访问 Twitter。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ # 推荐:uv tool(更快,隔离环境)
11
+ uv tool install twitter-cli
12
+
13
+ # 其次:pipx
14
+ pipx install twitter-cli
15
+ ```
16
+
17
+ 从源码安装:
18
+
19
+ ```bash
20
+ git clone git@github.com:jackwener/twitter-cli.git
21
+ cd twitter-cli
22
+ uv sync
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```bash
28
+ # 运行(自动从 Chrome 提取 Cookie)
29
+ twitter feed
30
+ ```
31
+
32
+ 首次运行确保 Chrome 已登录 x.com。
33
+
34
+ ## 使用方式
35
+
36
+ ### 读取
37
+
38
+ ```bash
39
+ # 抓取首页 timeline(For You 算法推荐)
40
+ twitter feed
41
+
42
+ # 抓取关注的人的 timeline(Following 时间线)
43
+ twitter feed -t following
44
+
45
+ # 自定义抓取条数
46
+ twitter feed --max 50
47
+
48
+ # 开启筛选(按 score 排序过滤)
49
+ twitter feed --filter
50
+
51
+ # JSON 输出
52
+ twitter feed --json > tweets.json
53
+
54
+ # 从已有数据加载
55
+ twitter feed --input tweets.json
56
+
57
+
58
+ # 抓取收藏
59
+ twitter favorite
60
+ twitter favorite --max 30 --json
61
+ ```
62
+
63
+ ### 用户
64
+
65
+ ```bash
66
+ # 查看用户资料
67
+ twitter user elonmusk
68
+
69
+ # 列出用户推文
70
+ twitter user-posts elonmusk --max 20
71
+ ```
72
+
73
+ ## Pipeline
74
+
75
+ ```
76
+ 抓取 (GraphQL API) → 筛选 (Engagement Score)
77
+ 50 条 top 20
78
+ ```
79
+
80
+ ### 筛选算法
81
+
82
+ 加权评分公式,收藏权重最高(代表"值得回看"):
83
+
84
+ ```
85
+ score = 1.0 × likes + 3.0 × retweets + 2.0 × replies
86
+ + 5.0 × bookmarks + 0.5 × log10(views)
87
+ ```
88
+
89
+ ## 配置
90
+
91
+ 编辑 `config.yaml`:
92
+
93
+ ```yaml
94
+ fetch:
95
+ count: 50
96
+
97
+ filter:
98
+ mode: "topN" # "topN" | "score" | "all"
99
+ topN: 20
100
+ weights:
101
+ likes: 1.0
102
+ retweets: 3.0
103
+ replies: 2.0
104
+ bookmarks: 5.0
105
+ views_log: 0.5
106
+ ```
107
+
108
+ ### Cookie 配置
109
+
110
+ **方式 1:自动提取**(推荐) — 确保浏览器已登录 x.com,程序自动通过 `browser-cookie3` 按 Chrome → Edge → Firefox → Brave 顺序尝试读取。
111
+
112
+ **方式 2:环境变量** — 设置:
113
+
114
+ ```bash
115
+ export TWITTER_AUTH_TOKEN=your_auth_token
116
+ export TWITTER_CT0=your_ct0
117
+ ```
118
+
119
+ 可通过 [Cookie-Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) 浏览器插件导出。
120
+
121
+ ## 项目结构
122
+
123
+ ```
124
+ twitter_cli/
125
+ ├── __init__.py # 版本信息
126
+ ├── cli.py # CLI 入口 (click)
127
+ ├── client.py # Twitter GraphQL API Client (GET)
128
+ ├── auth.py # Cookie 提取 (env / browser-cookie3)
129
+ ├── filter.py # Engagement scoring + 筛选
130
+ ├── formatter.py # Rich 终端输出 + JSON
131
+ ├── config.py # YAML 配置加载
132
+ ├── serialization.py # Tweet JSON <-> dataclass
133
+ └── models.py # 数据模型 (dataclass)
134
+ ```
135
+
136
+ ## Development
137
+
138
+ ```bash
139
+ # Install development tools
140
+ uv sync --extra dev
141
+
142
+ # Run tests
143
+ uv run pytest
144
+
145
+ # Lint
146
+ uv run ruff check .
147
+ ```
148
+
149
+ ## 注意事项
150
+
151
+ - 使用 Cookie 登录存在被平台检测的风险,建议使用**专用小号**
152
+ - Cookie 只存在本地,不上传不外传
153
+ - GraphQL `queryId` 会从 Twitter 前端 JS 自动检测,无需手动维护
@@ -0,0 +1,15 @@
1
+ fetch:
2
+ count: 50
3
+
4
+ filter:
5
+ mode: "topN"
6
+ topN: 20
7
+ minScore: 50
8
+ lang: []
9
+ excludeRetweets: false
10
+ weights:
11
+ likes: 1.0
12
+ retweets: 3.0
13
+ replies: 2.0
14
+ bookmarks: 5.0
15
+ views_log: 0.5
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "twitter-cli"
7
+ version = "0.1.0"
8
+ description = "A CLI for Twitter/X — feed, bookmarks, and user timeline in terminal"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.8"
12
+ authors = [{ name = "jackwener", email = "jakevingoo@gmail.com" }]
13
+ keywords = ["twitter", "x", "cli", "feed", "timeline"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.8",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Utilities",
27
+ ]
28
+ dependencies = [
29
+ "browser-cookie3>=0.19",
30
+ "click>=8.0",
31
+ "rich>=13.0",
32
+ "PyYAML>=6.0",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ "ruff>=0.8",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/jackwener/twitter-cli"
43
+ Repository = "https://github.com/jackwener/twitter-cli"
44
+ Issues = "https://github.com/jackwener/twitter-cli/issues"
45
+
46
+ [project.scripts]
47
+ twitter = "twitter_cli.cli:cli"
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+ python_files = ["test_*.py"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+
56
+ [tool.hatch.build.targets.wheel]
57
+ packages = ["twitter_cli"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pytest
6
+
7
+ from twitter_cli.models import Author, Metrics, Tweet
8
+
9
+
10
+ @pytest.fixture()
11
+ def tweet_factory():
12
+ def _make_tweet(tweet_id: str = "1", **overrides: Any) -> Tweet:
13
+ metrics = overrides.pop(
14
+ "metrics",
15
+ Metrics(likes=10, retweets=2, replies=1, quotes=0, views=120, bookmarks=3),
16
+ )
17
+ author = overrides.pop(
18
+ "author",
19
+ Author(id="u1", name="Alice", screen_name="alice", verified=False),
20
+ )
21
+ return Tweet(
22
+ id=tweet_id,
23
+ text=overrides.pop("text", "hello"),
24
+ author=author,
25
+ metrics=metrics,
26
+ created_at=overrides.pop("created_at", "2025-01-01"),
27
+ media=overrides.pop("media", []),
28
+ urls=overrides.pop("urls", []),
29
+ is_retweet=overrides.pop("is_retweet", False),
30
+ lang=overrides.pop("lang", "en"),
31
+ retweeted_by=overrides.pop("retweeted_by", None),
32
+ quoted_tweet=overrides.pop("quoted_tweet", None),
33
+ score=overrides.pop("score", 0.0),
34
+ )
35
+
36
+ return _make_tweet
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from click.testing import CliRunner
4
+
5
+ from twitter_cli.cli import cli
6
+ from twitter_cli.models import UserProfile
7
+ from twitter_cli.serialization import tweets_to_json
8
+
9
+
10
+ def test_cli_user_command_works_with_client_factory(monkeypatch) -> None:
11
+ class FakeClient:
12
+ def fetch_user(self, screen_name: str) -> UserProfile:
13
+ return UserProfile(id="1", name="Alice", screen_name=screen_name)
14
+
15
+ monkeypatch.setattr("twitter_cli.cli._get_client", lambda: FakeClient())
16
+ runner = CliRunner()
17
+ result = runner.invoke(cli, ["user", "alice"])
18
+ assert result.exit_code == 0
19
+
20
+
21
+ def test_cli_feed_json_input_path(tmp_path, tweet_factory) -> None:
22
+ json_path = tmp_path / "tweets.json"
23
+ json_path.write_text(tweets_to_json([tweet_factory("1")]), encoding="utf-8")
24
+
25
+ runner = CliRunner()
26
+ result = runner.invoke(cli, ["feed", "--input", str(json_path), "--json"])
27
+ assert result.exit_code == 0
28
+ assert '"id": "1"' in result.output
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from twitter_cli.config import DEFAULT_CONFIG, load_config
6
+
7
+
8
+ def test_load_config_supports_block_list_yaml(tmp_path: Path) -> None:
9
+ config_file = tmp_path / "config.yaml"
10
+ config_file.write_text(
11
+ "\n".join(
12
+ [
13
+ "fetch:",
14
+ " count: 25",
15
+ "filter:",
16
+ " mode: score",
17
+ " lang:",
18
+ " - en",
19
+ " - zh",
20
+ ]
21
+ ),
22
+ encoding="utf-8",
23
+ )
24
+
25
+ config = load_config(str(config_file))
26
+ assert config["fetch"]["count"] == 25
27
+ assert config["filter"]["mode"] == "score"
28
+ assert config["filter"]["lang"] == ["en", "zh"]
29
+
30
+
31
+ def test_load_config_invalid_yaml_falls_back_to_defaults(tmp_path: Path) -> None:
32
+ config_file = tmp_path / "config.yaml"
33
+ config_file.write_text("fetch: [", encoding="utf-8")
34
+
35
+ config = load_config(str(config_file))
36
+ assert config["fetch"]["count"] == DEFAULT_CONFIG["fetch"]["count"]
37
+ assert config["filter"]["mode"] == DEFAULT_CONFIG["filter"]["mode"]
38
+
39
+
40
+ def test_load_config_does_not_mutate_defaults(tmp_path: Path) -> None:
41
+ config = load_config(str(tmp_path / "missing-config.yaml"))
42
+ config["filter"]["weights"]["likes"] = 999
43
+ assert DEFAULT_CONFIG["filter"]["weights"]["likes"] == 1.0
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from twitter_cli.config import load_config
6
+
7
+
8
+ def test_filter_normalization_for_invalid_values(tmp_path: Path) -> None:
9
+ config_file = tmp_path / "config.yaml"
10
+ config_file.write_text(
11
+ "\n".join(
12
+ [
13
+ "fetch:",
14
+ " count: -5",
15
+ "filter:",
16
+ " mode: unknown",
17
+ " topN: -1",
18
+ " minScore: abc",
19
+ " lang: zh",
20
+ " weights:",
21
+ " likes: bad",
22
+ " retweets: 4",
23
+ ]
24
+ ),
25
+ encoding="utf-8",
26
+ )
27
+
28
+ config = load_config(str(config_file))
29
+ assert config["fetch"]["count"] == 1
30
+ assert config["filter"]["mode"] == "topN"
31
+ assert config["filter"]["topN"] == 1
32
+ assert config["filter"]["minScore"] == 50.0
33
+ assert config["filter"]["lang"] == []
34
+ assert config["filter"]["weights"]["likes"] == 1.0
35
+ assert config["filter"]["weights"]["retweets"] == 4.0
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from twitter_cli.filter import filter_tweets
4
+
5
+
6
+ def test_filter_tweets_does_not_mutate_input(tweet_factory) -> None:
7
+ tweet = tweet_factory("1", score=0.0)
8
+ output = filter_tweets([tweet], {"mode": "all", "weights": {}})
9
+
10
+ assert tweet.score == 0.0
11
+ assert output[0].score > 0.0
12
+ assert output[0] is not tweet
13
+
14
+
15
+ def test_filter_tweets_applies_language_and_retweet_filters(tweet_factory) -> None:
16
+ tweets = [
17
+ tweet_factory("1", lang="en", is_retweet=False),
18
+ tweet_factory("2", lang="zh", is_retweet=False),
19
+ tweet_factory("3", lang="en", is_retweet=True),
20
+ ]
21
+ output = filter_tweets(
22
+ tweets,
23
+ {
24
+ "mode": "all",
25
+ "lang": ["en"],
26
+ "excludeRetweets": True,
27
+ "weights": {},
28
+ },
29
+ )
30
+
31
+ assert [tweet.id for tweet in output] == ["1"]
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from twitter_cli.serialization import tweet_from_dict, tweet_to_dict, tweets_from_json, tweets_to_json
4
+
5
+
6
+ def test_tweet_roundtrip_dict(tweet_factory) -> None:
7
+ tweet = tweet_factory("42")
8
+ payload = tweet_to_dict(tweet)
9
+ restored = tweet_from_dict(payload)
10
+
11
+ assert restored.id == tweet.id
12
+ assert restored.author.screen_name == tweet.author.screen_name
13
+ assert restored.metrics.likes == tweet.metrics.likes
14
+
15
+
16
+ def test_tweets_json_roundtrip(tweet_factory) -> None:
17
+ tweets = [tweet_factory("1"), tweet_factory("2", lang="zh")]
18
+ raw = tweets_to_json(tweets)
19
+ restored = tweets_from_json(raw)
20
+
21
+ assert [tweet.id for tweet in restored] == ["1", "2"]
22
+ assert restored[1].lang == "zh"
@@ -0,0 +1,3 @@
1
+ """twitter-cli: A CLI for Twitter/X."""
2
+
3
+ __version__ = "0.1.0"