jike-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.
- jike_cli-0.1.0/LICENSE +21 -0
- jike_cli-0.1.0/PKG-INFO +104 -0
- jike_cli-0.1.0/README.md +78 -0
- jike_cli-0.1.0/pyproject.toml +37 -0
- jike_cli-0.1.0/src/jike_cli/__init__.py +3 -0
- jike_cli-0.1.0/src/jike_cli/__main__.py +5 -0
- jike_cli-0.1.0/src/jike_cli/_jike/__init__.py +7 -0
- jike_cli-0.1.0/src/jike_cli/_jike/auth.py +133 -0
- jike_cli-0.1.0/src/jike_cli/_jike/client.py +139 -0
- jike_cli-0.1.0/src/jike_cli/_jike/types.py +28 -0
- jike_cli-0.1.0/src/jike_cli/auth.py +35 -0
- jike_cli-0.1.0/src/jike_cli/cli.py +265 -0
- jike_cli-0.1.0/src/jike_cli/formatter.py +124 -0
- jike_cli-0.1.0/src/jike_cli/models.py +191 -0
- jike_cli-0.1.0/src/jike_cli/output.py +18 -0
jike_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 case
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jike_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jike-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool for Jike (即刻) social network
|
|
5
|
+
Project-URL: Homepage, https://github.com/case/jike-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/case/jike-cli
|
|
7
|
+
Author: cypggsai
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,jike,social,即刻
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: click>=8.0
|
|
22
|
+
Requires-Dist: qrcode>=7.0
|
|
23
|
+
Requires-Dist: requests>=2.28
|
|
24
|
+
Requires-Dist: rich>=13.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# jike-cli
|
|
28
|
+
|
|
29
|
+
A CLI tool for [Jike (即刻)](https://www.okjike.com) social network.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# via uv
|
|
35
|
+
uv tool install jike-cli
|
|
36
|
+
|
|
37
|
+
# via pipx
|
|
38
|
+
pipx install jike-cli
|
|
39
|
+
|
|
40
|
+
# via pip
|
|
41
|
+
pip install jike-cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Login
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
jike login # Scan QR code with Jike app
|
|
50
|
+
jike logout # Clear saved tokens
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Browse
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
jike feed # View following feed
|
|
57
|
+
jike feed -n 10 # Limit to 10 posts
|
|
58
|
+
jike search "AI" # Search posts
|
|
59
|
+
jike notifications # View notifications
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Post
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
jike post "Hello from CLI!" # Create a post
|
|
66
|
+
jike delete-post POST_ID # Delete a post
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Comments
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
jike comment POST_ID "Nice!" # Add comment
|
|
73
|
+
jike delete-comment COMMENT_ID # Delete comment
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Users
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
jike profile USERNAME # View profile
|
|
80
|
+
jike followers USER_ID # List followers
|
|
81
|
+
jike following USER_ID # List following
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### JSON Output
|
|
85
|
+
|
|
86
|
+
All read commands support `--json` for machine-readable output:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
jike feed --json | jq '.[] | .content'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Non-TTY output (pipes) automatically uses JSON format.
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
git clone https://github.com/case/jike-cli
|
|
98
|
+
cd jike-cli
|
|
99
|
+
pip install -e .
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
jike_cli-0.1.0/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# jike-cli
|
|
2
|
+
|
|
3
|
+
A CLI tool for [Jike (即刻)](https://www.okjike.com) social network.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# via uv
|
|
9
|
+
uv tool install jike-cli
|
|
10
|
+
|
|
11
|
+
# via pipx
|
|
12
|
+
pipx install jike-cli
|
|
13
|
+
|
|
14
|
+
# via pip
|
|
15
|
+
pip install jike-cli
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Login
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
jike login # Scan QR code with Jike app
|
|
24
|
+
jike logout # Clear saved tokens
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Browse
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
jike feed # View following feed
|
|
31
|
+
jike feed -n 10 # Limit to 10 posts
|
|
32
|
+
jike search "AI" # Search posts
|
|
33
|
+
jike notifications # View notifications
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Post
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
jike post "Hello from CLI!" # Create a post
|
|
40
|
+
jike delete-post POST_ID # Delete a post
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Comments
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
jike comment POST_ID "Nice!" # Add comment
|
|
47
|
+
jike delete-comment COMMENT_ID # Delete comment
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Users
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
jike profile USERNAME # View profile
|
|
54
|
+
jike followers USER_ID # List followers
|
|
55
|
+
jike following USER_ID # List following
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### JSON Output
|
|
59
|
+
|
|
60
|
+
All read commands support `--json` for machine-readable output:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
jike feed --json | jq '.[] | .content'
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Non-TTY output (pipes) automatically uses JSON format.
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/case/jike-cli
|
|
72
|
+
cd jike-cli
|
|
73
|
+
pip install -e .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jike-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A CLI tool for Jike (即刻) social network"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "cypggsai" }]
|
|
13
|
+
keywords = ["jike", "即刻", "cli", "social"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Communications",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"click>=8.0",
|
|
27
|
+
"rich>=13.0",
|
|
28
|
+
"requests>=2.28",
|
|
29
|
+
"qrcode>=7.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
jike = "jike_cli.cli:cli"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/case/jike-cli"
|
|
37
|
+
Repository = "https://github.com/case/jike-cli"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Vendored jike-skill — Jike API client (MIT license, from imHw/jike-skill)."""
|
|
2
|
+
|
|
3
|
+
from .auth import authenticate, refresh_tokens
|
|
4
|
+
from .client import JikeClient
|
|
5
|
+
from .types import TokenPair
|
|
6
|
+
|
|
7
|
+
__all__ = ["JikeClient", "TokenPair", "authenticate", "refresh_tokens"]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Jike QR Authentication — scan-to-login flow."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .types import API_BASE, DEFAULT_HEADERS, TokenPair
|
|
12
|
+
|
|
13
|
+
POLL_INTERVAL_SEC = 1
|
|
14
|
+
POLL_TIMEOUT_SEC = 180
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _post(path: str, headers: Optional[dict] = None, **kwargs) -> requests.Response:
|
|
18
|
+
merged = {**DEFAULT_HEADERS, "Content-Type": "application/json"}
|
|
19
|
+
if headers:
|
|
20
|
+
merged.update(headers)
|
|
21
|
+
return requests.post(f"{API_BASE}{path}", headers=merged, **kwargs)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get(path: str) -> requests.Response:
|
|
25
|
+
return requests.get(f"{API_BASE}{path}", headers={**DEFAULT_HEADERS})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_session() -> str:
|
|
29
|
+
resp = _post("/sessions.create")
|
|
30
|
+
resp.raise_for_status()
|
|
31
|
+
return resp.json()["uuid"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_qr_payload(uuid: str) -> str:
|
|
35
|
+
scan_url = f"https://www.okjike.com/account/scan?uuid={uuid}"
|
|
36
|
+
return (
|
|
37
|
+
"jike://page.jk/web?url="
|
|
38
|
+
+ urllib.parse.quote(scan_url, safe="")
|
|
39
|
+
+ "&displayHeader=false&displayFooter=false"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_qr(data: str) -> bool:
|
|
44
|
+
try:
|
|
45
|
+
import qrcode
|
|
46
|
+
|
|
47
|
+
qr = qrcode.QRCode(border=1)
|
|
48
|
+
qr.add_data(data)
|
|
49
|
+
qr.make(fit=True)
|
|
50
|
+
qr.print_ascii(out=sys.stderr)
|
|
51
|
+
return True
|
|
52
|
+
except ImportError:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_tokens(resp: requests.Response) -> Optional[TokenPair]:
|
|
57
|
+
body: dict = {}
|
|
58
|
+
try:
|
|
59
|
+
body = resp.json()
|
|
60
|
+
except (ValueError, KeyError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
access = (
|
|
64
|
+
body.get("x-jike-access-token")
|
|
65
|
+
or body.get("access_token")
|
|
66
|
+
or resp.headers.get("x-jike-access-token")
|
|
67
|
+
)
|
|
68
|
+
refresh = (
|
|
69
|
+
body.get("x-jike-refresh-token")
|
|
70
|
+
or body.get("refresh_token")
|
|
71
|
+
or resp.headers.get("x-jike-refresh-token")
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if access and refresh:
|
|
75
|
+
return TokenPair(access_token=access, refresh_token=refresh)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def poll_confirmation(uuid: str) -> Optional[TokenPair]:
|
|
80
|
+
attempts = POLL_TIMEOUT_SEC // POLL_INTERVAL_SEC
|
|
81
|
+
|
|
82
|
+
for _ in range(attempts):
|
|
83
|
+
try:
|
|
84
|
+
resp = _get(f"/sessions.wait_for_confirmation?uuid={uuid}")
|
|
85
|
+
except requests.RequestException:
|
|
86
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if resp.status_code == 200:
|
|
90
|
+
return _extract_tokens(resp)
|
|
91
|
+
|
|
92
|
+
if resp.status_code == 400:
|
|
93
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
time.sleep(POLL_INTERVAL_SEC)
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def refresh_tokens(token_pair: TokenPair) -> TokenPair:
|
|
102
|
+
resp = _post(
|
|
103
|
+
"/app_auth_tokens.refresh",
|
|
104
|
+
headers={"x-jike-refresh-token": token_pair.refresh_token},
|
|
105
|
+
json={},
|
|
106
|
+
)
|
|
107
|
+
resp.raise_for_status()
|
|
108
|
+
|
|
109
|
+
return TokenPair(
|
|
110
|
+
access_token=resp.headers.get(
|
|
111
|
+
"x-jike-access-token", token_pair.access_token
|
|
112
|
+
),
|
|
113
|
+
refresh_token=resp.headers.get(
|
|
114
|
+
"x-jike-refresh-token", token_pair.refresh_token
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def authenticate() -> TokenPair:
|
|
120
|
+
uuid = create_session()
|
|
121
|
+
|
|
122
|
+
qr_payload = build_qr_payload(uuid)
|
|
123
|
+
if not render_qr(qr_payload):
|
|
124
|
+
print("Install 'qrcode' for terminal QR code.", file=sys.stderr)
|
|
125
|
+
print(f"Or open: {qr_payload}", file=sys.stderr)
|
|
126
|
+
|
|
127
|
+
tokens = poll_confirmation(uuid)
|
|
128
|
+
if not tokens:
|
|
129
|
+
print("Timeout — no scan detected.", file=sys.stderr)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
tokens = refresh_tokens(tokens)
|
|
133
|
+
return tokens
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Jike API Client — feed, posts, comments, search, profiles, notifications."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .types import API_BASE, DEFAULT_HEADERS, TokenPair
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JikeClient:
|
|
11
|
+
"""Jike API client with automatic token refresh on 401."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, tokens: TokenPair):
|
|
14
|
+
self._tokens = tokens
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def tokens(self) -> TokenPair:
|
|
18
|
+
return self._tokens
|
|
19
|
+
|
|
20
|
+
def _headers(self) -> dict:
|
|
21
|
+
return {
|
|
22
|
+
**DEFAULT_HEADERS,
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
"x-jike-access-token": self._tokens.access_token,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def _request(
|
|
28
|
+
self, method: str, path: str, retry_on_401: bool = True, **kwargs
|
|
29
|
+
) -> dict:
|
|
30
|
+
resp = requests.request(
|
|
31
|
+
method, f"{API_BASE}{path}", headers=self._headers(), **kwargs
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if resp.status_code == 401 and retry_on_401:
|
|
35
|
+
self._refresh()
|
|
36
|
+
return self._request(method, path, retry_on_401=False, **kwargs)
|
|
37
|
+
|
|
38
|
+
resp.raise_for_status()
|
|
39
|
+
return resp.json() if resp.content else {}
|
|
40
|
+
|
|
41
|
+
def _refresh(self) -> None:
|
|
42
|
+
resp = requests.post(
|
|
43
|
+
f"{API_BASE}/app_auth_tokens.refresh",
|
|
44
|
+
headers={
|
|
45
|
+
**DEFAULT_HEADERS,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"x-jike-refresh-token": self._tokens.refresh_token,
|
|
48
|
+
},
|
|
49
|
+
json={},
|
|
50
|
+
)
|
|
51
|
+
resp.raise_for_status()
|
|
52
|
+
self._tokens = TokenPair(
|
|
53
|
+
access_token=resp.headers.get(
|
|
54
|
+
"x-jike-access-token", self._tokens.access_token
|
|
55
|
+
),
|
|
56
|
+
refresh_token=resp.headers.get(
|
|
57
|
+
"x-jike-refresh-token", self._tokens.refresh_token
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def feed(self, limit: int = 20, load_more_key: Optional[str] = None) -> dict:
|
|
62
|
+
body: dict[str, object] = {"limit": limit}
|
|
63
|
+
if load_more_key:
|
|
64
|
+
body["loadMoreKey"] = load_more_key
|
|
65
|
+
return self._request(
|
|
66
|
+
"POST", "/1.0/personalUpdate/followingUpdates", json=body
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get_post(self, post_id: str) -> dict:
|
|
70
|
+
return self._request("GET", f"/1.0/originalPosts/get?id={post_id}")
|
|
71
|
+
|
|
72
|
+
def create_post(self, content: str, picture_keys: Optional[list] = None) -> dict:
|
|
73
|
+
return self._request(
|
|
74
|
+
"POST",
|
|
75
|
+
"/1.0/originalPosts/create",
|
|
76
|
+
json={"content": content, "pictureKeys": picture_keys or []},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def delete_post(self, post_id: str) -> dict:
|
|
80
|
+
return self._request(
|
|
81
|
+
"POST", "/1.0/originalPosts/remove", json={"id": post_id}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def add_comment(self, post_id: str, content: str) -> dict:
|
|
85
|
+
return self._request(
|
|
86
|
+
"POST",
|
|
87
|
+
"/1.0/comments/add",
|
|
88
|
+
json={
|
|
89
|
+
"targetType": "ORIGINAL_POST",
|
|
90
|
+
"targetId": post_id,
|
|
91
|
+
"content": content,
|
|
92
|
+
"syncToPersonalUpdates": False,
|
|
93
|
+
"pictureKeys": [],
|
|
94
|
+
"force": False,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def delete_comment(self, comment_id: str) -> dict:
|
|
99
|
+
return self._request(
|
|
100
|
+
"POST",
|
|
101
|
+
"/1.0/comments/remove",
|
|
102
|
+
json={"id": comment_id, "targetType": "ORIGINAL_POST"},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def search(
|
|
106
|
+
self, keyword: str, limit: int = 20, load_more_key: Optional[str] = None
|
|
107
|
+
) -> dict:
|
|
108
|
+
body: dict[str, object] = {"keyword": keyword, "limit": limit}
|
|
109
|
+
if load_more_key:
|
|
110
|
+
body["loadMoreKey"] = load_more_key
|
|
111
|
+
return self._request("POST", "/1.0/search/integrate", json=body)
|
|
112
|
+
|
|
113
|
+
def profile(self, username: str) -> dict:
|
|
114
|
+
return self._request("GET", f"/1.0/users/profile?username={username}")
|
|
115
|
+
|
|
116
|
+
def followers(self, user_id: str, load_more_key: Optional[str] = None) -> dict:
|
|
117
|
+
body: dict[str, object] = {"userId": user_id}
|
|
118
|
+
if load_more_key:
|
|
119
|
+
body["loadMoreKey"] = load_more_key
|
|
120
|
+
return self._request(
|
|
121
|
+
"POST", "/1.0/userRelation/getFollowerList", json=body
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def following(self, user_id: str, load_more_key: Optional[str] = None) -> dict:
|
|
125
|
+
body: dict[str, object] = {"userId": user_id}
|
|
126
|
+
if load_more_key:
|
|
127
|
+
body["loadMoreKey"] = load_more_key
|
|
128
|
+
return self._request(
|
|
129
|
+
"POST", "/1.0/userRelation/getFollowingList", json=body
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def unread_notifications(self) -> dict:
|
|
133
|
+
return self._request("GET", "/1.0/notifications/unread")
|
|
134
|
+
|
|
135
|
+
def list_notifications(self, load_more_key: Optional[str] = None) -> dict:
|
|
136
|
+
body: dict[str, object] = {}
|
|
137
|
+
if load_more_key:
|
|
138
|
+
body["loadMoreKey"] = load_more_key
|
|
139
|
+
return self._request("POST", "/1.0/notifications/list", json=body)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared types for the Jike client."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
API_BASE = "https://api.ruguoapp.com"
|
|
6
|
+
|
|
7
|
+
DEFAULT_HEADERS = {
|
|
8
|
+
"Origin": "https://web.okjike.com",
|
|
9
|
+
"User-Agent": (
|
|
10
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) "
|
|
11
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 "
|
|
12
|
+
"Mobile/15E148 Safari/604.1"
|
|
13
|
+
),
|
|
14
|
+
"Accept": "application/json, text/plain, */*",
|
|
15
|
+
"DNT": "1",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class TokenPair:
|
|
21
|
+
access_token: str
|
|
22
|
+
refresh_token: str
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, str]:
|
|
25
|
+
return {
|
|
26
|
+
"access_token": self.access_token,
|
|
27
|
+
"refresh_token": self.refresh_token,
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Token persistence — save/load/clear tokens to ~/.config/jike-cli/tokens.json."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from jike_cli._jike import TokenPair
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path(os.environ.get("JIKE_CLI_CONFIG_DIR", Path.home() / ".config" / "jike-cli"))
|
|
11
|
+
TOKEN_FILE = CONFIG_DIR / "tokens.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def save_tokens(tokens: TokenPair) -> None:
|
|
15
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
TOKEN_FILE.write_text(json.dumps(tokens.to_dict(), indent=2))
|
|
17
|
+
TOKEN_FILE.chmod(0o600)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_tokens() -> Optional[TokenPair]:
|
|
21
|
+
if not TOKEN_FILE.exists():
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(TOKEN_FILE.read_text())
|
|
25
|
+
return TokenPair(
|
|
26
|
+
access_token=data["access_token"],
|
|
27
|
+
refresh_token=data["refresh_token"],
|
|
28
|
+
)
|
|
29
|
+
except (json.JSONDecodeError, KeyError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clear_tokens() -> None:
|
|
34
|
+
if TOKEN_FILE.exists():
|
|
35
|
+
TOKEN_FILE.unlink()
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Click command group — all jike CLI commands."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from jike_cli import __version__
|
|
9
|
+
from jike_cli._jike import JikeClient, TokenPair, authenticate
|
|
10
|
+
from jike_cli.auth import clear_tokens, load_tokens, save_tokens
|
|
11
|
+
from jike_cli.formatter import (
|
|
12
|
+
console,
|
|
13
|
+
render_comment,
|
|
14
|
+
render_notifications,
|
|
15
|
+
render_posts,
|
|
16
|
+
render_user,
|
|
17
|
+
render_user_list,
|
|
18
|
+
)
|
|
19
|
+
from jike_cli.models import (
|
|
20
|
+
Comment,
|
|
21
|
+
Post,
|
|
22
|
+
User,
|
|
23
|
+
parse_feed,
|
|
24
|
+
parse_notifications,
|
|
25
|
+
parse_search,
|
|
26
|
+
parse_user_list,
|
|
27
|
+
)
|
|
28
|
+
from jike_cli.output import is_tty, print_json
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_client() -> JikeClient:
|
|
32
|
+
tokens = load_tokens()
|
|
33
|
+
if not tokens:
|
|
34
|
+
console.print("[red]Not logged in. Run `jike login` first.[/red]", file=sys.stderr)
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
return JikeClient(tokens)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _save_refreshed_tokens(client: JikeClient) -> None:
|
|
40
|
+
"""Persist tokens if they were refreshed during the request."""
|
|
41
|
+
save_tokens(client.tokens)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _use_json(json_flag: bool) -> bool:
|
|
45
|
+
return json_flag or not is_tty()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@click.group()
|
|
49
|
+
@click.version_option(__version__, prog_name="jike")
|
|
50
|
+
def cli() -> None:
|
|
51
|
+
"""Jike (即刻) social network CLI."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@cli.command()
|
|
55
|
+
def login() -> None:
|
|
56
|
+
"""Login via QR code scan."""
|
|
57
|
+
console.print("[bold]Scan the QR code with Jike app...[/bold]")
|
|
58
|
+
try:
|
|
59
|
+
tokens = authenticate()
|
|
60
|
+
save_tokens(tokens)
|
|
61
|
+
console.print("[green]Login successful![/green]")
|
|
62
|
+
except requests.HTTPError as e:
|
|
63
|
+
console.print(f"[red]Login failed: {e}[/red]", file=sys.stderr)
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command()
|
|
68
|
+
def logout() -> None:
|
|
69
|
+
"""Clear saved tokens."""
|
|
70
|
+
clear_tokens()
|
|
71
|
+
console.print("Logged out.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@cli.command()
|
|
75
|
+
@click.option("-n", "--limit", default=20, help="Number of posts to fetch.")
|
|
76
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
77
|
+
def feed(limit: int, json_flag: bool) -> None:
|
|
78
|
+
"""View your following feed."""
|
|
79
|
+
client = _get_client()
|
|
80
|
+
try:
|
|
81
|
+
data = client.feed(limit=limit)
|
|
82
|
+
_save_refreshed_tokens(client)
|
|
83
|
+
posts = parse_feed(data)
|
|
84
|
+
if _use_json(json_flag):
|
|
85
|
+
print_json(posts)
|
|
86
|
+
else:
|
|
87
|
+
render_posts(posts)
|
|
88
|
+
except requests.HTTPError as e:
|
|
89
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
90
|
+
raise SystemExit(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@cli.command()
|
|
94
|
+
@click.argument("content")
|
|
95
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
96
|
+
def post(content: str, json_flag: bool) -> None:
|
|
97
|
+
"""Create a new post."""
|
|
98
|
+
client = _get_client()
|
|
99
|
+
try:
|
|
100
|
+
data = client.create_post(content)
|
|
101
|
+
_save_refreshed_tokens(client)
|
|
102
|
+
if _use_json(json_flag):
|
|
103
|
+
print_json(data)
|
|
104
|
+
else:
|
|
105
|
+
console.print("[green]Post created![/green]")
|
|
106
|
+
p = Post.from_api(data.get("data", data))
|
|
107
|
+
if p.id:
|
|
108
|
+
console.print(f"[dim]ID: {p.id}[/dim]")
|
|
109
|
+
except requests.HTTPError as e:
|
|
110
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
111
|
+
raise SystemExit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@cli.command("delete-post")
|
|
115
|
+
@click.argument("post_id")
|
|
116
|
+
def delete_post(post_id: str) -> None:
|
|
117
|
+
"""Delete a post by ID."""
|
|
118
|
+
client = _get_client()
|
|
119
|
+
try:
|
|
120
|
+
client.delete_post(post_id)
|
|
121
|
+
_save_refreshed_tokens(client)
|
|
122
|
+
console.print("[green]Post deleted.[/green]")
|
|
123
|
+
except requests.HTTPError as e:
|
|
124
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
125
|
+
raise SystemExit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@cli.command()
|
|
129
|
+
@click.argument("post_id")
|
|
130
|
+
@click.argument("content")
|
|
131
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
132
|
+
def comment(post_id: str, content: str, json_flag: bool) -> None:
|
|
133
|
+
"""Add a comment to a post."""
|
|
134
|
+
client = _get_client()
|
|
135
|
+
try:
|
|
136
|
+
data = client.add_comment(post_id, content)
|
|
137
|
+
_save_refreshed_tokens(client)
|
|
138
|
+
if _use_json(json_flag):
|
|
139
|
+
print_json(data)
|
|
140
|
+
else:
|
|
141
|
+
console.print("[green]Comment added![/green]")
|
|
142
|
+
c = Comment.from_api(data.get("data", data))
|
|
143
|
+
if c.id:
|
|
144
|
+
console.print(f"[dim]ID: {c.id}[/dim]")
|
|
145
|
+
except requests.HTTPError as e:
|
|
146
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
147
|
+
raise SystemExit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@cli.command("delete-comment")
|
|
151
|
+
@click.argument("comment_id")
|
|
152
|
+
def delete_comment(comment_id: str) -> None:
|
|
153
|
+
"""Delete a comment by ID."""
|
|
154
|
+
client = _get_client()
|
|
155
|
+
try:
|
|
156
|
+
client.delete_comment(comment_id)
|
|
157
|
+
_save_refreshed_tokens(client)
|
|
158
|
+
console.print("[green]Comment deleted.[/green]")
|
|
159
|
+
except requests.HTTPError as e:
|
|
160
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
161
|
+
raise SystemExit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@cli.command()
|
|
165
|
+
@click.argument("keyword")
|
|
166
|
+
@click.option("-n", "--limit", default=20, help="Number of results.")
|
|
167
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
168
|
+
def search(keyword: str, limit: int, json_flag: bool) -> None:
|
|
169
|
+
"""Search for posts."""
|
|
170
|
+
client = _get_client()
|
|
171
|
+
try:
|
|
172
|
+
data = client.search(keyword, limit=limit)
|
|
173
|
+
_save_refreshed_tokens(client)
|
|
174
|
+
posts = parse_search(data)
|
|
175
|
+
if _use_json(json_flag):
|
|
176
|
+
print_json(posts)
|
|
177
|
+
else:
|
|
178
|
+
render_posts(posts)
|
|
179
|
+
except requests.HTTPError as e:
|
|
180
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
181
|
+
raise SystemExit(1)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@cli.command()
|
|
185
|
+
@click.argument("username")
|
|
186
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
187
|
+
def profile(username: str, json_flag: bool) -> None:
|
|
188
|
+
"""View a user's profile."""
|
|
189
|
+
client = _get_client()
|
|
190
|
+
try:
|
|
191
|
+
data = client.profile(username)
|
|
192
|
+
_save_refreshed_tokens(client)
|
|
193
|
+
user = User.from_api(data.get("user", data))
|
|
194
|
+
if _use_json(json_flag):
|
|
195
|
+
print_json(user)
|
|
196
|
+
else:
|
|
197
|
+
render_user(user)
|
|
198
|
+
except requests.HTTPError as e:
|
|
199
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
200
|
+
raise SystemExit(1)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@cli.command()
|
|
204
|
+
@click.argument("user_id")
|
|
205
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
206
|
+
def followers(user_id: str, json_flag: bool) -> None:
|
|
207
|
+
"""List a user's followers."""
|
|
208
|
+
client = _get_client()
|
|
209
|
+
try:
|
|
210
|
+
data = client.followers(user_id)
|
|
211
|
+
_save_refreshed_tokens(client)
|
|
212
|
+
users = parse_user_list(data)
|
|
213
|
+
if _use_json(json_flag):
|
|
214
|
+
print_json(users)
|
|
215
|
+
else:
|
|
216
|
+
render_user_list(users, title="Followers")
|
|
217
|
+
except requests.HTTPError as e:
|
|
218
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
219
|
+
raise SystemExit(1)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@cli.command()
|
|
223
|
+
@click.argument("user_id")
|
|
224
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
225
|
+
def following(user_id: str, json_flag: bool) -> None:
|
|
226
|
+
"""List users someone is following."""
|
|
227
|
+
client = _get_client()
|
|
228
|
+
try:
|
|
229
|
+
data = client.following(user_id)
|
|
230
|
+
_save_refreshed_tokens(client)
|
|
231
|
+
users = parse_user_list(data)
|
|
232
|
+
if _use_json(json_flag):
|
|
233
|
+
print_json(users)
|
|
234
|
+
else:
|
|
235
|
+
render_user_list(users, title="Following")
|
|
236
|
+
except requests.HTTPError as e:
|
|
237
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
238
|
+
raise SystemExit(1)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@cli.command()
|
|
242
|
+
@click.option("--unread-only", is_flag=True, help="Show only unread count.")
|
|
243
|
+
@click.option("--json", "json_flag", is_flag=True, help="Output as JSON.")
|
|
244
|
+
def notifications(unread_only: bool, json_flag: bool) -> None:
|
|
245
|
+
"""View notifications."""
|
|
246
|
+
client = _get_client()
|
|
247
|
+
try:
|
|
248
|
+
if unread_only:
|
|
249
|
+
data = client.unread_notifications()
|
|
250
|
+
_save_refreshed_tokens(client)
|
|
251
|
+
if _use_json(json_flag):
|
|
252
|
+
print_json(data)
|
|
253
|
+
else:
|
|
254
|
+
console.print(f"Unread notifications: [bold]{data}[/bold]")
|
|
255
|
+
else:
|
|
256
|
+
data = client.list_notifications()
|
|
257
|
+
_save_refreshed_tokens(client)
|
|
258
|
+
notifs = parse_notifications(data)
|
|
259
|
+
if _use_json(json_flag):
|
|
260
|
+
print_json(notifs)
|
|
261
|
+
else:
|
|
262
|
+
render_notifications(notifs)
|
|
263
|
+
except requests.HTTPError as e:
|
|
264
|
+
console.print(f"[red]Error: {e}[/red]", file=sys.stderr)
|
|
265
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Rich terminal rendering — tables, panels, formatted output."""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from jike_cli.models import Comment, Notification, Post, User
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _fmt_count(n: int) -> str:
|
|
14
|
+
if n >= 10000:
|
|
15
|
+
return f"{n / 10000:.1f}w"
|
|
16
|
+
if n >= 1000:
|
|
17
|
+
return f"{n / 1000:.1f}k"
|
|
18
|
+
return str(n)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _truncate(s: str, max_len: int = 80) -> str:
|
|
22
|
+
if len(s) <= max_len:
|
|
23
|
+
return s
|
|
24
|
+
return s[: max_len - 1] + "…"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_post(post: Post) -> None:
|
|
28
|
+
name = post.user.screen_name if post.user else "Unknown"
|
|
29
|
+
username = f"@{post.user.username}" if post.user else ""
|
|
30
|
+
|
|
31
|
+
header = Text()
|
|
32
|
+
header.append(name, style="bold cyan")
|
|
33
|
+
header.append(f" {username}", style="dim")
|
|
34
|
+
header.append(f" {post.created_at[:10]}", style="dim")
|
|
35
|
+
|
|
36
|
+
content = post.content or "(no content)"
|
|
37
|
+
if post.link_title:
|
|
38
|
+
content += f"\n🔗 {post.link_title}"
|
|
39
|
+
|
|
40
|
+
stats = (
|
|
41
|
+
f"❤ {_fmt_count(post.like_count)} "
|
|
42
|
+
f"💬 {_fmt_count(post.comment_count)} "
|
|
43
|
+
f"🔄 {_fmt_count(post.share_count)}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
panel = Panel(
|
|
47
|
+
f"{content}\n\n[dim]{stats}[/dim]",
|
|
48
|
+
title=header,
|
|
49
|
+
subtitle=f"[dim]{post.id}[/dim]",
|
|
50
|
+
border_style="blue",
|
|
51
|
+
padding=(0, 1),
|
|
52
|
+
)
|
|
53
|
+
console.print(panel)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_posts(posts: list[Post]) -> None:
|
|
57
|
+
if not posts:
|
|
58
|
+
console.print("[dim]No posts found.[/dim]")
|
|
59
|
+
return
|
|
60
|
+
for post in posts:
|
|
61
|
+
render_post(post)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def render_user(user: User) -> None:
|
|
65
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
66
|
+
table.add_column(style="bold")
|
|
67
|
+
table.add_column()
|
|
68
|
+
|
|
69
|
+
table.add_row("Name", user.screen_name)
|
|
70
|
+
table.add_row("Username", f"@{user.username}")
|
|
71
|
+
table.add_row("ID", user.id)
|
|
72
|
+
if user.bio:
|
|
73
|
+
table.add_row("Bio", user.bio)
|
|
74
|
+
if user.gender:
|
|
75
|
+
table.add_row("Gender", user.gender)
|
|
76
|
+
table.add_row("Followers", _fmt_count(user.followers_count))
|
|
77
|
+
table.add_row("Following", _fmt_count(user.following_count))
|
|
78
|
+
|
|
79
|
+
panel = Panel(table, title="[bold cyan]Profile[/bold cyan]", border_style="blue")
|
|
80
|
+
console.print(panel)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_user_list(users: list[User], title: str = "Users") -> None:
|
|
84
|
+
if not users:
|
|
85
|
+
console.print("[dim]No users found.[/dim]")
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
table = Table(title=title)
|
|
89
|
+
table.add_column("Name", style="cyan")
|
|
90
|
+
table.add_column("Username", style="dim")
|
|
91
|
+
table.add_column("Bio")
|
|
92
|
+
|
|
93
|
+
for u in users:
|
|
94
|
+
table.add_row(u.screen_name, f"@{u.username}", _truncate(u.bio, 50))
|
|
95
|
+
|
|
96
|
+
console.print(table)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_notifications(notifications: list[Notification]) -> None:
|
|
100
|
+
if not notifications:
|
|
101
|
+
console.print("[dim]No notifications.[/dim]")
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
table = Table(title="Notifications")
|
|
105
|
+
table.add_column("Type", style="yellow")
|
|
106
|
+
table.add_column("From", style="cyan")
|
|
107
|
+
table.add_column("Content")
|
|
108
|
+
table.add_column("Time", style="dim")
|
|
109
|
+
|
|
110
|
+
for n in notifications:
|
|
111
|
+
name = n.user.screen_name if n.user else ""
|
|
112
|
+
table.add_row(n.type, name, _truncate(n.content, 60), n.created_at[:10])
|
|
113
|
+
|
|
114
|
+
console.print(table)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def render_comment(comment: Comment) -> None:
|
|
118
|
+
name = comment.user.screen_name if comment.user else "Unknown"
|
|
119
|
+
console.print(
|
|
120
|
+
f"[bold cyan]{name}[/bold cyan] [dim]{comment.created_at[:10]}[/dim]"
|
|
121
|
+
)
|
|
122
|
+
console.print(f" {comment.content}")
|
|
123
|
+
console.print(f" [dim]❤ {_fmt_count(comment.like_count)} ID: {comment.id}[/dim]")
|
|
124
|
+
console.print()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Data models and API response parsers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class User:
|
|
9
|
+
id: str
|
|
10
|
+
username: str
|
|
11
|
+
screen_name: str
|
|
12
|
+
avatar_url: str = ""
|
|
13
|
+
bio: str = ""
|
|
14
|
+
gender: str = ""
|
|
15
|
+
is_following: bool = False
|
|
16
|
+
is_banned: bool = False
|
|
17
|
+
followers_count: int = 0
|
|
18
|
+
following_count: int = 0
|
|
19
|
+
highlights_count: int = 0
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_api(cls, data: dict) -> "User":
|
|
23
|
+
return cls(
|
|
24
|
+
id=data.get("id", ""),
|
|
25
|
+
username=data.get("username", ""),
|
|
26
|
+
screen_name=data.get("screenName", ""),
|
|
27
|
+
avatar_url=data.get("avatarImage", {}).get("picUrl", "") if isinstance(data.get("avatarImage"), dict) else "",
|
|
28
|
+
bio=data.get("bio", ""),
|
|
29
|
+
gender=data.get("gender", ""),
|
|
30
|
+
is_following=data.get("following", False),
|
|
31
|
+
is_banned=data.get("isBanned", False),
|
|
32
|
+
followers_count=data.get("statsCount", {}).get("followedCount", 0) if isinstance(data.get("statsCount"), dict) else 0,
|
|
33
|
+
following_count=data.get("statsCount", {}).get("followingCount", 0) if isinstance(data.get("statsCount"), dict) else 0,
|
|
34
|
+
highlights_count=data.get("statsCount", {}).get("highlightedPersonalUpdates", 0) if isinstance(data.get("statsCount"), dict) else 0,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"id": self.id,
|
|
40
|
+
"username": self.username,
|
|
41
|
+
"screen_name": self.screen_name,
|
|
42
|
+
"bio": self.bio,
|
|
43
|
+
"followers_count": self.followers_count,
|
|
44
|
+
"following_count": self.following_count,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Post:
|
|
50
|
+
id: str
|
|
51
|
+
type: str
|
|
52
|
+
content: str
|
|
53
|
+
user: Optional[User] = None
|
|
54
|
+
created_at: str = ""
|
|
55
|
+
like_count: int = 0
|
|
56
|
+
comment_count: int = 0
|
|
57
|
+
share_count: int = 0
|
|
58
|
+
pictures: list[str] = field(default_factory=list)
|
|
59
|
+
link_url: str = ""
|
|
60
|
+
link_title: str = ""
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_api(cls, data: dict) -> "Post":
|
|
64
|
+
user_data = data.get("user")
|
|
65
|
+
pictures = []
|
|
66
|
+
for pic in data.get("pictureKeys", []):
|
|
67
|
+
if isinstance(pic, str):
|
|
68
|
+
pictures.append(pic)
|
|
69
|
+
for pic in data.get("pictures", []):
|
|
70
|
+
if isinstance(pic, dict) and pic.get("picUrl"):
|
|
71
|
+
pictures.append(pic["picUrl"])
|
|
72
|
+
|
|
73
|
+
link_info = data.get("linkInfo") or {}
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
id=data.get("id", ""),
|
|
77
|
+
type=data.get("type", "ORIGINAL_POST"),
|
|
78
|
+
content=data.get("content", ""),
|
|
79
|
+
user=User.from_api(user_data) if user_data else None,
|
|
80
|
+
created_at=data.get("createdAt", ""),
|
|
81
|
+
like_count=data.get("likeCount", 0),
|
|
82
|
+
comment_count=data.get("commentCount", 0),
|
|
83
|
+
share_count=data.get("shareCount", 0),
|
|
84
|
+
pictures=pictures,
|
|
85
|
+
link_url=link_info.get("linkUrl", ""),
|
|
86
|
+
link_title=link_info.get("title", ""),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"id": self.id,
|
|
92
|
+
"type": self.type,
|
|
93
|
+
"content": self.content,
|
|
94
|
+
"user": self.user.to_dict() if self.user else None,
|
|
95
|
+
"created_at": self.created_at,
|
|
96
|
+
"like_count": self.like_count,
|
|
97
|
+
"comment_count": self.comment_count,
|
|
98
|
+
"share_count": self.share_count,
|
|
99
|
+
"pictures": self.pictures,
|
|
100
|
+
"link_url": self.link_url,
|
|
101
|
+
"link_title": self.link_title,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Comment:
|
|
107
|
+
id: str
|
|
108
|
+
content: str
|
|
109
|
+
user: Optional[User] = None
|
|
110
|
+
created_at: str = ""
|
|
111
|
+
like_count: int = 0
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_api(cls, data: dict) -> "Comment":
|
|
115
|
+
user_data = data.get("user")
|
|
116
|
+
return cls(
|
|
117
|
+
id=data.get("id", ""),
|
|
118
|
+
content=data.get("content", ""),
|
|
119
|
+
user=User.from_api(user_data) if user_data else None,
|
|
120
|
+
created_at=data.get("createdAt", ""),
|
|
121
|
+
like_count=data.get("likeCount", 0),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict:
|
|
125
|
+
return {
|
|
126
|
+
"id": self.id,
|
|
127
|
+
"content": self.content,
|
|
128
|
+
"user": self.user.to_dict() if self.user else None,
|
|
129
|
+
"created_at": self.created_at,
|
|
130
|
+
"like_count": self.like_count,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Notification:
|
|
136
|
+
id: str
|
|
137
|
+
type: str
|
|
138
|
+
content: str
|
|
139
|
+
user: Optional[User] = None
|
|
140
|
+
created_at: str = ""
|
|
141
|
+
referral_type: str = ""
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_api(cls, data: dict) -> "Notification":
|
|
145
|
+
user_data = data.get("actionItem", {}).get("users", [None])[0] if data.get("actionItem", {}).get("users") else None
|
|
146
|
+
action = data.get("actionItem", {})
|
|
147
|
+
return cls(
|
|
148
|
+
id=data.get("id", ""),
|
|
149
|
+
type=data.get("type", ""),
|
|
150
|
+
content=action.get("content", "") or data.get("content", ""),
|
|
151
|
+
user=User.from_api(user_data) if user_data else None,
|
|
152
|
+
created_at=data.get("createdAt", ""),
|
|
153
|
+
referral_type=data.get("referralType", ""),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict:
|
|
157
|
+
return {
|
|
158
|
+
"id": self.id,
|
|
159
|
+
"type": self.type,
|
|
160
|
+
"content": self.content,
|
|
161
|
+
"user": self.user.to_dict() if self.user else None,
|
|
162
|
+
"created_at": self.created_at,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_feed(data: dict) -> list[Post]:
|
|
167
|
+
posts = []
|
|
168
|
+
for item in data.get("data", []):
|
|
169
|
+
target = item
|
|
170
|
+
if item.get("type") == "REPOST" and item.get("target"):
|
|
171
|
+
target = item["target"]
|
|
172
|
+
posts.append(Post.from_api(target))
|
|
173
|
+
return posts
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_search(data: dict) -> list[Post]:
|
|
177
|
+
posts = []
|
|
178
|
+
for item in data.get("data", []):
|
|
179
|
+
if item.get("type") in ("ORIGINAL_POST", "REPOST"):
|
|
180
|
+
posts.append(Post.from_api(item))
|
|
181
|
+
elif item.get("target"):
|
|
182
|
+
posts.append(Post.from_api(item["target"]))
|
|
183
|
+
return posts
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_notifications(data: dict) -> list[Notification]:
|
|
187
|
+
return [Notification.from_api(n) for n in data.get("data", [])]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_user_list(data: dict) -> list[User]:
|
|
191
|
+
return [User.from_api(u) for u in data.get("data", [])]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Output utilities — JSON mode, TTY detection."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_tty() -> bool:
|
|
8
|
+
return sys.stdout.isatty()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def print_json(data: object) -> None:
|
|
12
|
+
if isinstance(data, list):
|
|
13
|
+
output = [item.to_dict() if hasattr(item, "to_dict") else item for item in data]
|
|
14
|
+
elif hasattr(data, "to_dict"):
|
|
15
|
+
output = data.to_dict()
|
|
16
|
+
else:
|
|
17
|
+
output = data
|
|
18
|
+
print(json.dumps(output, ensure_ascii=False, indent=2))
|