yifaban-cli 0.1.0__py3-none-any.whl
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.
- yifaban/__init__.py +29 -0
- yifaban/api.py +219 -0
- yifaban/cli.py +520 -0
- yifaban/config.py +78 -0
- yifaban/constants.py +34 -0
- yifaban/errors.py +14 -0
- yifaban/logger.py +66 -0
- yifaban/publishers/base.py +136 -0
- yifaban/publishers/honor.py +229 -0
- yifaban/publishers/huawei.py +228 -0
- yifaban/publishers/oppo.py +255 -0
- yifaban/publishers/tencent.py +422 -0
- yifaban/publishers/vivo.py +200 -0
- yifaban/publishers/xiaomi.py +399 -0
- yifaban/utils.py +53 -0
- yifaban_cli-0.1.0.dist-info/METADATA +98 -0
- yifaban_cli-0.1.0.dist-info/RECORD +20 -0
- yifaban_cli-0.1.0.dist-info/WHEEL +5 -0
- yifaban_cli-0.1.0.dist-info/entry_points.txt +2 -0
- yifaban_cli-0.1.0.dist-info/top_level.txt +1 -0
yifaban/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# -*- coding=utf8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
yifaban - 本地直连应用市场发布 CLI
|
|
5
|
+
|
|
6
|
+
复用 apk-publisher-python 仓库中的渠道发布器,直接从本地电脑
|
|
7
|
+
把 APK(渠道包)上传到华为/荣耀/小米/OPPO/vivo/腾讯应用宝。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .logger import log_t, get_g_log
|
|
11
|
+
from .constants import (
|
|
12
|
+
HUAWEI_CHANNEL_NAME,
|
|
13
|
+
XIAOMI_CHANNEL_NAME,
|
|
14
|
+
OPPO_CHANNEL_NAME,
|
|
15
|
+
TENCENT_CHANNEL_NAME,
|
|
16
|
+
VIVO_CHANNEL_NAME,
|
|
17
|
+
HONOR_CHANNEL_NAME,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"log_t",
|
|
22
|
+
"get_g_log",
|
|
23
|
+
"HUAWEI_CHANNEL_NAME",
|
|
24
|
+
"XIAOMI_CHANNEL_NAME",
|
|
25
|
+
"OPPO_CHANNEL_NAME",
|
|
26
|
+
"TENCENT_CHANNEL_NAME",
|
|
27
|
+
"VIVO_CHANNEL_NAME",
|
|
28
|
+
"HONOR_CHANNEL_NAME",
|
|
29
|
+
]
|
yifaban/api.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# -*- coding=utf8 -*-
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
易发版后端 API 客户端
|
|
5
|
+
|
|
6
|
+
签名协议与 apk-publisher-python 后端 core/utils.py 完全一致:
|
|
7
|
+
1. 参数按 key 排序(排除 key 字段),过滤 None / 空字符串 / 字典值
|
|
8
|
+
2. 值含中文([\u4e00-\u9fff])时做 URL 编码(safe="!~*'()",先归一化换行)
|
|
9
|
+
3. 拼接 key=value&... 后追加 &token={token}123D77aHE@m
|
|
10
|
+
4. 取 MD5 作为 key 参数
|
|
11
|
+
|
|
12
|
+
登录使用空 token 签名;业务请求 header 携带 Authorization/uid,body 携带 key。
|
|
13
|
+
响应格式统一为 {code, message, data},data 键为驼峰命名。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
import urllib.parse
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
from yifaban.config import apply_env_overrides
|
|
24
|
+
|
|
25
|
+
SIGN_SALT = "123D77aHE@m"
|
|
26
|
+
_CHINESE_RE = re.compile(r"[\u4e00-\u9fff]+")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def calculate_api_key(data: dict, token: str) -> str:
|
|
34
|
+
"""计算 API 签名(与后端 core/utils.py 的 calculate_api_key 一致)"""
|
|
35
|
+
valid_items = []
|
|
36
|
+
for key in sorted(data.keys()):
|
|
37
|
+
if key == "key":
|
|
38
|
+
continue
|
|
39
|
+
value = data[key]
|
|
40
|
+
if value is None or isinstance(value, dict) or len(str(value)) == 0:
|
|
41
|
+
continue
|
|
42
|
+
value_str = str(value)
|
|
43
|
+
if _CHINESE_RE.search(value_str):
|
|
44
|
+
value_str = urllib.parse.quote(
|
|
45
|
+
value_str.replace("\r\n", "\n").replace("\r", "\n"),
|
|
46
|
+
safe="!~*'()",
|
|
47
|
+
encoding="utf-8",
|
|
48
|
+
)
|
|
49
|
+
valid_items.append(f"{key}={value_str}")
|
|
50
|
+
s = "&".join(valid_items)
|
|
51
|
+
return hashlib.md5(f"{s}&token={token}{SIGN_SALT}".encode("utf-8")).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _post(server, path, data, token="", uid="", timeout=30):
|
|
55
|
+
payload = dict(data or {})
|
|
56
|
+
payload["key"] = calculate_api_key(payload, token)
|
|
57
|
+
headers = {"Content-Type": "application/json"}
|
|
58
|
+
if token:
|
|
59
|
+
headers["Authorization"] = token
|
|
60
|
+
headers["uid"] = uid
|
|
61
|
+
try:
|
|
62
|
+
resp = requests.post(f"{server}{path}", json=payload, headers=headers, timeout=timeout)
|
|
63
|
+
except requests.RequestException as e:
|
|
64
|
+
raise ApiError(f"网络请求失败: {e}") from e
|
|
65
|
+
try:
|
|
66
|
+
body = resp.json()
|
|
67
|
+
except ValueError:
|
|
68
|
+
raise ApiError(f"服务器返回非 JSON(HTTP {resp.status_code}): {resp.text[:200]}")
|
|
69
|
+
if resp.status_code == 401 and body.get("code") == 4001:
|
|
70
|
+
raise ApiError("登录已过期,请重新运行 yifaban login")
|
|
71
|
+
if body.get("code") != 200:
|
|
72
|
+
raise ApiError(f"{body.get('message') or '请求失败'}(code={body.get('code')})")
|
|
73
|
+
return body.get("data", {})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _post_public(server, path, data, timeout=30):
|
|
77
|
+
"""无 token 签名 POST(登录、Q助理 exchange/bind/cli poll 等公开接口)"""
|
|
78
|
+
payload = dict(data or {})
|
|
79
|
+
payload["key"] = calculate_api_key(payload, "")
|
|
80
|
+
try:
|
|
81
|
+
resp = requests.post(f"{server}{path}", json=payload, timeout=timeout)
|
|
82
|
+
except requests.RequestException as e:
|
|
83
|
+
raise ApiError(f"网络请求失败: {e}") from e
|
|
84
|
+
try:
|
|
85
|
+
body = resp.json()
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise ApiError(f"服务器返回非 JSON(HTTP {resp.status_code}): {resp.text[:200]}")
|
|
88
|
+
if body.get("code") != 200:
|
|
89
|
+
raise ApiError(body.get("message") or "请求失败")
|
|
90
|
+
return body.get("data", {})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save_login_session(server: str, result: dict, fallback_username: str = ""):
|
|
94
|
+
"""保存登录会话到 ~/.yifaban/auth.json(登录/扫码兑换/绑定登录共用)"""
|
|
95
|
+
from yifaban.config import save_auth
|
|
96
|
+
|
|
97
|
+
save_auth({
|
|
98
|
+
"server": server,
|
|
99
|
+
"token": result.get("token", ""),
|
|
100
|
+
"uid": result.get("uid", ""),
|
|
101
|
+
"username": result.get("username", fallback_username),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def login(username: str, password: str, server: str):
|
|
106
|
+
"""账号密码登录易发版,保存会话到 ~/.yifaban/auth.json"""
|
|
107
|
+
data = {
|
|
108
|
+
"username": username,
|
|
109
|
+
"password": password,
|
|
110
|
+
"t": str(int(time.time())),
|
|
111
|
+
}
|
|
112
|
+
result = _post_public(server, "/api/auth/login", data)
|
|
113
|
+
save_login_session(server, result, username)
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def qzhuli_auth_url(server: str):
|
|
118
|
+
"""获取 Q助理扫码授权地址与轮询会话 ID(响应 data 为驼峰键)"""
|
|
119
|
+
try:
|
|
120
|
+
resp = requests.get(f"{server}/api/qzhuli/cli/auth-url", timeout=30)
|
|
121
|
+
except requests.RequestException as e:
|
|
122
|
+
raise ApiError(f"网络请求失败: {e}") from e
|
|
123
|
+
try:
|
|
124
|
+
body = resp.json()
|
|
125
|
+
except ValueError:
|
|
126
|
+
raise ApiError(f"服务器返回非 JSON(HTTP {resp.status_code}): {resp.text[:200]}")
|
|
127
|
+
if body.get("code") != 200:
|
|
128
|
+
raise ApiError(body.get("message") or "获取授权地址失败")
|
|
129
|
+
data = body.get("data", {})
|
|
130
|
+
url, request_id = data.get("url", ""), data.get("requestId", "")
|
|
131
|
+
if not url or not request_id:
|
|
132
|
+
raise ApiError("服务器未返回授权地址")
|
|
133
|
+
return {"url": url, "request_id": request_id}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def qzhuli_poll(server: str, request_id: str):
|
|
137
|
+
"""轮询 Q助理扫码结果:{status: pending|ok|expired, ticket?}"""
|
|
138
|
+
return _post_public(server, "/api/qzhuli/cli/poll", {"request_id": request_id})
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def qzhuli_exchange(server: str, ticket: str):
|
|
142
|
+
"""
|
|
143
|
+
用 ticket 兑换登录(响应 data 为驼峰键):
|
|
144
|
+
已绑定 -> {token, uid, username, ...}
|
|
145
|
+
未绑定 -> {needBind: true, bindToken, nickname, qUid}
|
|
146
|
+
"""
|
|
147
|
+
return _post_public(server, "/api/qzhuli/exchange", {"ticket": ticket})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def qzhuli_bind(server: str, bind_token: str, username: str, password: str):
|
|
151
|
+
"""未绑定 Q助理:用易发版账号密码完成绑定并登录"""
|
|
152
|
+
return _post_public(server, "/api/qzhuli/bind", {
|
|
153
|
+
"bind_token": bind_token,
|
|
154
|
+
"username": username,
|
|
155
|
+
"password": password,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def require_auth():
|
|
160
|
+
from yifaban.config import load_auth
|
|
161
|
+
|
|
162
|
+
auth = load_auth()
|
|
163
|
+
if not auth.get("token") or not auth.get("uid"):
|
|
164
|
+
raise ApiError("未登录,请先运行 yifaban login")
|
|
165
|
+
return auth
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_apps():
|
|
169
|
+
"""获取账号下的应用列表"""
|
|
170
|
+
auth = require_auth()
|
|
171
|
+
data = _post(
|
|
172
|
+
auth["server"],
|
|
173
|
+
"/api/app/get_apps_data",
|
|
174
|
+
{"uid": auth["uid"], "with_channel_config": 0, "with_latest_task": 0},
|
|
175
|
+
auth["token"],
|
|
176
|
+
auth["uid"],
|
|
177
|
+
)
|
|
178
|
+
return data.get("apps", [])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_channel_configs(app_id: str):
|
|
182
|
+
"""获取某应用在所有市场的渠道配置"""
|
|
183
|
+
auth = require_auth()
|
|
184
|
+
return _post(
|
|
185
|
+
auth["server"],
|
|
186
|
+
"/api/config/get_app_channel_configs",
|
|
187
|
+
{"uid": auth["uid"], "app_id": app_id},
|
|
188
|
+
auth["token"],
|
|
189
|
+
auth["uid"],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_CAMEL_TO_SNAKE = {
|
|
194
|
+
"clientId": "client_id",
|
|
195
|
+
"clientSecret": "client_secret",
|
|
196
|
+
"appId": "app_id",
|
|
197
|
+
"accessKey": "access_key",
|
|
198
|
+
"accessSecret": "access_secret",
|
|
199
|
+
"publicKey": "public_key",
|
|
200
|
+
"userId": "user_id",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def channel_publisher_config(server_cfg: dict, channel: str) -> dict:
|
|
205
|
+
"""
|
|
206
|
+
把服务器返回的渠道配置(驼峰键)转换为发布器需要的配置(下划线键)。
|
|
207
|
+
小米 publicKey 服务器侧已做 base64 解码,这里直接透传。
|
|
208
|
+
"""
|
|
209
|
+
config_data = (server_cfg or {}).get("configData") or {}
|
|
210
|
+
result = {}
|
|
211
|
+
for k, v in config_data.items():
|
|
212
|
+
if v is None:
|
|
213
|
+
continue
|
|
214
|
+
snake = _CAMEL_TO_SNAKE.get(k, k)
|
|
215
|
+
result[snake] = v
|
|
216
|
+
# 渠道行自身的 app_id 兜底(华为/荣耀/腾讯发布器需要)
|
|
217
|
+
if "app_id" not in result and server_cfg.get("appId"):
|
|
218
|
+
result["app_id"] = server_cfg.get("appId")
|
|
219
|
+
return apply_env_overrides(channel, result)
|