pymecli 0.5.3__tar.gz → 0.5.4__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.
- {pymecli-0.5.3 → pymecli-0.5.4}/PKG-INFO +3 -2
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/bitget.py +8 -12
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/gate.py +3 -5
- pymecli-0.5.4/cli/tele.py +355 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/crypto/bitget.py +15 -22
- {pymecli-0.5.3 → pymecli-0.5.4}/crypto/gate.py +5 -7
- {pymecli-0.5.3 → pymecli-0.5.4}/pyproject.toml +3 -1
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/mysql.py +62 -20
- {pymecli-0.5.3 → pymecli-0.5.4}/.gitignore +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/README.md +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/api/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/api/v1/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/api/v1/clash.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/api/v1/redis.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/dst.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/example.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/fast.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/redis_csv.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/cli/util.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/core/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/core/clash.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/core/config.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/core/redis_client.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/crypto/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/data/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/data/dou_dict.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/data/dou_list.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/data/main.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/data/template.yaml +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/models/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/models/douzero_model.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/models/ocr_model.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/models/response.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/__init__.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/elapsed.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/helper.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/logger.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/path.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/pd.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/pyredis.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/sleep.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/text.py +0 -0
- {pymecli-0.5.3 → pymecli-0.5.4}/utils/toml.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pymecli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
4
4
|
Summary: My CLI
|
|
5
5
|
Project-URL: Homepage, https://pypi.org/project/pymecli
|
|
6
6
|
Project-URL: Repository, https://github.com/meme2046/pymecli
|
|
@@ -28,6 +28,7 @@ Requires-Dist: selenium>=4.40.0
|
|
|
28
28
|
Requires-Dist: slpp>=1.2.3
|
|
29
29
|
Requires-Dist: sqlalchemy>=2.0.45
|
|
30
30
|
Requires-Dist: tabulate>=0.9.0
|
|
31
|
+
Requires-Dist: telethon>=1.44.0
|
|
31
32
|
Requires-Dist: toml>=0.10.2
|
|
32
33
|
Requires-Dist: typer>=0.20.1
|
|
33
34
|
Requires-Dist: uvicorn>=0.40.0
|
|
@@ -21,23 +21,19 @@ app = typer.Typer()
|
|
|
21
21
|
@app.command()
|
|
22
22
|
def sync(
|
|
23
23
|
env_path: str = "d:/.env",
|
|
24
|
-
csv_dir: str = "d:/github/meme2046/data",
|
|
25
24
|
):
|
|
26
|
-
"""同步mysql中grid数据到
|
|
25
|
+
"""同步mysql中grid数据到redis"""
|
|
27
26
|
engine = get_database_engine(env_path)
|
|
28
|
-
grid_csv_fp = f"{csv_dir}/bitget_grid_0.csv"
|
|
29
|
-
sf_csv_fp = f"{csv_dir}/bitget_sf_0.csv"
|
|
30
|
-
ff_csv_fp = f"{csv_dir}/bitget_ff_0.csv"
|
|
31
27
|
|
|
32
|
-
asyncio.run(grid_open(engine
|
|
33
|
-
asyncio.run(grid_close(engine
|
|
28
|
+
asyncio.run(grid_open(engine))
|
|
29
|
+
asyncio.run(grid_close(engine))
|
|
34
30
|
|
|
35
|
-
asyncio.run(bitget_sf_open(engine
|
|
36
|
-
asyncio.run(bitget_sf_close(engine
|
|
31
|
+
asyncio.run(bitget_sf_open(engine))
|
|
32
|
+
asyncio.run(bitget_sf_close(engine))
|
|
37
33
|
|
|
38
|
-
asyncio.run(bitget_ff_open(engine
|
|
39
|
-
asyncio.run(bitget_ff_pending(engine
|
|
40
|
-
asyncio.run(bitget_ff_close(engine
|
|
34
|
+
asyncio.run(bitget_ff_open(engine))
|
|
35
|
+
asyncio.run(bitget_ff_pending(engine))
|
|
36
|
+
asyncio.run(bitget_ff_close(engine))
|
|
41
37
|
|
|
42
38
|
|
|
43
39
|
@app.command()
|
|
@@ -21,10 +21,8 @@ app = typer.Typer()
|
|
|
21
21
|
@app.command()
|
|
22
22
|
def rsync(
|
|
23
23
|
env_path: str = "d:/.env",
|
|
24
|
-
csv_dir: str = "d:/github/meme2046/data",
|
|
25
24
|
):
|
|
26
|
-
"""同步mysql中grid数据到
|
|
25
|
+
"""同步mysql中grid数据到redis"""
|
|
27
26
|
engine = get_database_engine(env_path)
|
|
28
|
-
|
|
29
|
-
asyncio.run(
|
|
30
|
-
asyncio.run(grid_close(engine, grid_csv_fp))
|
|
27
|
+
asyncio.run(grid_open(engine))
|
|
28
|
+
asyncio.run(grid_close(engine))
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
from utils.logger import get_logger
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
app = typer.Typer()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.command()
|
|
13
|
+
def info(
|
|
14
|
+
dotenv_path: str = typer.Argument(
|
|
15
|
+
help="dotenv file contains TELE_SEESION_STRING", default="d:/.env"
|
|
16
|
+
),
|
|
17
|
+
):
|
|
18
|
+
load_dotenv("d:/.env")
|
|
19
|
+
logger.info(os.getenv("TELE_SESSION_STRING"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def check(
|
|
24
|
+
dotenv_path: str = typer.Argument(
|
|
25
|
+
help="dotenv file contains TELE_SESSION_STRING / TELE_API_ID / TELE_API_HASH",
|
|
26
|
+
default="d:/.env",
|
|
27
|
+
),
|
|
28
|
+
):
|
|
29
|
+
"""验证 TELE_SESSION_STRING 是否有效。
|
|
30
|
+
|
|
31
|
+
通过 StringSession 建立客户端并发起一次 get_me 调用:
|
|
32
|
+
能正常返回账号对象即视为 session 有效;get_me 在 auth_key
|
|
33
|
+
未授权 / 被注销时会内部捕获 UnauthorizedError 并返回 None,
|
|
34
|
+
故 me is None 即判定 session 失效。
|
|
35
|
+
需要 .env 中同时配置 TELE_API_ID 与 TELE_API_HASH。
|
|
36
|
+
"""
|
|
37
|
+
load_dotenv(dotenv_path)
|
|
38
|
+
|
|
39
|
+
session_string = os.getenv("TELE_SESSION_STRING")
|
|
40
|
+
api_id = os.getenv("TELE_API_ID")
|
|
41
|
+
api_hash = os.getenv("TELE_API_HASH")
|
|
42
|
+
if not session_string:
|
|
43
|
+
logger.error("TELE_SESSION_STRING 未找到: %s", dotenv_path)
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
if not (api_id and api_hash):
|
|
46
|
+
logger.error("TELE_API_ID / TELE_API_HASH 未找到: %s", dotenv_path)
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
|
|
49
|
+
# 延迟导入:仅在使用 check 子命令时才加载 telethon,避免拖慢其它子命令
|
|
50
|
+
import asyncio
|
|
51
|
+
from typing import Awaitable, cast
|
|
52
|
+
|
|
53
|
+
from telethon import TelegramClient
|
|
54
|
+
from telethon.sessions import StringSession
|
|
55
|
+
|
|
56
|
+
async def _verify():
|
|
57
|
+
client = TelegramClient(StringSession(session_string), int(api_id), api_hash)
|
|
58
|
+
try:
|
|
59
|
+
await client.connect()
|
|
60
|
+
# get_me 会真正发起一次 API 调用,能返回即说明 auth_key 仍然有效
|
|
61
|
+
return await client.get_me()
|
|
62
|
+
finally:
|
|
63
|
+
# telethon 的 disconnect 声明为同步 def,但 loop 运行时实际返回协程,
|
|
64
|
+
# Pylance 据签名推断返回 None 导致 await 报错,用 cast 显式标注
|
|
65
|
+
await cast(Awaitable[None], client.disconnect())
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
me = asyncio.run(_verify())
|
|
69
|
+
except Exception as e:
|
|
70
|
+
# 网络异常等非鉴权问题,打印完整堆栈便于排查
|
|
71
|
+
logger.exception("验证 session 时发生异常: %s", e)
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
|
|
74
|
+
# get_me 内部 catch UnauthorizedError(含 AuthKeyUnregistered/Invalid 等所有子类)后返回 None,
|
|
75
|
+
# 所以 me is None 才是 auth_key 已被服务器拒绝 / session 失效的真实信号
|
|
76
|
+
if me is None:
|
|
77
|
+
logger.error("session 无效: auth_key 未授权或已被服务器注销")
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
|
|
80
|
+
logger.info(
|
|
81
|
+
"session 有效: id=%s name=%s phone=%s",
|
|
82
|
+
getattr(me, "id", None), getattr(me, "first_name", None), getattr(me, "phone", None),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.command()
|
|
87
|
+
def login(
|
|
88
|
+
dotenv_path: str = typer.Argument(
|
|
89
|
+
help="dotenv file containing TELE_API_ID / TELE_API_HASH",
|
|
90
|
+
default="d:/.env",
|
|
91
|
+
),
|
|
92
|
+
):
|
|
93
|
+
"""交互式登录并打印 TELE_SESSION_STRING。
|
|
94
|
+
|
|
95
|
+
使用 telethon 的 start() 完成 phone → 验证码 → 2FA 的交互式登录,
|
|
96
|
+
登录成功后用 StringSession.save() 取出可复用 session 串并打印。
|
|
97
|
+
session string 失效周期很长,手动复制到 .env 即可,不做自动回写。
|
|
98
|
+
需要 .env 中已配置 TELE_API_ID 与 TELE_API_HASH。
|
|
99
|
+
"""
|
|
100
|
+
load_dotenv(dotenv_path)
|
|
101
|
+
api_id = os.getenv("TELE_API_ID")
|
|
102
|
+
api_hash = os.getenv("TELE_API_HASH")
|
|
103
|
+
if not (api_id and api_hash):
|
|
104
|
+
logger.error("TELE_API_ID / TELE_API_HASH 未找到: %s", dotenv_path)
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
# 延迟导入:仅在使用 login 子命令时才加载 telethon
|
|
108
|
+
import asyncio
|
|
109
|
+
from typing import Awaitable, cast
|
|
110
|
+
|
|
111
|
+
from telethon import TelegramClient
|
|
112
|
+
from telethon.sessions import StringSession
|
|
113
|
+
|
|
114
|
+
async def _login():
|
|
115
|
+
# 空 StringSession + start() 触发交互式登录(控制台输入 phone / 验证码 / 2FA 密码)
|
|
116
|
+
session = StringSession()
|
|
117
|
+
client = TelegramClient(session, int(api_id), api_hash)
|
|
118
|
+
try:
|
|
119
|
+
# start() 同 disconnect 一样声明为同步 def,loop 运行时返回协程,用 cast 标注
|
|
120
|
+
await cast(Awaitable[None], client.start())
|
|
121
|
+
# telethon 在 connect 时原地写入 session.auth_key,故持有的 session 引用即登录后状态;
|
|
122
|
+
# 直接用 session.save() 而非 client.session,避开 client.session 宽类型推断的 .save() 报错
|
|
123
|
+
return session.save()
|
|
124
|
+
finally:
|
|
125
|
+
await cast(Awaitable[None], client.disconnect())
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
session_string = asyncio.run(_login())
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.exception("生成 session string 时发生异常: %s", e)
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
|
|
133
|
+
# 只打印,不自动回写 .env:session string 失效周期长,手动加一次即可,避免覆盖风险
|
|
134
|
+
typer.secho("TELE_SESSION_STRING 生成成功,请复制下行并手动写入 .env:", fg=typer.colors.GREEN)
|
|
135
|
+
typer.secho(session_string, fg=typer.colors.CYAN)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def upload(
|
|
140
|
+
image_path: str = typer.Argument(
|
|
141
|
+
help="要上传的图片文件路径",
|
|
142
|
+
),
|
|
143
|
+
dotenv_path: str = typer.Option(
|
|
144
|
+
help="dotenv file path", default="d:/.env"
|
|
145
|
+
),
|
|
146
|
+
):
|
|
147
|
+
"""上传图片到指定 Telegram 频道。
|
|
148
|
+
|
|
149
|
+
使用 TELE_SESSION_STRING 登录并上传图片到 TELE_ONEDAOSHARE 环境变量指定的频道。
|
|
150
|
+
"""
|
|
151
|
+
load_dotenv(dotenv_path)
|
|
152
|
+
|
|
153
|
+
session_string = os.getenv("TELE_SESSION_STRING")
|
|
154
|
+
api_id = os.getenv("TELE_API_ID")
|
|
155
|
+
api_hash = os.getenv("TELE_API_HASH")
|
|
156
|
+
channel = os.getenv("TELE_ONEDAOSHARE")
|
|
157
|
+
|
|
158
|
+
if not session_string:
|
|
159
|
+
logger.error("TELE_SESSION_STRING 未找到: %s", dotenv_path)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
if not (api_id and api_hash):
|
|
162
|
+
logger.error("TELE_API_ID / TELE_API_HASH 未找到: %s", dotenv_path)
|
|
163
|
+
raise typer.Exit(1)
|
|
164
|
+
if not channel:
|
|
165
|
+
logger.error("TELE_ONEDAOSHARE 未找到: %s", dotenv_path)
|
|
166
|
+
raise typer.Exit(1)
|
|
167
|
+
if not os.path.isfile(image_path):
|
|
168
|
+
logger.error("图片文件不存在: %s", image_path)
|
|
169
|
+
raise typer.Exit(1)
|
|
170
|
+
|
|
171
|
+
import asyncio
|
|
172
|
+
from typing import Awaitable, cast
|
|
173
|
+
|
|
174
|
+
from telethon import TelegramClient
|
|
175
|
+
from telethon.sessions import StringSession
|
|
176
|
+
|
|
177
|
+
async def _upload():
|
|
178
|
+
client = TelegramClient(StringSession(session_string), int(api_id), api_hash)
|
|
179
|
+
try:
|
|
180
|
+
await client.connect()
|
|
181
|
+
if not await client.is_user_authorized():
|
|
182
|
+
logger.error("session 无效或已失效,请先运行 login 命令重新生成")
|
|
183
|
+
raise typer.Exit(1)
|
|
184
|
+
|
|
185
|
+
# 规范化频道 ID:如果是纯数字,自动加 -100 前缀
|
|
186
|
+
target = channel.strip()
|
|
187
|
+
if target.isdigit():
|
|
188
|
+
target = f"-100{target}"
|
|
189
|
+
logger.info("频道 ID 已自动规范化为: %s", target)
|
|
190
|
+
|
|
191
|
+
target_id = int(target) if target.lstrip('-').isdigit() else None
|
|
192
|
+
|
|
193
|
+
# 方法1:先尝试直接通过 ID 在 dialogs 缓存中查找(更可靠)
|
|
194
|
+
logger.info("正在查找频道: %s", target)
|
|
195
|
+
found_dialog = None
|
|
196
|
+
async for dialog in client.iter_dialogs():
|
|
197
|
+
if dialog.id == target_id:
|
|
198
|
+
found_dialog = dialog
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
if found_dialog:
|
|
202
|
+
logger.info("成功找到频道: %s (ID: %s)", target, found_dialog.id)
|
|
203
|
+
# 使用 dialog.entity 作为目标,避免再次解析
|
|
204
|
+
target = found_dialog.entity
|
|
205
|
+
else:
|
|
206
|
+
# 方法2:退回到 get_entity(支持 @username 等)
|
|
207
|
+
logger.info("缓存中未找到,尝试通过 get_entity 解析: %s", target)
|
|
208
|
+
try:
|
|
209
|
+
entity = await client.get_entity(target)
|
|
210
|
+
if isinstance(entity, list):
|
|
211
|
+
entity = entity[0]
|
|
212
|
+
logger.info("成功解析: %s (ID: %s)", target, getattr(entity, 'id', 'N/A'))
|
|
213
|
+
target = entity
|
|
214
|
+
except ValueError as e:
|
|
215
|
+
logger.error(
|
|
216
|
+
"无法访问频道 %s。请检查:\n"
|
|
217
|
+
"1. ID 是否正确(纯数字需要带 -100 前缀)\n"
|
|
218
|
+
"2. 当前登录的账号是否为该频道成员\n"
|
|
219
|
+
"3. 如果是私有名片,请使用频道的 @username\n"
|
|
220
|
+
"错误详情: %s", target, e
|
|
221
|
+
)
|
|
222
|
+
raise typer.Exit(1)
|
|
223
|
+
|
|
224
|
+
# 发送文件到频道,force_document=False 表示作为图片发送(Telegram 会自动压缩)
|
|
225
|
+
# 如果想原样发送文件,设置 force_document=True
|
|
226
|
+
result = await client.send_file(
|
|
227
|
+
target,
|
|
228
|
+
image_path,
|
|
229
|
+
caption="", # 可以自定义描述
|
|
230
|
+
force_document=False,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# send_file 可能返回 Message 或 List[Message]
|
|
234
|
+
message = result[0] if isinstance(result, list) else result
|
|
235
|
+
|
|
236
|
+
# 下载媒体文件到本地临时目录
|
|
237
|
+
download_link = None
|
|
238
|
+
try:
|
|
239
|
+
if hasattr(message, 'media') and message.media:
|
|
240
|
+
import tempfile
|
|
241
|
+
temp_dir = tempfile.gettempdir()
|
|
242
|
+
local_path = await client.download_media(
|
|
243
|
+
message,
|
|
244
|
+
file=temp_dir
|
|
245
|
+
)
|
|
246
|
+
if local_path:
|
|
247
|
+
download_link = local_path
|
|
248
|
+
except Exception as e:
|
|
249
|
+
logger.debug("下载文件失败: %s", e)
|
|
250
|
+
|
|
251
|
+
return message, download_link
|
|
252
|
+
finally:
|
|
253
|
+
await cast(Awaitable[None], client.disconnect())
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
message, download_link = asyncio.run(_upload())
|
|
257
|
+
|
|
258
|
+
# 打印详细的上传响应信息
|
|
259
|
+
typer.secho("\n📤 上传成功!", fg=typer.colors.GREEN, bold=True)
|
|
260
|
+
typer.secho("-" * 50, fg=typer.colors.CYAN)
|
|
261
|
+
typer.secho(f" 消息 ID: {message.id}", fg=typer.colors.WHITE)
|
|
262
|
+
typer.secho(f" 频道: {channel}", fg=typer.colors.WHITE)
|
|
263
|
+
|
|
264
|
+
# 尝试获取文件信息
|
|
265
|
+
if hasattr(message, 'media') and message.media:
|
|
266
|
+
media = message.media
|
|
267
|
+
# 递归打印完整结构(类似 JSON 格式)
|
|
268
|
+
typer.secho(f" Media 类型: {type(media).__name__}", fg=typer.colors.YELLOW)
|
|
269
|
+
|
|
270
|
+
# 打印下载的本地文件路径(telesco.pe 公网链接 telethon 无法直接获取)
|
|
271
|
+
if download_link:
|
|
272
|
+
typer.secho(f"\n � 本地下载路径:", fg=typer.colors.MAGENTA, bold=True)
|
|
273
|
+
typer.secho(f" {download_link}", fg=typer.colors.MAGENTA)
|
|
274
|
+
|
|
275
|
+
# 打印消息链接(如果可能)
|
|
276
|
+
if hasattr(message, 'chat') and message.chat:
|
|
277
|
+
chat_id = getattr(message.chat, 'id', None)
|
|
278
|
+
if chat_id:
|
|
279
|
+
typer.secho(f" 聊天 ID: {chat_id}", fg=typer.colors.WHITE)
|
|
280
|
+
|
|
281
|
+
typer.secho("-" * 50, fg=typer.colors.CYAN)
|
|
282
|
+
except typer.Exit:
|
|
283
|
+
raise # 重新抛出 typer.Exit 以保持退出码
|
|
284
|
+
except Exception as e:
|
|
285
|
+
logger.exception("上传图片时发生异常: %s", e)
|
|
286
|
+
raise typer.Exit(1)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command()
|
|
290
|
+
def list_channels(
|
|
291
|
+
dotenv_path: str = typer.Option(
|
|
292
|
+
help="dotenv file path", default="d:/.env"
|
|
293
|
+
),
|
|
294
|
+
):
|
|
295
|
+
"""列出当前账号加入的所有频道及其 ID。
|
|
296
|
+
|
|
297
|
+
方便获取正确的频道 ID 填入 TELE_ONE_DAO_SHARE。
|
|
298
|
+
"""
|
|
299
|
+
load_dotenv(dotenv_path)
|
|
300
|
+
|
|
301
|
+
session_string = os.getenv("TELE_SESSION_STRING")
|
|
302
|
+
api_id = os.getenv("TELE_API_ID")
|
|
303
|
+
api_hash = os.getenv("TELE_API_HASH")
|
|
304
|
+
|
|
305
|
+
if not session_string:
|
|
306
|
+
logger.error("TELE_SESSION_STRING 未找到: %s", dotenv_path)
|
|
307
|
+
raise typer.Exit(1)
|
|
308
|
+
if not (api_id and api_hash):
|
|
309
|
+
logger.error("TELE_API_ID / TELE_API_HASH 未找到: %s", dotenv_path)
|
|
310
|
+
raise typer.Exit(1)
|
|
311
|
+
|
|
312
|
+
import asyncio
|
|
313
|
+
from typing import Awaitable, cast
|
|
314
|
+
|
|
315
|
+
from telethon import TelegramClient
|
|
316
|
+
from telethon.sessions import StringSession
|
|
317
|
+
|
|
318
|
+
async def _list():
|
|
319
|
+
client = TelegramClient(StringSession(session_string), int(api_id), api_hash)
|
|
320
|
+
try:
|
|
321
|
+
await client.connect()
|
|
322
|
+
if not await client.is_user_authorized():
|
|
323
|
+
logger.error("session 无效或已失效,请先运行 login 命令重新生成")
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
|
|
326
|
+
channels = []
|
|
327
|
+
async for dialog in client.iter_dialogs():
|
|
328
|
+
if dialog.is_channel:
|
|
329
|
+
channels.append(dialog)
|
|
330
|
+
return channels
|
|
331
|
+
finally:
|
|
332
|
+
await cast(Awaitable[None], client.disconnect())
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
channels = asyncio.run(_list())
|
|
336
|
+
if not channels:
|
|
337
|
+
typer.secho("你还没有加入任何频道", fg=typer.colors.YELLOW)
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
typer.secho(f"\n你加入的 {len(channels)} 个频道:\n", fg=typer.colors.GREEN)
|
|
341
|
+
typer.secho(f"{'频道名称':<40} {'ID':<25}", fg=typer.colors.CYAN)
|
|
342
|
+
typer.secho("-" * 65, fg=typer.colors.CYAN)
|
|
343
|
+
for ch in channels:
|
|
344
|
+
name = ch.name or "(无名称)"
|
|
345
|
+
typer.secho(f"{name:<40} {ch.id:<25}")
|
|
346
|
+
typer.secho("\n提示:复制 ID(如 -100xxxxxxxx)到 .env 的 TELE_ONEDAOSHARE", fg=typer.colors.YELLOW)
|
|
347
|
+
except typer.Exit:
|
|
348
|
+
raise
|
|
349
|
+
except Exception as e:
|
|
350
|
+
logger.exception("列出频道时发生异常: %s", e)
|
|
351
|
+
raise typer.Exit(1)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
logger.info("main")
|
|
@@ -4,7 +4,7 @@ import requests
|
|
|
4
4
|
from sqlalchemy import Engine
|
|
5
5
|
|
|
6
6
|
from utils import logger
|
|
7
|
-
from utils.mysql import mysql_to_csv,
|
|
7
|
+
from utils.mysql import mysql_to_csv, mysql_to_redis
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def tickers(url: str, symbols: list, proxy: str | None = None):
|
|
@@ -47,15 +47,14 @@ def spot_tickers(symbols: list, proxy: str | None = None):
|
|
|
47
47
|
tickers(url, symbols, proxy)
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
async def bitget_sf_open(engine: Engine
|
|
50
|
+
async def bitget_sf_open(engine: Engine):
|
|
51
51
|
query = "select * from bitget_sf where spot_open_usdt is not null and futures_open_usdt is not null and pnl is null and up_status = 0 and deleted_at is null;"
|
|
52
52
|
key_prefix = "bitget_sf"
|
|
53
53
|
table = "bitget_sf"
|
|
54
54
|
|
|
55
|
-
row_count = await
|
|
55
|
+
row_count = await mysql_to_redis(
|
|
56
56
|
engine,
|
|
57
57
|
key_prefix,
|
|
58
|
-
csv_fp,
|
|
59
58
|
table,
|
|
60
59
|
query,
|
|
61
60
|
update_status=1,
|
|
@@ -76,15 +75,14 @@ async def bitget_sf_open(engine: Engine, csv_fp: str):
|
|
|
76
75
|
logger.info(f"🚀 bitget SF open count:({row_count})")
|
|
77
76
|
|
|
78
77
|
|
|
79
|
-
async def bitget_ff_open(engine: Engine
|
|
78
|
+
async def bitget_ff_open(engine: Engine):
|
|
80
79
|
key_prefix = "bitget_ff"
|
|
81
80
|
table = "bitget_ff"
|
|
82
81
|
query = f"select * from {table} where long_open_usdt is not null and short_open_usdt is not null and pnl is null and up_status = 0 and deleted_at is null;"
|
|
83
82
|
|
|
84
|
-
row_count = await
|
|
83
|
+
row_count = await mysql_to_redis(
|
|
85
84
|
engine,
|
|
86
85
|
key_prefix,
|
|
87
|
-
csv_fp,
|
|
88
86
|
table,
|
|
89
87
|
query,
|
|
90
88
|
update_status=1,
|
|
@@ -105,15 +103,14 @@ async def bitget_ff_open(engine: Engine, csv_fp: str):
|
|
|
105
103
|
logger.info(f"💰 bitget FF open count:({row_count})")
|
|
106
104
|
|
|
107
105
|
|
|
108
|
-
async def bitget_sf_close(engine: Engine
|
|
106
|
+
async def bitget_sf_close(engine: Engine):
|
|
109
107
|
query = "select * from bitget_sf where pnl is not null and up_status in (0,1);"
|
|
110
108
|
key_prefix = "bitget_sf"
|
|
111
109
|
table = "bitget_sf"
|
|
112
110
|
|
|
113
|
-
row_count = await
|
|
111
|
+
row_count = await mysql_to_redis(
|
|
114
112
|
engine,
|
|
115
113
|
key_prefix,
|
|
116
|
-
csv_path,
|
|
117
114
|
table,
|
|
118
115
|
query,
|
|
119
116
|
update_status=2,
|
|
@@ -134,15 +131,14 @@ async def bitget_sf_close(engine: Engine, csv_path: str):
|
|
|
134
131
|
logger.info(f"🚀 bitget SF close count:({row_count})")
|
|
135
132
|
|
|
136
133
|
|
|
137
|
-
async def bitget_ff_pending(engine: Engine
|
|
134
|
+
async def bitget_ff_pending(engine: Engine):
|
|
138
135
|
key_prefix = "bitget_ff"
|
|
139
136
|
table = "bitget_ff"
|
|
140
137
|
query = f"select * from {table} where pnl is null and (long_close_at is not null or short_close_at is not null) and up_status in (1);"
|
|
141
138
|
|
|
142
|
-
row_count = await
|
|
139
|
+
row_count = await mysql_to_redis(
|
|
143
140
|
engine,
|
|
144
141
|
key_prefix,
|
|
145
|
-
csv_path,
|
|
146
142
|
table,
|
|
147
143
|
query,
|
|
148
144
|
update_status=2,
|
|
@@ -163,15 +159,14 @@ async def bitget_ff_pending(engine: Engine, csv_path: str):
|
|
|
163
159
|
logger.info(f"💰 bitget FF pending count:({row_count})")
|
|
164
160
|
|
|
165
161
|
|
|
166
|
-
async def bitget_ff_close(engine: Engine
|
|
162
|
+
async def bitget_ff_close(engine: Engine):
|
|
167
163
|
key_prefix = "bitget_ff"
|
|
168
164
|
table = "bitget_ff"
|
|
169
165
|
query = f"select * from {table} where pnl is not null and up_status in (0,1,2);"
|
|
170
166
|
|
|
171
|
-
row_count = await
|
|
167
|
+
row_count = await mysql_to_redis(
|
|
172
168
|
engine,
|
|
173
169
|
key_prefix,
|
|
174
|
-
csv_path,
|
|
175
170
|
table,
|
|
176
171
|
query,
|
|
177
172
|
update_status=3,
|
|
@@ -192,15 +187,14 @@ async def bitget_ff_close(engine: Engine, csv_path: str):
|
|
|
192
187
|
logger.info(f"💰 bitget FF close count:({row_count})")
|
|
193
188
|
|
|
194
189
|
|
|
195
|
-
async def grid_open(engine: Engine
|
|
190
|
+
async def grid_open(engine: Engine):
|
|
196
191
|
query = "select * from bitget where ((cost is not null or benefit is not null) and profit is null) and up_status = 0 and order_id is not null and deleted_at is null;"
|
|
197
192
|
key_prefix = "bitget_grid"
|
|
198
193
|
table = "bitget"
|
|
199
194
|
|
|
200
|
-
row_count = await
|
|
195
|
+
row_count = await mysql_to_redis(
|
|
201
196
|
engine,
|
|
202
197
|
key_prefix,
|
|
203
|
-
csv_path,
|
|
204
198
|
table,
|
|
205
199
|
query,
|
|
206
200
|
update_status=1,
|
|
@@ -217,15 +211,14 @@ async def grid_open(engine: Engine, csv_path: str):
|
|
|
217
211
|
logger.info(f"🧮 bitget grid open count:({row_count})")
|
|
218
212
|
|
|
219
213
|
|
|
220
|
-
async def grid_close(engine: Engine
|
|
214
|
+
async def grid_close(engine: Engine):
|
|
221
215
|
query = "select * from bitget where profit is not null and up_status in (0,1) and deleted_at is null;"
|
|
222
216
|
key_prefix = "bitget_grid"
|
|
223
217
|
table = "bitget"
|
|
224
218
|
|
|
225
|
-
row_count = await
|
|
219
|
+
row_count = await mysql_to_redis(
|
|
226
220
|
engine,
|
|
227
221
|
key_prefix,
|
|
228
|
-
csv_path,
|
|
229
222
|
table,
|
|
230
223
|
query,
|
|
231
224
|
update_status=2,
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
from sqlalchemy import Engine
|
|
2
2
|
|
|
3
3
|
from utils import logger
|
|
4
|
-
from utils.mysql import mysql_to_csv,
|
|
4
|
+
from utils.mysql import mysql_to_csv, mysql_to_redis
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
async def grid_open(engine: Engine
|
|
7
|
+
async def grid_open(engine: Engine):
|
|
8
8
|
query = "select * from gate where ((cost is not null or benefit is not null) and profit is null) and up_status = 0 and order_id is not null and deleted_at is null;"
|
|
9
9
|
key_prefix = "gate_grid"
|
|
10
10
|
table = "gate"
|
|
11
11
|
|
|
12
|
-
row_count = await
|
|
12
|
+
row_count = await mysql_to_redis(
|
|
13
13
|
engine,
|
|
14
14
|
key_prefix,
|
|
15
|
-
csv_path,
|
|
16
15
|
table,
|
|
17
16
|
query,
|
|
18
17
|
update_status=1,
|
|
@@ -29,15 +28,14 @@ async def grid_open(engine: Engine, csv_path: str):
|
|
|
29
28
|
logger.info(f"🧮 gate open count:({row_count})")
|
|
30
29
|
|
|
31
30
|
|
|
32
|
-
async def grid_close(engine: Engine
|
|
31
|
+
async def grid_close(engine: Engine):
|
|
33
32
|
query = "select * from gate where profit is not null and up_status in (0,1) and deleted_at is null;"
|
|
34
33
|
key_prefix = "gate_grid"
|
|
35
34
|
table = "gate"
|
|
36
35
|
|
|
37
|
-
row_count = await
|
|
36
|
+
row_count = await mysql_to_redis(
|
|
38
37
|
engine,
|
|
39
38
|
key_prefix,
|
|
40
|
-
csv_path,
|
|
41
39
|
table,
|
|
42
40
|
query,
|
|
43
41
|
update_status=2,
|
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
"pydantic-settings>=2.14.2",
|
|
30
30
|
"netifaces-plus>=0.12.5",
|
|
31
31
|
"ifaddr>=0.2.0",
|
|
32
|
+
"telethon>=1.44.0",
|
|
32
33
|
]
|
|
33
34
|
description = "My CLI"
|
|
34
35
|
license = { text = "MIT" }
|
|
35
36
|
name = "pymecli"
|
|
36
37
|
readme = "README.md"
|
|
37
38
|
requires-python = ">=3.10"
|
|
38
|
-
version = "0.5.
|
|
39
|
+
version = "0.5.4"
|
|
39
40
|
|
|
40
41
|
[project.urls]
|
|
41
42
|
Homepage = "https://pypi.org/project/pymecli"
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
util = "cli.util:app"
|
|
52
53
|
# 临时命令
|
|
53
54
|
rd = "cli.redis_csv:app"
|
|
55
|
+
# tele = "cli.tele:app"
|
|
54
56
|
|
|
55
57
|
[tool.uv]
|
|
56
58
|
[[tool.uv.index]]
|
|
@@ -70,22 +70,7 @@ def mysql_to_csv(
|
|
|
70
70
|
# csv去重,保留最后加入的数据
|
|
71
71
|
deduplicated(csv_path, d_column_names, "last", pd_dtype)
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
if ids:
|
|
75
|
-
# 使用 text() 构建查询时,确保 :ids 是一个列表
|
|
76
|
-
update_query = text(
|
|
77
|
-
f"UPDATE {table} SET up_status = :status WHERE id IN ({','.join(map(str, ids))});"
|
|
78
|
-
)
|
|
79
|
-
with engine.connect() as connection:
|
|
80
|
-
with connection.begin():
|
|
81
|
-
result = connection.execute(
|
|
82
|
-
update_query,
|
|
83
|
-
{"status": update_status},
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
return result.rowcount
|
|
87
|
-
|
|
88
|
-
return 0
|
|
73
|
+
return _update_status(engine, table, ids, update_status)
|
|
89
74
|
|
|
90
75
|
|
|
91
76
|
async def mysql_to_redis_and_csv(
|
|
@@ -185,9 +170,68 @@ async def mysql_to_redis_and_csv(
|
|
|
185
170
|
|
|
186
171
|
logger.debug(f"𝄜 to csv: {count}")
|
|
187
172
|
|
|
188
|
-
|
|
173
|
+
return _update_status(engine, table, ids, update_status)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def mysql_to_redis(
|
|
177
|
+
engine: Engine,
|
|
178
|
+
key_prefix: str,
|
|
179
|
+
table: str,
|
|
180
|
+
query: str,
|
|
181
|
+
update_status: int,
|
|
182
|
+
d_column_names: list[str],
|
|
183
|
+
pd_dtype: dict | None = None,
|
|
184
|
+
del_column_names: list[str] = ["id", "created_at", "updated_at", "deleted_at"],
|
|
185
|
+
) -> int:
|
|
186
|
+
df = pd.read_sql(query, engine, dtype=pd_dtype)
|
|
187
|
+
df["open_at"] = df["open_at"].fillna(df["created_at"])
|
|
188
|
+
ids = df["id"].tolist()
|
|
189
|
+
columns_to_drop = [col for col in del_column_names if col in df.columns]
|
|
190
|
+
df = df.drop(columns=columns_to_drop)
|
|
191
|
+
|
|
192
|
+
datetime_cols = [
|
|
193
|
+
"open_at",
|
|
194
|
+
"close_at",
|
|
195
|
+
"spot_close_at",
|
|
196
|
+
"futures_close_at",
|
|
197
|
+
"long_close_at",
|
|
198
|
+
"short_close_at",
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
logger.debug(df.head())
|
|
202
|
+
logger.debug(df.dtypes)
|
|
203
|
+
|
|
204
|
+
for col in datetime_cols:
|
|
205
|
+
if col in df.columns:
|
|
206
|
+
df[col] = dt_to_timestamp(df[col])
|
|
207
|
+
|
|
208
|
+
r = get_redis_client()
|
|
209
|
+
pipe = r.pipeline()
|
|
210
|
+
count = 0
|
|
211
|
+
n1, n2 = d_column_names
|
|
212
|
+
|
|
213
|
+
for _, row in df.iterrows():
|
|
214
|
+
idx1 = row[n1]
|
|
215
|
+
idx2 = row[n2]
|
|
216
|
+
if not idx1 or not idx2:
|
|
217
|
+
raise ValueError("ERR:id行无效")
|
|
218
|
+
id = f"{idx1}_{idx2}"
|
|
219
|
+
key = f"{key_prefix}:{id}"
|
|
220
|
+
|
|
221
|
+
row_dict = row.where(pd.notna(row), "").to_dict()
|
|
222
|
+
|
|
223
|
+
pipe.hset(key, mapping=row_dict)
|
|
224
|
+
pipe.zadd(f"by_time:{key_prefix}", {id: time.time()})
|
|
225
|
+
count += 1
|
|
226
|
+
|
|
227
|
+
await pipe.execute()
|
|
228
|
+
logger.debug(f"🧱 to redis: {count}")
|
|
229
|
+
|
|
230
|
+
return _update_status(engine, table, ids, update_status)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _update_status(engine: Engine, table: str, ids: list, update_status: int) -> int:
|
|
189
234
|
if ids:
|
|
190
|
-
# 使用 text() 构建查询时,确保 :ids 是一个列表
|
|
191
235
|
update_query = text(
|
|
192
236
|
f"UPDATE {table} SET up_status = :status WHERE id IN ({','.join(map(str, ids))});"
|
|
193
237
|
)
|
|
@@ -197,7 +241,5 @@ async def mysql_to_redis_and_csv(
|
|
|
197
241
|
update_query,
|
|
198
242
|
{"status": update_status},
|
|
199
243
|
)
|
|
200
|
-
|
|
201
244
|
return result.rowcount
|
|
202
|
-
|
|
203
245
|
return 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|