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.
- twitter_cli-0.1.0/.github/workflows/ci.yml +31 -0
- twitter_cli-0.1.0/.github/workflows/publish.yml +34 -0
- twitter_cli-0.1.0/.gitignore +9 -0
- twitter_cli-0.1.0/PKG-INFO +185 -0
- twitter_cli-0.1.0/README.md +153 -0
- twitter_cli-0.1.0/config.yaml +15 -0
- twitter_cli-0.1.0/pyproject.toml +57 -0
- twitter_cli-0.1.0/tests/conftest.py +36 -0
- twitter_cli-0.1.0/tests/test_cli.py +28 -0
- twitter_cli-0.1.0/tests/test_config.py +43 -0
- twitter_cli-0.1.0/tests/test_config_normalization.py +35 -0
- twitter_cli-0.1.0/tests/test_filter.py +31 -0
- twitter_cli-0.1.0/tests/test_serialization.py +22 -0
- twitter_cli-0.1.0/twitter_cli/__init__.py +3 -0
- twitter_cli-0.1.0/twitter_cli/auth.py +193 -0
- twitter_cli-0.1.0/twitter_cli/cli.py +245 -0
- twitter_cli-0.1.0/twitter_cli/client.py +586 -0
- twitter_cli-0.1.0/twitter_cli/config.py +149 -0
- twitter_cli-0.1.0/twitter_cli/filter.py +115 -0
- twitter_cli-0.1.0/twitter_cli/formatter.py +247 -0
- twitter_cli-0.1.0/twitter_cli/models.py +69 -0
- twitter_cli-0.1.0/twitter_cli/serialization.py +147 -0
- twitter_cli-0.1.0/uv.lock +690 -0
|
@@ -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,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,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"
|