ryry-cli 6.17__tar.gz → 6.19__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.
Files changed (33) hide show
  1. {ryry_cli-6.17/ryry_cli.egg-info → ryry_cli-6.19}/PKG-INFO +1 -3
  2. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/constant.py +2 -2
  3. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/ryry_webapi.py +0 -10
  4. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/shared_memory.py +18 -0
  5. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/store.py +1 -1
  6. ryry_cli-6.19/ryry/taskUtils.py +496 -0
  7. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/upload.py +191 -46
  8. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/utils.py +219 -217
  9. {ryry_cli-6.17 → ryry_cli-6.19/ryry_cli.egg-info}/PKG-INFO +1 -3
  10. {ryry_cli-6.17 → ryry_cli-6.19}/ryry_cli.egg-info/requires.txt +0 -3
  11. {ryry_cli-6.17 → ryry_cli-6.19}/setup.py +1 -2
  12. ryry_cli-6.17/ryry/taskUtils.py +0 -304
  13. {ryry_cli-6.17 → ryry_cli-6.19}/LICENSE +0 -0
  14. {ryry_cli-6.17 → ryry_cli-6.19}/README.md +0 -0
  15. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/__init__.py +0 -0
  16. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/daemon_base.py +0 -0
  17. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/daemon_manager.py +0 -0
  18. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/main.py +0 -0
  19. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/proxy_manager.py +0 -0
  20. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/ryry_server_socket.py +0 -0
  21. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/ryry_service.py +0 -0
  22. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/ryry_widget.py +0 -0
  23. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/script_template/__init__.py +0 -0
  24. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/script_template/daemon.py +0 -0
  25. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/script_template/main.py +0 -0
  26. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/script_template/run.py +0 -0
  27. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/server_func.py +0 -0
  28. {ryry_cli-6.17 → ryry_cli-6.19}/ryry/task.py +0 -0
  29. {ryry_cli-6.17 → ryry_cli-6.19}/ryry_cli.egg-info/SOURCES.txt +0 -0
  30. {ryry_cli-6.17 → ryry_cli-6.19}/ryry_cli.egg-info/dependency_links.txt +0 -0
  31. {ryry_cli-6.17 → ryry_cli-6.19}/ryry_cli.egg-info/entry_points.txt +0 -0
  32. {ryry_cli-6.17 → ryry_cli-6.19}/ryry_cli.egg-info/top_level.txt +0 -0
  33. {ryry_cli-6.17 → ryry_cli-6.19}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ryry-cli
3
- Version: 6.17
3
+ Version: 6.19
4
4
  Summary: ryry tools
5
5
  Home-page: https://github.com/dalipenMedia
6
6
  Author: dalipen
@@ -24,8 +24,6 @@ Requires-Dist: urlparser
24
24
  Requires-Dist: urllib3
25
25
  Requires-Dist: portalocker
26
26
  Requires-Dist: PyYAML
27
- Provides-Extra: with-mecord
28
- Requires-Dist: mecord-cli>=0.7.407; extra == "with-mecord"
29
27
 
30
28
  ryry Python Tool
31
29
  ===============================================
@@ -1,6 +1,6 @@
1
1
  #!!!!! do not change this file !!!!!
2
- app_version="6.17"
3
- app_bulld_anchor="Noh_2026-07-02 15:26:27.184223"
2
+ app_version="6.19"
3
+ app_bulld_anchor="Noh_2026-07-09 01:20:20.729214"
4
4
  app_name="ryry-cli"
5
5
  import sys, os
6
6
  if getattr(sys, 'frozen', False):
@@ -311,16 +311,6 @@ def upload(localFilePath, ext, keepItAlways=False, needTranscode=False, addition
311
311
  return _upload(localFilePath, ext, keepItAlways=keepItAlways)
312
312
  except:
313
313
  pass
314
- from mecord import upload as mecord_upload
315
- try:
316
- url = mecord_upload.upload(localFilePath,
317
- None,
318
- "sg",
319
- needTranscode=needTranscode,
320
- needAddtionUrl=True) #mecord会使用签名信息,强制needAddtionUrl
321
- return _changeDomain(url, "m.mecordai.com")
322
- except:
323
- pass
324
314
  def upload_oss(localFilePath, ext, bucket, endpoint):
325
315
  import hashlib
326
316
  import hmac
@@ -78,6 +78,23 @@ class SharedMemoryService:
78
78
  return 0o666
79
79
  return 0o600
80
80
 
81
+ def _path_permission_info(self, path: str) -> str:
82
+ try:
83
+ st = os.stat(path)
84
+ return f"mode={oct(st.st_mode & 0o7777)} uid={st.st_uid} gid={st.st_gid}"
85
+ except OSError as e:
86
+ return f"stat failed: {e}"
87
+
88
+ def _assert_shared_dir_writable(self):
89
+ if os.access(self.data_dir, os.W_OK | os.X_OK):
90
+ return
91
+ raise PermissionError(
92
+ f"shared memory dir is not writable: {self.data_dir} "
93
+ f"({self._path_permission_info(self.data_dir)}). "
94
+ f"fix: sudo chown -R $(id -u):$(id -g) {self.data_dir} "
95
+ f"or sudo chmod 1777 {self.data_dir}"
96
+ )
97
+
81
98
  def _prepare_existing_shared_file(self, path: str):
82
99
  if os.path.isdir(path):
83
100
  raise PermissionError(f"shared memory file path is a directory: {path}")
@@ -98,6 +115,7 @@ class SharedMemoryService:
98
115
  """确保固定共享目录和锁文件可写"""
99
116
  os.makedirs(self.data_dir, mode=self._shared_dir_mode(), exist_ok=True)
100
117
  self._chmod_if_possible(self.data_dir, self._shared_dir_mode())
118
+ self._assert_shared_dir_writable()
101
119
  self._touch_shared_file(self.lock_file)
102
120
  self._touch_shared_file(self.process_count_lock_file)
103
121
  self._prepare_existing_shared_file(self.data_file)
@@ -177,7 +177,7 @@ def extend():
177
177
  _genExtend()
178
178
  elif read_data["extend"].get("device_id", "") != read_data.get("deviceInfo", {}).get("device_id", ""):
179
179
  _genExtend()
180
- elif read_data["extend"].get("app", "") == "ryry-cli 6.17":
180
+ elif read_data["extend"].get("app", "") == "ryry-cli 6.19":
181
181
  _genExtend()
182
182
  elif any(k not in read_data["extend"] for k in ["device_id", "host_name", "cpu", "memory", "disk", "gpu"]):
183
183
  _genExtend()
@@ -0,0 +1,496 @@
1
+ import os
2
+ import requests
3
+ import datetime
4
+ import json
5
+ import socket
6
+ from io import BytesIO
7
+ from requests_toolbelt import MultipartEncoder
8
+ from urllib import parse
9
+ import base64
10
+ import calendar, time
11
+ import hashlib
12
+ from contextlib import contextmanager
13
+ from threading import RLock
14
+ from ryry import utils, constant
15
+
16
+ try:
17
+ import fcntl
18
+ except ImportError:
19
+ fcntl = None
20
+
21
+ # 获取版本信息的兼容函数
22
+ def get_version():
23
+ try:
24
+ from importlib.metadata import version
25
+ return version("ryry-cli")
26
+ except ImportError:
27
+ from importlib_metadata import version
28
+ return version("ryry-cli")
29
+
30
+ task_config_file = os.path.join(constant.base_path, f"task_config.txt")
31
+ def taskInfoWithFirstTask():
32
+ if os.path.exists(task_config_file):
33
+ with open(task_config_file, 'r', encoding='utf-8') as f:
34
+ data = json.load(f)
35
+ for it in data:
36
+ if it not in ["last_task_pts"]:
37
+ return it, data[it]
38
+ return None, None
39
+ def taskInfoWithUUID(taskUUID):
40
+ if os.path.exists(task_config_file):
41
+ with open(task_config_file, 'r', encoding='utf-8') as f:
42
+ task_config = json.load(f)
43
+ if taskUUID in task_config:
44
+ return task_config[taskUUID]
45
+ return None
46
+
47
+ WECHAT_URL = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=1b006560-7012-4bf0-8c5e-a23830bc0a0b"
48
+ def uploadFile2Wechat(filepath):
49
+ real_robot_url = WECHAT_URL
50
+ params = parse.parse_qs( parse.urlparse( real_robot_url ).query )
51
+ webHookKey=params['key'][0]
52
+ upload_url = f'https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={webHookKey}&type=file'
53
+ headers = {"Accept": "application/json, text/plain, */*", "Accept-Encoding": "gzip, deflate",
54
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.100 Safari/537.36"}
55
+ filename = os.path.basename(filepath)
56
+ try:
57
+ multipart = MultipartEncoder(
58
+ fields={'filename': filename, 'filelength': '', 'name': 'media', 'media': (filename, open(filepath, 'rb'), 'application/octet-stream')},
59
+ boundary='-------------------------acebdf13572468')
60
+ headers['Content-Type'] = multipart.content_type
61
+ resp = requests.post(upload_url, headers=headers, data=multipart, timeout=300)
62
+ json_res = resp.json()
63
+ if json_res.get('media_id'):
64
+ return json_res.get('media_id')
65
+ except Exception as e:
66
+ return ""
67
+ def notifyWechatRobot(param):
68
+ real_robot_url = WECHAT_URL
69
+ try:
70
+ s = requests.session()
71
+ s.headers.update({'Connection':'close'})
72
+ headers = dict()
73
+ headers['Content-Type'] = "application/json"
74
+ res = s.post(real_robot_url, json.dumps(param), headers=headers, verify=False, timeout=30)
75
+ s.close()
76
+ except Exception as e:
77
+ print(f"===== qyapi.weixin.qq.com fail ", True)
78
+
79
+ logs = {}
80
+ def taskPrint(taskUUID, msg):
81
+ global logs
82
+ if (taskUUID == None or len(taskUUID) == 0) and msg == None:
83
+ return
84
+ if taskUUID and msg == None:
85
+ del logs[taskUUID]
86
+ return
87
+ if taskUUID and msg:
88
+ if taskUUID not in logs:
89
+ logs[taskUUID] = []
90
+ logs[taskUUID].append(msg)
91
+ if taskUUID == None and msg:
92
+ for uuid in logs:
93
+ logs[uuid].append(msg)
94
+ print(msg)
95
+ def getTaskLog(taskUUID):
96
+ if taskUUID in logs:
97
+ return logs[taskUUID]
98
+ return []
99
+
100
+ def _uploadLog(taskUUID):
101
+ log_path = f"{constant.base_path}/log_{taskUUID}.log"
102
+ try:
103
+ with open(log_path, 'w', encoding='utf-8') as f:
104
+ f.write("\n".join(getTaskLog(taskUUID)))
105
+ notifyWechatRobot({
106
+ "msgtype": "file",
107
+ "file": {
108
+ "media_id": uploadFile2Wechat(log_path)
109
+ }
110
+ })
111
+ except:
112
+ pass
113
+ finally:
114
+ if os.path.exists(log_path):
115
+ os.remove(log_path)
116
+
117
+ def notifyTaskFail(taskUUID, reason):
118
+ try:
119
+ real_reason = ""
120
+ if len(reason) > 2010:
121
+ real_reason = f"{reason[0:1000]}\n...\n{reason[len(reason)-1000:]}"
122
+ else:
123
+ real_reason = reason
124
+ notifyWechatRobot({
125
+ "msgtype": "markdown",
126
+ "markdown": {
127
+ "content": f"机器<<font color=\"warning\">{socket.gethostname()}</font>> 执行任务<{taskUUID}>失败\n<{real_reason}>"
128
+ }
129
+ })
130
+ _uploadLog(taskUUID)
131
+ except:
132
+ pass
133
+
134
+ def notifyServerError(taskUUID):
135
+ try:
136
+ notifyWechatRobot({
137
+ "msgtype": "markdown",
138
+ "markdown": {
139
+ "content": f"机器<<font color=\"warning\">{socket.gethostname()}</font>> 执行任务<{taskUUID}>上报失败, retry..."
140
+ }
141
+ })
142
+ except:
143
+ pass
144
+
145
+ def notifyScriptError(taskUUID):
146
+ try:
147
+ notifyWechatRobot({
148
+ "msgtype": "markdown",
149
+ "markdown": {
150
+ "content": f"机器<<font color=\"warning\">{socket.gethostname()}</font>> 执行任务<{taskUUID}>异常"
151
+ }
152
+ })
153
+ _uploadLog(taskUUID)
154
+ except:
155
+ pass
156
+
157
+ def idlingNotify(cnt):
158
+ device_id = utils.generate_unique_id()
159
+ machine_name = socket.gethostname()
160
+ hour = int(float(cnt)/(60.0*60.0))
161
+ if hour<72:
162
+ if hour not in [1, 2, 3, 10, 30, 50, 70]:
163
+ return
164
+ notifyWechatRobot({
165
+ "msgtype": "text",
166
+ "text": {
167
+ "content": f"机器<{machine_name}[{device_id}]> 空转{hour}小时"
168
+ }
169
+ })
170
+
171
+ def onlineNotify():
172
+ device_id = utils.generate_unique_id()
173
+ machine_name = socket.gethostname()
174
+ ver = get_version()
175
+ notifyWechatRobot({
176
+ "msgtype": "text",
177
+ "text": {
178
+ "content": f"机器<{machine_name}[{device_id}]>[{ver}] 上线"
179
+ }
180
+ })
181
+
182
+ def restartNotify(msg):
183
+ device_id = utils.generate_unique_id()
184
+ machine_name = socket.gethostname()
185
+ notifyWechatRobot({
186
+ "msgtype": "text",
187
+ "text": {
188
+ "content": f"机器<{machine_name}[{device_id}]> 即将下线,原因:{msg}"
189
+ }
190
+ })
191
+
192
+ def offlineNotify():
193
+ device_id = utils.generate_unique_id()
194
+ machine_name = socket.gethostname()
195
+ ver = get_version()
196
+ notifyWechatRobot({
197
+ "msgtype": "text",
198
+ "text": {
199
+ "content": f"机器<{machine_name}[{device_id}]> 下线"
200
+ }
201
+ })
202
+
203
+ #===================================================== counter ===============================================#
204
+ task_counter_file = os.path.join(constant.base_path, "task_counter.txt")
205
+ task_counter_lock_file = os.path.join(constant.base_path, "task_counter.lock")
206
+ counter_lock = RLock()
207
+
208
+ @contextmanager
209
+ def counterFileLock():
210
+ with counter_lock:
211
+ lock_fp = open(task_counter_lock_file, 'a+', encoding='utf-8')
212
+ try:
213
+ if fcntl:
214
+ fcntl.flock(lock_fp.fileno(), fcntl.LOCK_EX)
215
+ yield
216
+ finally:
217
+ if fcntl:
218
+ fcntl.flock(lock_fp.fileno(), fcntl.LOCK_UN)
219
+ lock_fp.close()
220
+
221
+ def _readCounterData():
222
+ if os.path.exists(task_counter_file) == False:
223
+ return {}
224
+ try:
225
+ with open(task_counter_file, 'r', encoding='utf-8') as f:
226
+ data = json.load(f)
227
+ if isinstance(data, dict):
228
+ return data
229
+ except:
230
+ pass
231
+ return {}
232
+
233
+ def _writeCounterData(data):
234
+ tmp_file = f"{task_counter_file}.tmp"
235
+ with open(tmp_file, 'w', encoding='utf-8') as f:
236
+ json.dump(data, f)
237
+ os.replace(tmp_file, task_counter_file)
238
+
239
+ def _hourKeys(data):
240
+ return [str(i) for i in range(0, 24) if str(i) in data and isinstance(data[str(i)], dict)]
241
+
242
+ def _resetCounterData(counter_date, last_report_date=None):
243
+ data = {
244
+ "_counter_date": counter_date
245
+ }
246
+ if last_report_date:
247
+ data["_last_report_date"] = last_report_date
248
+ return data
249
+
250
+ def _counterReportStats(data):
251
+ s_cnt = 0
252
+ f_cnt = 0
253
+ all_day_usage = 0
254
+ s_l = []
255
+ f_l = []
256
+ usage_l = []
257
+ for i in range(0,24):
258
+ usage = 0
259
+ if str(i) in data:
260
+ s_cnt += data[str(i)]["success"]
261
+ f_cnt += data[str(i)]["fail"]
262
+ s_l.append(data[str(i)]["success"])
263
+ f_l.append(data[str(i)]["fail"])
264
+ if "usage" in data[str(i)]:
265
+ usage = data[str(i)]["usage"]
266
+ all_day_usage += usage
267
+ else:
268
+ s_cnt += 0
269
+ f_cnt += 0
270
+ s_l.append(0)
271
+ f_l.append(0)
272
+ usage_l.append(int((float(usage)/float(60*60))*100) if usage > 0 else 0)
273
+ usage_percentage = int((float(all_day_usage)/float(24*60*60))*100)
274
+ return {
275
+ "success": s_cnt,
276
+ "fail": f_cnt,
277
+ "total": s_cnt + f_cnt,
278
+ "usage": usage_percentage,
279
+ "hour_success": s_l,
280
+ "hour_fail": f_l,
281
+ "hour_usage": usage_l
282
+ }
283
+
284
+ def _sendCounterMarkdownReport(report_date, stats):
285
+ notifyWechatRobot({
286
+ "msgtype": "markdown",
287
+ "markdown": {
288
+ "content": f"机器<<font color=\"warning\">{socket.gethostname()}</font>> {report_date} 日报 \n\n\
289
+ >过去24小时执行任务<<font color=\"warning\">{stats['total']}</font>>个, 负载<<font color=\"warning\">{stats['usage']}%</font>> \n\
290
+ >成功<font color=\"warning\">{stats['success']}</font>个 \n\
291
+ >失败<font color=\"warning\">{stats['fail']}</font>个"
292
+ }
293
+ })
294
+
295
+ def _reportFont(size, bold=False):
296
+ try:
297
+ from PIL import ImageFont
298
+ font_paths = [
299
+ "/System/Library/Fonts/STHeiti Medium.ttc" if bold else "/System/Library/Fonts/STHeiti Light.ttc",
300
+ "/System/Library/Fonts/PingFang.ttc",
301
+ "/Library/Fonts/Arial Unicode.ttf",
302
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
303
+ "/usr/share/fonts/opentype/noto/NotoSansCJKSC-Regular.otf",
304
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
305
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
306
+ "C:/Windows/Fonts/msyhbd.ttc" if bold else "C:/Windows/Fonts/msyh.ttc",
307
+ ]
308
+ for font_path in font_paths:
309
+ if font_path and os.path.exists(font_path):
310
+ return ImageFont.truetype(font_path, size)
311
+ return ImageFont.load_default()
312
+ except:
313
+ return None
314
+
315
+ def _text(draw, xy, text, fill, font=None, **kwargs):
316
+ try:
317
+ draw.text(xy, text, fill=fill, font=font, **kwargs)
318
+ except TypeError:
319
+ kwargs.pop("anchor", None)
320
+ draw.text(xy, text, fill=fill, font=font, **kwargs)
321
+ except UnicodeEncodeError:
322
+ safe_text = text.encode("ascii", "ignore").decode("ascii")
323
+ try:
324
+ draw.text(xy, safe_text, fill=fill, font=font, **kwargs)
325
+ except TypeError:
326
+ kwargs.pop("anchor", None)
327
+ draw.text(xy, safe_text, fill=fill, font=font, **kwargs)
328
+
329
+ def _roundedRectangle(draw, box, radius, fill, outline=None):
330
+ if hasattr(draw, "rounded_rectangle"):
331
+ draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline)
332
+ else:
333
+ draw.rectangle(box, fill=fill, outline=outline)
334
+
335
+ def _counterReportImageBytes(report_date, stats):
336
+ from PIL import Image, ImageDraw
337
+
338
+ width = 1120
339
+ height = 700
340
+ margin = 54
341
+ bg = "#f6f8fb"
342
+ ink = "#172033"
343
+ muted = "#667085"
344
+ grid = "#d8dee8"
345
+ green = "#34a853"
346
+ red = "#ea4335"
347
+ blue = "#1a73e8"
348
+
349
+ img = Image.new("RGB", (width, height), bg)
350
+ draw = ImageDraw.Draw(img)
351
+ title_font = _reportFont(36, True)
352
+ card_font = _reportFont(22, True)
353
+ text_font = _reportFont(18)
354
+ small_font = _reportFont(14)
355
+
356
+ host = socket.gethostname()
357
+ _text(draw, (margin, 38), f"机器<{host}> {report_date} 日报", ink, title_font)
358
+ _text(draw, (margin, 88), "每日任务执行概览", muted, text_font)
359
+
360
+ cards = [
361
+ ("执行任务", stats["total"], blue),
362
+ ("成功", stats["success"], green),
363
+ ("失败", stats["fail"], red),
364
+ ("负载", f"{stats['usage']}%", "#f29900"),
365
+ ]
366
+ card_y = 126
367
+ card_w = 238
368
+ card_h = 92
369
+ gap = 18
370
+ for idx, (label, value, color) in enumerate(cards):
371
+ x = margin + idx * (card_w + gap)
372
+ _roundedRectangle(draw, (x, card_y, x + card_w, card_y + card_h), 16, "#ffffff", "#e4e8f0")
373
+ _text(draw, (x + 22, card_y + 18), label, muted, text_font)
374
+ _text(draw, (x + 22, card_y + 46), str(value), color, card_font)
375
+
376
+ chart_left = margin
377
+ chart_top = 272
378
+ chart_right = width - margin
379
+ chart_bottom = height - 86
380
+ chart_w = chart_right - chart_left
381
+ _roundedRectangle(draw, (chart_left - 16, chart_top - 26, chart_right + 16, chart_bottom + 48), 18, "#ffffff", "#e4e8f0")
382
+ _text(draw, (chart_left, chart_top - 6), "24小时任务分布", ink, card_font)
383
+ _text(draw, (chart_right - 220, chart_top - 2), "绿色=成功 红色=失败", muted, small_font)
384
+
385
+ plot_top = chart_top + 48
386
+ plot_bottom = chart_bottom - 36
387
+ plot_h = plot_bottom - plot_top
388
+ max_count = max([s + f for s, f in zip(stats["hour_success"], stats["hour_fail"])] + [1])
389
+ for i in range(0, 5):
390
+ y = plot_bottom - int(plot_h * i / 4)
391
+ draw.line((chart_left, y, chart_right, y), fill=grid, width=1)
392
+ tick = int(max_count * i / 4)
393
+ _text(draw, (chart_left - 36, y - 8), str(tick), muted, small_font)
394
+
395
+ slot = chart_w / 24.0
396
+ bar_w = max(12, int(slot * 0.54))
397
+ for hour in range(0, 24):
398
+ success = stats["hour_success"][hour]
399
+ fail = stats["hour_fail"][hour]
400
+ total = success + fail
401
+ x_center = chart_left + slot * hour + slot / 2
402
+ x0 = int(x_center - bar_w / 2)
403
+ x1 = int(x_center + bar_w / 2)
404
+ total_h = int((float(total) / max_count) * plot_h) if total > 0 else 0
405
+ y_total = plot_bottom - total_h
406
+ fail_h = int((float(fail) / max(total, 1)) * total_h) if total > 0 else 0
407
+ y_fail = y_total + fail_h
408
+ if success > 0:
409
+ draw.rectangle((x0, y_fail, x1, plot_bottom), fill=green)
410
+ if fail > 0:
411
+ draw.rectangle((x0, y_total, x1, y_fail), fill=red)
412
+ if total > 0:
413
+ _text(draw, (x_center, y_total - 18), str(total), ink, small_font, anchor="mm")
414
+ if hour % 2 == 0:
415
+ _text(draw, (x_center, plot_bottom + 18), str(hour), muted, small_font, anchor="mm")
416
+ if stats["hour_usage"][hour] > 0 and hour % 3 == 0:
417
+ _text(draw, (x_center, plot_bottom + 38), f"{stats['hour_usage'][hour]}%", muted, small_font, anchor="mm")
418
+
419
+ _text(draw, (chart_left, height - 50), "柱顶数字为任务数;底部百分比为该小时任务累计忙时。", muted, small_font)
420
+ output = BytesIO()
421
+ img.save(output, format="PNG", optimize=True)
422
+ return output.getvalue()
423
+
424
+ def _sendCounterImageReport(report_date, stats):
425
+ image_bytes = _counterReportImageBytes(report_date, stats)
426
+ md5 = hashlib.md5()
427
+ md5.update(image_bytes)
428
+ notifyWechatRobot({
429
+ "msgtype": "image",
430
+ "image": {
431
+ "base64": str(base64.b64encode(image_bytes), encoding='utf-8'),
432
+ "md5": md5.hexdigest()
433
+ }
434
+ })
435
+
436
+ def _notifyCounterWithData(data, report_date):
437
+ stats = _counterReportStats(data)
438
+ try:
439
+ _sendCounterImageReport(report_date, stats)
440
+ except:
441
+ _sendCounterMarkdownReport(report_date, stats)
442
+
443
+ def _rolloverCounterIfNeed(data, now):
444
+ today = now.strftime('%Y-%m-%d')
445
+ hour_keys = _hourKeys(data)
446
+ if "_counter_date" not in data:
447
+ # Legacy counters have no date and may already contain stale totals.
448
+ # Start a fresh dated counter so an old file cannot pollute tomorrow's report.
449
+ return _resetCounterData(today), True
450
+ counter_date = data.get("_counter_date")
451
+ if counter_date == today:
452
+ return data, False
453
+ if len(hour_keys) == 0:
454
+ return _resetCounterData(today, data.get("_last_report_date")), True
455
+ if data.get("_last_report_date") != counter_date:
456
+ _notifyCounterWithData(data, counter_date)
457
+ return _resetCounterData(today, counter_date), True
458
+
459
+ def notifyCounterIfNeed():
460
+ try:
461
+ with counterFileLock():
462
+ data = _readCounterData()
463
+ data, changed = _rolloverCounterIfNeed(data, datetime.datetime.now())
464
+ if changed:
465
+ _writeCounterData(data)
466
+ except:
467
+ pass
468
+
469
+ def saveCounter(taskUUID, duration, isSuccess):
470
+ try:
471
+ with counterFileLock():
472
+ now = datetime.datetime.now()
473
+ data = _readCounterData()
474
+ data, _ = _rolloverCounterIfNeed(data, now)
475
+ if "_counter_date" not in data:
476
+ data["_counter_date"] = now.strftime('%Y-%m-%d')
477
+ #update
478
+ now_hour = str(now.hour)
479
+ if now_hour in data:
480
+ if isSuccess:
481
+ data[now_hour]["success"] += 1
482
+ else:
483
+ data[now_hour]["fail"] += 1
484
+ if "usage" not in data[now_hour]:
485
+ data[now_hour]["usage"] = 0
486
+ data[now_hour]["usage"] += duration
487
+ else:
488
+ data[now_hour] = {
489
+ "success" : 1 if isSuccess else 0,
490
+ "fail" : 0 if isSuccess else 1,
491
+ "usage" : duration
492
+ }
493
+ #save
494
+ _writeCounterData(data)
495
+ except:
496
+ pass