qrpa 1.0.27__tar.gz → 1.0.29__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.
Potentially problematic release.
This version of qrpa might be problematic. Click here for more details.
- {qrpa-1.0.27 → qrpa-1.0.29}/PKG-INFO +1 -1
- {qrpa-1.0.27 → qrpa-1.0.29}/pyproject.toml +1 -1
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/__init__.py +1 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/feishu_bot_app.py +2 -2
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/fun_excel.py +105 -37
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/shein_excel.py +52 -14
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/temu_excel.py +40 -13
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/wxwork.py +318 -318
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa.egg-info/PKG-INFO +1 -1
- {qrpa-1.0.27 → qrpa-1.0.29}/README.md +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/RateLimitedSender.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/db_migrator.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/fun_base.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/fun_file.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/fun_web.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/fun_win.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/shein_daily_report_model.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/shein_lib.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/shein_sqlite.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/shein_ziniao.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/temu_chrome.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/temu_lib.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/time_utils.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa/time_utils_example.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa.egg-info/SOURCES.txt +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa.egg-info/dependency_links.txt +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/qrpa.egg-info/top_level.txt +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/setup.cfg +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/setup.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/tests/test_db_migrator.py +0 -0
- {qrpa-1.0.27 → qrpa-1.0.29}/tests/test_wxwork.py +0 -0
|
@@ -26,8 +26,8 @@ class FeishuBot:
|
|
|
26
26
|
"""获取飞书客户端,使用懒加载模式"""
|
|
27
27
|
if self._client is None:
|
|
28
28
|
self._client = lark.Client.builder() \
|
|
29
|
-
.app_id(self.config.
|
|
30
|
-
.app_secret(self.config.
|
|
29
|
+
.app_id(self.config.feishu_bot.app_id) \
|
|
30
|
+
.app_secret(self.config.feishu_bot.app_secret) \
|
|
31
31
|
.log_level(lark.LogLevel.INFO) \
|
|
32
32
|
.build()
|
|
33
33
|
return self._client
|
|
@@ -282,6 +282,7 @@ def delete_sheet_if_exists(wb, sheet_name):
|
|
|
282
282
|
sheet_names = [s.name for s in wb.sheets]
|
|
283
283
|
if sheet_name in sheet_names:
|
|
284
284
|
wb.sheets[sheet_name].delete()
|
|
285
|
+
wb.save()
|
|
285
286
|
print(f"已删除 Sheet: {sheet_name}")
|
|
286
287
|
else:
|
|
287
288
|
print(f"Sheet 不存在: {sheet_name}")
|
|
@@ -1871,18 +1872,18 @@ def format_to_text_v2_safe(sheet, columns=None, data_rows=None):
|
|
|
1871
1872
|
"""
|
|
1872
1873
|
if columns is None or len(columns) == 0:
|
|
1873
1874
|
return
|
|
1874
|
-
|
|
1875
|
+
|
|
1875
1876
|
# 确保columns是列表
|
|
1876
1877
|
if not isinstance(columns, list):
|
|
1877
1878
|
columns = [columns]
|
|
1878
|
-
|
|
1879
|
+
|
|
1879
1880
|
for col_name in columns:
|
|
1880
1881
|
try:
|
|
1881
1882
|
if isinstance(col_name, int):
|
|
1882
1883
|
col_name = xw.utils.col_name(col_name)
|
|
1883
|
-
|
|
1884
|
+
|
|
1884
1885
|
log(f'安全设置[{col_name}] 文本格式')
|
|
1885
|
-
|
|
1886
|
+
|
|
1886
1887
|
# 如果指定了数据行数,只格式化有数据的范围
|
|
1887
1888
|
if data_rows and data_rows > 0:
|
|
1888
1889
|
# 格式化从第1行到数据行数的范围
|
|
@@ -1899,7 +1900,7 @@ def format_to_text_v2_safe(sheet, columns=None, data_rows=None):
|
|
|
1899
1900
|
log(f'列 {col_name} 没有数据,跳过格式化')
|
|
1900
1901
|
except:
|
|
1901
1902
|
log(f'列 {col_name} 格式化失败,跳过')
|
|
1902
|
-
|
|
1903
|
+
|
|
1903
1904
|
except Exception as e:
|
|
1904
1905
|
log(f'设置列 {col_name} 文本格式失败: {e},继续处理其他列')
|
|
1905
1906
|
|
|
@@ -1914,14 +1915,14 @@ def pre_format_columns_safe(sheet, columns, data_rows):
|
|
|
1914
1915
|
"""
|
|
1915
1916
|
if not columns or not isinstance(columns, list):
|
|
1916
1917
|
return
|
|
1917
|
-
|
|
1918
|
+
|
|
1918
1919
|
for col_name in columns:
|
|
1919
1920
|
try:
|
|
1920
1921
|
if isinstance(col_name, int):
|
|
1921
1922
|
col_name = xw.utils.col_name(col_name)
|
|
1922
|
-
|
|
1923
|
+
|
|
1923
1924
|
log(f'预格式化列 [{col_name}] 为文本格式')
|
|
1924
|
-
|
|
1925
|
+
|
|
1925
1926
|
# 方法1:先创建最小范围,避免整列操作
|
|
1926
1927
|
try:
|
|
1927
1928
|
# 创建足够大的范围来覆盖预期数据
|
|
@@ -1930,7 +1931,7 @@ def pre_format_columns_safe(sheet, columns, data_rows):
|
|
|
1930
1931
|
log(f'预格式化成功: {range_str}')
|
|
1931
1932
|
except Exception as e1:
|
|
1932
1933
|
log(f'预格式化方法1失败: {e1}')
|
|
1933
|
-
|
|
1934
|
+
|
|
1934
1935
|
# 方法2:逐行设置格式,更安全但稍慢
|
|
1935
1936
|
try:
|
|
1936
1937
|
for row in range(1, data_rows + 1):
|
|
@@ -1939,7 +1940,7 @@ def pre_format_columns_safe(sheet, columns, data_rows):
|
|
|
1939
1940
|
log(f'逐行预格式化成功: {col_name}')
|
|
1940
1941
|
except Exception as e2:
|
|
1941
1942
|
log(f'逐行预格式化也失败: {e2}')
|
|
1942
|
-
|
|
1943
|
+
|
|
1943
1944
|
except Exception as e:
|
|
1944
1945
|
log(f'预格式化列 {col_name} 失败: {e},继续处理其他列')
|
|
1945
1946
|
|
|
@@ -1954,20 +1955,20 @@ def post_format_columns_safe(sheet, columns, data_rows):
|
|
|
1954
1955
|
"""
|
|
1955
1956
|
if not columns or not isinstance(columns, list):
|
|
1956
1957
|
return
|
|
1957
|
-
|
|
1958
|
+
|
|
1958
1959
|
for col_name in columns:
|
|
1959
1960
|
try:
|
|
1960
1961
|
if isinstance(col_name, int):
|
|
1961
1962
|
col_name = xw.utils.col_name(col_name)
|
|
1962
|
-
|
|
1963
|
+
|
|
1963
1964
|
log(f'后格式化列 [{col_name}] 为文本格式')
|
|
1964
|
-
|
|
1965
|
+
|
|
1965
1966
|
# 只对实际有数据的行进行格式化
|
|
1966
1967
|
if data_rows > 0:
|
|
1967
1968
|
range_str = f'{col_name}1:{col_name}{data_rows}'
|
|
1968
1969
|
sheet.range(range_str).number_format = '@'
|
|
1969
1970
|
log(f'后格式化成功: {range_str}')
|
|
1970
|
-
|
|
1971
|
+
|
|
1971
1972
|
except Exception as e:
|
|
1972
1973
|
log(f'后格式化列 {col_name} 失败: {e},继续处理其他列')
|
|
1973
1974
|
|
|
@@ -2545,18 +2546,77 @@ def format_excel_with_lock(excel_path, sheet_name, format_func, *args, **kwargs)
|
|
|
2545
2546
|
# 经过观察 fortmat时 传入函数需要为类函数且第二个参数必须是 sheet
|
|
2546
2547
|
def batch_excel_operations(excel_path, operations):
|
|
2547
2548
|
"""
|
|
2548
|
-
批量 Excel
|
|
2549
|
-
|
|
2549
|
+
批量 Excel 操作函数,自动分批处理,避免一次操作过多sheet导致Excel COM错误
|
|
2550
|
+
保持操作的原始顺序执行
|
|
2551
|
+
|
|
2550
2552
|
Args:
|
|
2551
2553
|
excel_path: Excel 文件路径
|
|
2552
2554
|
operations: 操作列表,每个操作是 (sheet_name, operation_type, data, format_func) 的元组
|
|
2553
|
-
operation_type: 'write'
|
|
2554
|
-
|
|
2555
|
-
format_func: 格式化函数(仅 format 操作需要)
|
|
2556
|
-
|
|
2555
|
+
operation_type: 'write', 'format', 'delete', 'move', 'active'
|
|
2556
|
+
|
|
2557
2557
|
Returns:
|
|
2558
2558
|
bool: 是否全部操作成功
|
|
2559
2559
|
"""
|
|
2560
|
+
if not operations:
|
|
2561
|
+
return True
|
|
2562
|
+
|
|
2563
|
+
# 批处理大小设置:每批最多处理8个操作
|
|
2564
|
+
MAX_OPERATIONS_PER_BATCH = 8
|
|
2565
|
+
|
|
2566
|
+
try:
|
|
2567
|
+
# 计算需要分几批
|
|
2568
|
+
total_batches = (len(operations) + MAX_OPERATIONS_PER_BATCH - 1) // MAX_OPERATIONS_PER_BATCH
|
|
2569
|
+
log(f"分{total_batches}批执行{len(operations)}个操作,每批最多{MAX_OPERATIONS_PER_BATCH}个,保持原始顺序")
|
|
2570
|
+
|
|
2571
|
+
# 按顺序分批执行
|
|
2572
|
+
for batch_idx in range(total_batches):
|
|
2573
|
+
start_idx = batch_idx * MAX_OPERATIONS_PER_BATCH
|
|
2574
|
+
end_idx = min(start_idx + MAX_OPERATIONS_PER_BATCH, len(operations))
|
|
2575
|
+
batch_operations = operations[start_idx:end_idx]
|
|
2576
|
+
|
|
2577
|
+
log(f"执行第{batch_idx + 1}/{total_batches}批操作({start_idx + 1}-{end_idx}),共{len(batch_operations)}个操作")
|
|
2578
|
+
|
|
2579
|
+
# 重试机制
|
|
2580
|
+
max_retries = 3
|
|
2581
|
+
for retry in range(max_retries):
|
|
2582
|
+
try:
|
|
2583
|
+
# 强制垃圾回收
|
|
2584
|
+
import gc
|
|
2585
|
+
gc.collect()
|
|
2586
|
+
|
|
2587
|
+
if _execute_operations_batch(excel_path, batch_operations):
|
|
2588
|
+
log(f"第{batch_idx + 1}批操作成功")
|
|
2589
|
+
break
|
|
2590
|
+
else:
|
|
2591
|
+
log(f"第{batch_idx + 1}批操作失败,重试 {retry + 1}/{max_retries}")
|
|
2592
|
+
if retry == max_retries - 1:
|
|
2593
|
+
log(f"第{batch_idx + 1}批操作最终失败")
|
|
2594
|
+
return False
|
|
2595
|
+
import time
|
|
2596
|
+
time.sleep(3)
|
|
2597
|
+
except Exception as e:
|
|
2598
|
+
log(f"第{batch_idx + 1}批操作异常: {e}")
|
|
2599
|
+
if retry == max_retries - 1:
|
|
2600
|
+
return False
|
|
2601
|
+
import time
|
|
2602
|
+
time.sleep(3)
|
|
2603
|
+
|
|
2604
|
+
# 批次间延迟
|
|
2605
|
+
if batch_idx < total_batches - 1:
|
|
2606
|
+
import time
|
|
2607
|
+
time.sleep(1)
|
|
2608
|
+
|
|
2609
|
+
log(f"所有批量操作完成: {excel_path}")
|
|
2610
|
+
return True
|
|
2611
|
+
|
|
2612
|
+
except Exception as e:
|
|
2613
|
+
log(f"批量操作过程异常: {e}")
|
|
2614
|
+
return False
|
|
2615
|
+
|
|
2616
|
+
def _execute_operations_batch(excel_path, operations):
|
|
2617
|
+
"""
|
|
2618
|
+
执行单个批次的操作
|
|
2619
|
+
"""
|
|
2560
2620
|
app, wb, sheet = open_excel_with_lock(excel_path)
|
|
2561
2621
|
if not app or not wb:
|
|
2562
2622
|
log(f"无法打开 Excel 文件: {excel_path}")
|
|
@@ -2564,23 +2624,38 @@ def batch_excel_operations(excel_path, operations):
|
|
|
2564
2624
|
|
|
2565
2625
|
try:
|
|
2566
2626
|
for sheet_name, operation_type, *args in operations:
|
|
2567
|
-
#
|
|
2627
|
+
# 根据操作类型决定是否需要获取或创建工作表
|
|
2628
|
+
sheet = None
|
|
2629
|
+
|
|
2630
|
+
# 删除操作不需要获取sheet对象
|
|
2631
|
+
if operation_type == 'delete':
|
|
2632
|
+
log(f'删除sheet: {sheet_name}')
|
|
2633
|
+
delete_sheet_if_exists(wb, sheet_name)
|
|
2634
|
+
continue
|
|
2635
|
+
|
|
2636
|
+
# 其他操作需要获取或创建工作表
|
|
2568
2637
|
if isinstance(sheet_name, str):
|
|
2569
2638
|
sheet_names = [s.name.strip().lower() for s in wb.sheets]
|
|
2570
2639
|
if sheet_name.strip().lower() in sheet_names:
|
|
2571
2640
|
sheet = wb.sheets[sheet_name]
|
|
2572
2641
|
else:
|
|
2573
|
-
sheet
|
|
2642
|
+
# 只有在需要操作sheet内容时才创建
|
|
2643
|
+
if operation_type in ['write', 'format']:
|
|
2644
|
+
sheet = wb.sheets.add(sheet_name, after=wb.sheets[-1])
|
|
2645
|
+
else:
|
|
2646
|
+
log(f"警告: 操作 {operation_type} 需要的sheet {sheet_name} 不存在,跳过此操作")
|
|
2647
|
+
continue
|
|
2574
2648
|
else:
|
|
2575
2649
|
sheet = wb.sheets[sheet_name]
|
|
2576
2650
|
|
|
2577
|
-
sheet
|
|
2651
|
+
if sheet:
|
|
2652
|
+
sheet.activate()
|
|
2578
2653
|
|
|
2579
2654
|
if operation_type == 'write':
|
|
2580
2655
|
data, format_to_text_colunm = args[0], args[1:] if len(args) > 1 else None
|
|
2581
2656
|
# 清空工作表
|
|
2582
2657
|
sheet.clear()
|
|
2583
|
-
|
|
2658
|
+
|
|
2584
2659
|
# 先设置文本格式,再写入数据(确保格式生效)
|
|
2585
2660
|
if format_to_text_colunm and format_to_text_colunm[0]:
|
|
2586
2661
|
try:
|
|
@@ -2588,11 +2663,11 @@ def batch_excel_operations(excel_path, operations):
|
|
|
2588
2663
|
pre_format_columns_safe(sheet, format_to_text_colunm[0], len(data))
|
|
2589
2664
|
except Exception as e:
|
|
2590
2665
|
log(f"预格式化失败: {e},继续执行")
|
|
2591
|
-
|
|
2666
|
+
|
|
2592
2667
|
# 写入数据
|
|
2668
|
+
log(f"批量操作,写入数据到: {sheet_name}")
|
|
2593
2669
|
sheet.range('A1').value = data
|
|
2594
|
-
|
|
2595
|
-
|
|
2670
|
+
|
|
2596
2671
|
# 写入后再次确认格式(双重保险)
|
|
2597
2672
|
if format_to_text_colunm and format_to_text_colunm[0]:
|
|
2598
2673
|
try:
|
|
@@ -2602,31 +2677,24 @@ def batch_excel_operations(excel_path, operations):
|
|
|
2602
2677
|
|
|
2603
2678
|
elif operation_type == 'format':
|
|
2604
2679
|
format_func, format_args = args[0], args[1:] if len(args) > 1 else ()
|
|
2605
|
-
log('格式化入参', *format_args)
|
|
2606
2680
|
# 执行格式化
|
|
2607
2681
|
format_func(sheet, *format_args)
|
|
2608
|
-
log(f"批量操作:格式化工作表 {sheet_name}")
|
|
2609
|
-
|
|
2610
|
-
elif operation_type == 'delete':
|
|
2611
|
-
pass
|
|
2612
|
-
delete_sheet_if_exists(wb, sheet_name)
|
|
2613
2682
|
|
|
2614
2683
|
elif operation_type == 'move':
|
|
2615
|
-
|
|
2684
|
+
log(f'移动sheet: {sheet_name}')
|
|
2616
2685
|
position = args[0]
|
|
2617
2686
|
move_sheet_to_position(wb, sheet_name, position)
|
|
2618
2687
|
|
|
2619
2688
|
elif operation_type == 'active':
|
|
2620
|
-
|
|
2689
|
+
log(f'激活sheet: {sheet_name}')
|
|
2621
2690
|
sheet.activate()
|
|
2622
2691
|
|
|
2623
2692
|
# 保存所有更改
|
|
2624
2693
|
wb.save()
|
|
2625
|
-
log(f"批量操作完成: {excel_path}")
|
|
2626
2694
|
return True
|
|
2627
2695
|
|
|
2628
2696
|
except Exception as e:
|
|
2629
|
-
log(f"
|
|
2697
|
+
log(f"单批次操作失败: {e}")
|
|
2630
2698
|
return False
|
|
2631
2699
|
finally:
|
|
2632
2700
|
# 释放锁但不关闭 Excel(保持复用)
|
|
@@ -1426,6 +1426,13 @@ class SheinExcel:
|
|
|
1426
1426
|
))
|
|
1427
1427
|
batch_excel_operations(excel_path, operations)
|
|
1428
1428
|
|
|
1429
|
+
# 添加月度sheet操作 - 自定义操作函数
|
|
1430
|
+
def write_monthly_data(self, sheet, data, name):
|
|
1431
|
+
# 写入数据到A5位置(月度数据从A列开始)
|
|
1432
|
+
sheet.range('A5').value = data
|
|
1433
|
+
# 设置标题
|
|
1434
|
+
sheet.range('A1').value = f'{name}SHEIN{TimeUtils.get_current_month()}月店铺数据'
|
|
1435
|
+
|
|
1429
1436
|
def write_sales_data(self):
|
|
1430
1437
|
yesterday = TimeUtils.get_yesterday()
|
|
1431
1438
|
model = SheinStoreSalesDetailManager(self.config.database_url)
|
|
@@ -1464,7 +1471,7 @@ class SheinExcel:
|
|
|
1464
1471
|
sheet_name_first = 'SHEIN销售部每日店铺情况'
|
|
1465
1472
|
|
|
1466
1473
|
# 准备批量操作列表
|
|
1467
|
-
|
|
1474
|
+
base_operations = []
|
|
1468
1475
|
|
|
1469
1476
|
# 添加每日汇总sheet的操作 - 自定义操作函数
|
|
1470
1477
|
def write_daily_data(sheet):
|
|
@@ -1475,13 +1482,16 @@ class SheinExcel:
|
|
|
1475
1482
|
# 设置日期和合并
|
|
1476
1483
|
sheet.range('A4').value = f'{TimeUtils.format_date_cross_platform(yesterday)}\n({TimeUtils.get_chinese_weekday(yesterday)})'
|
|
1477
1484
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1485
|
+
base_operations.append((sheet_name_first, 'format', write_daily_data))
|
|
1486
|
+
base_operations.append((sheet_name_first, 'format', self._format_daily_summary_sheet, yesterday, len(data_day)))
|
|
1487
|
+
base_operations.append((sheet_name_first, 'move', 1))
|
|
1488
|
+
base_operations.append(('Sheet1', 'delete'))
|
|
1482
1489
|
|
|
1483
1490
|
# 获取店铺列表并准备月度数据
|
|
1484
1491
|
store_list = model.get_distinct_store_sales_list()
|
|
1492
|
+
|
|
1493
|
+
# 准备所有店铺的数据
|
|
1494
|
+
store_operations_data = []
|
|
1485
1495
|
for store in store_list:
|
|
1486
1496
|
store_username = store[0]
|
|
1487
1497
|
store_name = dict_store_name.get(store_username)
|
|
@@ -1512,18 +1522,22 @@ class SheinExcel:
|
|
|
1512
1522
|
# store_data.append(record.remark) # 月度数据不包含备注列,保持19列
|
|
1513
1523
|
data_month.append(store_data)
|
|
1514
1524
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
# 设置标题
|
|
1520
|
-
sheet.range('A1').value = f'{name}SHEIN{TimeUtils.get_current_month()}月店铺数据'
|
|
1525
|
+
store_operations_data.append((store_name, data_month))
|
|
1526
|
+
|
|
1527
|
+
# 构建所有操作列表
|
|
1528
|
+
operations = base_operations.copy()
|
|
1521
1529
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1530
|
+
# 添加店铺操作
|
|
1531
|
+
for store_name, data_month in store_operations_data:
|
|
1532
|
+
# 清理店铺名称
|
|
1533
|
+
clean_store_name = self._clean_sheet_name(store_name)
|
|
1534
|
+
operations.append((clean_store_name, 'format', self.write_monthly_data, data_month, clean_store_name))
|
|
1535
|
+
operations.append((clean_store_name, 'format', self._format_store_monthly_sheet, clean_store_name, len(data_month)))
|
|
1524
1536
|
|
|
1525
|
-
#
|
|
1537
|
+
# 添加最后激活操作
|
|
1526
1538
|
operations.append((sheet_name_first, 'active'))
|
|
1539
|
+
|
|
1540
|
+
# 执行批量操作(内部会自动分批处理)
|
|
1527
1541
|
success = batch_excel_operations(excel_path, operations)
|
|
1528
1542
|
|
|
1529
1543
|
if success:
|
|
@@ -1533,6 +1547,30 @@ class SheinExcel:
|
|
|
1533
1547
|
else:
|
|
1534
1548
|
log(f"销售数据写入失败: {excel_path}")
|
|
1535
1549
|
|
|
1550
|
+
def _clean_sheet_name(self, name):
|
|
1551
|
+
"""
|
|
1552
|
+
清理工作表名称,移除Excel不支持的字符
|
|
1553
|
+
"""
|
|
1554
|
+
if not name:
|
|
1555
|
+
return "DefaultSheet"
|
|
1556
|
+
|
|
1557
|
+
# Excel工作表名称限制:不能包含 [ ] : * ? / \ 字符,且长度不超过31字符
|
|
1558
|
+
invalid_chars = ['[', ']', ':', '*', '?', '/', '\\']
|
|
1559
|
+
clean_name = name
|
|
1560
|
+
|
|
1561
|
+
for char in invalid_chars:
|
|
1562
|
+
clean_name = clean_name.replace(char, '_')
|
|
1563
|
+
|
|
1564
|
+
# 限制长度为31字符
|
|
1565
|
+
if len(clean_name) > 31:
|
|
1566
|
+
clean_name = clean_name[:28] + "..."
|
|
1567
|
+
|
|
1568
|
+
# 确保不为空
|
|
1569
|
+
if not clean_name.strip():
|
|
1570
|
+
clean_name = "Sheet"
|
|
1571
|
+
|
|
1572
|
+
return clean_name
|
|
1573
|
+
|
|
1536
1574
|
def _format_daily_summary_sheet(self, sheet, yesterday, data_length):
|
|
1537
1575
|
"""格式化每日汇总sheet"""
|
|
1538
1576
|
las_row = data_length + 4 # 数据从第5行开始,4行header
|
|
@@ -3,6 +3,7 @@ from .fun_base import log
|
|
|
3
3
|
from .fun_file import read_dict_from_file, read_dict_from_file_ex, write_dict_to_file, write_dict_to_file_ex, delete_file
|
|
4
4
|
from .time_utils import TimeUtils
|
|
5
5
|
from .wxwork import WxWorkBot
|
|
6
|
+
import os
|
|
6
7
|
|
|
7
8
|
class TemuExcel:
|
|
8
9
|
|
|
@@ -47,9 +48,9 @@ class TemuExcel:
|
|
|
47
48
|
autofit_column(sheet, ['店铺名称', '商品信息'])
|
|
48
49
|
column_to_left(sheet, ['商品信息'])
|
|
49
50
|
InsertImageV2(sheet, ['SKC图片', 'SKU图片'], 'temu', 120)
|
|
50
|
-
if sheet.used_range.rows.count > 330:
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
# if sheet.used_range.rows.count > 330:
|
|
52
|
+
# log('表格数据行数超过了330行,将删除SKC图片')
|
|
53
|
+
# remove_excel_columns(sheet, ['SKC图片'])
|
|
53
54
|
|
|
54
55
|
def write_purchase_advise(self, erp='mb'):
|
|
55
56
|
cache_file = f'{self.config.auto_dir}/temu/cache/warehouse_list_{TimeUtils.today_date()}.json'
|
|
@@ -59,19 +60,23 @@ class TemuExcel:
|
|
|
59
60
|
|
|
60
61
|
header = ['店铺名称', 'SKC图片', 'SKU图片', '商品信息', '现有库存数量', '已采购数量', '近7日销量', '平均日销', '本地和采购可售天数', '生产天数', '建议采购', '产品起定量', '备货周期(天)', 'SKC', '导出时间']
|
|
61
62
|
new_excel_path_list = []
|
|
63
|
+
|
|
62
64
|
for mall_id, subOrderList in dict.items():
|
|
63
65
|
excel_data = []
|
|
64
66
|
mall_name = store_info.get(mall_id)[1]
|
|
67
|
+
|
|
65
68
|
for product in subOrderList:
|
|
66
69
|
spu = str(product['productId']) # temu平台 spu_id
|
|
67
70
|
skc = str(product['productSkcId']) # temu平台 skc_id
|
|
68
71
|
skcExtCode = product['skcExtCode'] # 商家 SKC货号
|
|
69
72
|
category = product['category'] # 叶子类目
|
|
70
73
|
onSalesDurationOffline = product['onSalesDurationOffline'] # 加入站点时长
|
|
74
|
+
|
|
71
75
|
for sku in product['skuQuantityDetailList']:
|
|
72
76
|
priceReviewStatus = sku['priceReviewStatus']
|
|
73
77
|
if priceReviewStatus == 3: # 过滤 开款价格状态 已作废的 2是已生效
|
|
74
78
|
continue
|
|
79
|
+
|
|
75
80
|
mall_info = f'{mall_name}\n{mall_id}'
|
|
76
81
|
productSkcPicture = product['productSkcPicture'] # skc图片
|
|
77
82
|
skuExtCode = str(sku['skuExtCode']) # sku货号
|
|
@@ -98,15 +103,37 @@ class TemuExcel:
|
|
|
98
103
|
row_item.append(TimeUtils.current_datetime())
|
|
99
104
|
excel_data.append(row_item)
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
# 按近7日销量排序
|
|
107
|
+
excel_data = sort_by_column(excel_data, 6, 1)
|
|
108
|
+
|
|
109
|
+
# 计算需要多少个文件(每个文件最多320行数据,包含表头)
|
|
110
|
+
max_data_rows = 320 - 1 # 减去表头行
|
|
111
|
+
total_files = (len(excel_data) + max_data_rows - 1) // max_data_rows # 通过加(max_data_rows-1)实现向上取整
|
|
112
|
+
|
|
113
|
+
for file_index in range(total_files):
|
|
114
|
+
start_idx = file_index * max_data_rows
|
|
115
|
+
end_idx = min((file_index + 1) * max_data_rows, len(excel_data))
|
|
116
|
+
current_data = excel_data[start_idx:end_idx]
|
|
117
|
+
|
|
118
|
+
# 生成文件名,如果超过一个文件则添加序号
|
|
119
|
+
if total_files == 1:
|
|
120
|
+
new_excel_path = str(self.config.excel_purcase_advice_temu).replace('#store_name#', mall_name).replace(' ', '_')
|
|
121
|
+
else:
|
|
122
|
+
# 在文件名后添加 _2, _3 等序号
|
|
123
|
+
base_path = str(self.config.excel_purcase_advice_temu).replace('#store_name#', mall_name).replace(' ', '_')
|
|
124
|
+
file_name, file_ext = os.path.splitext(base_path)
|
|
125
|
+
new_excel_path = f"{file_name}_{file_index + 1}{file_ext}"
|
|
126
|
+
|
|
127
|
+
new_excel_path_list.append(new_excel_path)
|
|
128
|
+
sheet_name = 'Sheet1'
|
|
129
|
+
data = [header] + current_data
|
|
130
|
+
|
|
131
|
+
close_excel_file(new_excel_path)
|
|
132
|
+
log(f"创建文件: {new_excel_path}, 数据行数: {len(current_data)}")
|
|
133
|
+
|
|
134
|
+
batch_excel_operations(new_excel_path, [
|
|
135
|
+
(sheet_name, 'write', data, ['N']),
|
|
136
|
+
(sheet_name, 'format', self.format_purchase_advise_batch)
|
|
137
|
+
])
|
|
111
138
|
|
|
112
139
|
return new_excel_path_list
|
|
@@ -1,318 +1,318 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
-------------------------------------------------
|
|
4
|
-
@version : v1.0
|
|
5
|
-
@author : qsir
|
|
6
|
-
@contact : qsir@vxnote.com
|
|
7
|
-
@software : PyCharm
|
|
8
|
-
@filename : wxwork.py
|
|
9
|
-
@create time: 2025/08/03
|
|
10
|
-
@modify time: 2025/08/03
|
|
11
|
-
@describe :
|
|
12
|
-
-------------------------------------------------
|
|
13
|
-
"""
|
|
14
|
-
import json
|
|
15
|
-
import os
|
|
16
|
-
import hashlib
|
|
17
|
-
import base64
|
|
18
|
-
import requests
|
|
19
|
-
from requests_toolbelt import MultipartEncoder
|
|
20
|
-
from datetime import datetime
|
|
21
|
-
|
|
22
|
-
# 通过企微群机器人发送消息
|
|
23
|
-
class WxWorkBot:
|
|
24
|
-
def __init__(self, key):
|
|
25
|
-
self.key = key
|
|
26
|
-
|
|
27
|
-
def upload_media(self, filepath):
|
|
28
|
-
"""
|
|
29
|
-
上传临时素材,给企微群里发文件消息时需要先将文件上传至企微临时素材中
|
|
30
|
-
:param filepath:
|
|
31
|
-
:return: 临时素材的media_id
|
|
32
|
-
"""
|
|
33
|
-
try:
|
|
34
|
-
headers = {
|
|
35
|
-
'Content-Type': 'multipart/form-data',
|
|
36
|
-
}
|
|
37
|
-
with open(filepath, 'rb') as f:
|
|
38
|
-
files = {
|
|
39
|
-
'media': (os.path.basename(filepath), f.read())
|
|
40
|
-
}
|
|
41
|
-
response = requests.post(
|
|
42
|
-
f'https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={self.key}&type=file',
|
|
43
|
-
headers=headers, files=files)
|
|
44
|
-
response_text = json.loads(response.text)
|
|
45
|
-
if str(response_text.get('errcode')) != '0':
|
|
46
|
-
raise Exception(response_text)
|
|
47
|
-
if response.status_code == 200:
|
|
48
|
-
result = json.loads(response.text)
|
|
49
|
-
return result['media_id']
|
|
50
|
-
else:
|
|
51
|
-
print("HTTP Error:", response.status_code)
|
|
52
|
-
return None
|
|
53
|
-
except Exception as err:
|
|
54
|
-
raise Exception("upload_media error", err)
|
|
55
|
-
|
|
56
|
-
def send_file(self, file_path):
|
|
57
|
-
if not os.path.exists(file_path):
|
|
58
|
-
print('文件不存在: ', file_path)
|
|
59
|
-
return
|
|
60
|
-
"""
|
|
61
|
-
发送文件到群里
|
|
62
|
-
:param file_path:
|
|
63
|
-
:return:
|
|
64
|
-
"""
|
|
65
|
-
media_id = self.upload_media(file_path)
|
|
66
|
-
data = {
|
|
67
|
-
"msgtype": "file",
|
|
68
|
-
"file" : {
|
|
69
|
-
"media_id": media_id
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
return self.send_msg(data)
|
|
73
|
-
|
|
74
|
-
def send_text(self, content, mentioned_list=None, mentioned_mobile_list=None):
|
|
75
|
-
"""
|
|
76
|
-
发送文本消息
|
|
77
|
-
:param content:
|
|
78
|
-
:param mentioned_list: 需要@的人userid
|
|
79
|
-
:param mentioned_mobile_list: 需要@的人手机号
|
|
80
|
-
:return:
|
|
81
|
-
"""
|
|
82
|
-
data = {
|
|
83
|
-
"msgtype": "text",
|
|
84
|
-
"text" : {
|
|
85
|
-
"content": content
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if mentioned_list is not None and mentioned_list:
|
|
89
|
-
data['text'].update({"mentioned_list": mentioned_list})
|
|
90
|
-
if mentioned_mobile_list is not None and mentioned_mobile_list:
|
|
91
|
-
data['text'].update({"mentioned_mobile_list": mentioned_mobile_list})
|
|
92
|
-
|
|
93
|
-
self.send_msg(data)
|
|
94
|
-
|
|
95
|
-
def send_markdown(self, content):
|
|
96
|
-
"""
|
|
97
|
-
发送Markdown消息
|
|
98
|
-
:param content:
|
|
99
|
-
:return:
|
|
100
|
-
"""
|
|
101
|
-
data = {
|
|
102
|
-
"msgtype" : "markdown",
|
|
103
|
-
"markdown": {
|
|
104
|
-
"content": content
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
self.send_msg(data)
|
|
108
|
-
|
|
109
|
-
def send_notify(self, title, sub_title_list, data_list):
|
|
110
|
-
"""
|
|
111
|
-
发送Markdown消息
|
|
112
|
-
:param content:
|
|
113
|
-
:return:
|
|
114
|
-
"""
|
|
115
|
-
|
|
116
|
-
current_date = datetime.now().strftime("%Y-%m-%d")
|
|
117
|
-
header = f"{current_date} {title}\n\n"
|
|
118
|
-
|
|
119
|
-
arr_color = ['warning', 'info', 'warning']
|
|
120
|
-
arr_sub_header = [f"<font color='{arr_color[index]}'>{title}</font>" for index, title in enumerate(sub_title_list)]
|
|
121
|
-
sub_header = "\t".join(arr_sub_header) + "\n\n"
|
|
122
|
-
|
|
123
|
-
# 获取每个元素的行索引和列索引
|
|
124
|
-
arr_content = [
|
|
125
|
-
[
|
|
126
|
-
f'{value}' if col_idx == 0 else f"<font color='{arr_color[col_idx - 1]}'>{value}</font>"
|
|
127
|
-
for col_idx, value in enumerate(row)
|
|
128
|
-
] # 每行的元素组成一个子列表
|
|
129
|
-
for row_idx, row in enumerate(data_list) # 外层循环控制行
|
|
130
|
-
]
|
|
131
|
-
# 将二维数组转换为字符串
|
|
132
|
-
content = "\n".join(
|
|
133
|
-
# 对每行的元素列表使用 join(),用 \t 连接
|
|
134
|
-
"\t".join(row) for row in arr_content
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
data = {
|
|
138
|
-
"msgtype" : "markdown",
|
|
139
|
-
"markdown": {
|
|
140
|
-
"content": header + sub_header + content
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
self.send_msg(data)
|
|
144
|
-
|
|
145
|
-
def send_img(self, img_path):
|
|
146
|
-
"""
|
|
147
|
-
发送图片消息
|
|
148
|
-
图片(base64编码前)最大不能超过2M,支持JPG,PNG格式
|
|
149
|
-
:param img_path:
|
|
150
|
-
:return:
|
|
151
|
-
"""
|
|
152
|
-
data = {
|
|
153
|
-
"msgtype": "image",
|
|
154
|
-
"image" : {
|
|
155
|
-
"base64": self.img_to_base64(img_path),
|
|
156
|
-
"md5" : self.img_to_md5(img_path)
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
self.send_msg(data)
|
|
160
|
-
|
|
161
|
-
def send_news(self, title, description, url, picurl):
|
|
162
|
-
"""
|
|
163
|
-
发送图文消息
|
|
164
|
-
:param title: 标题
|
|
165
|
-
:param description: 描述
|
|
166
|
-
:param url: 跳转URL
|
|
167
|
-
:param picurl: 图文图片地址
|
|
168
|
-
:return:
|
|
169
|
-
"""
|
|
170
|
-
data = {
|
|
171
|
-
"msgtype": "news",
|
|
172
|
-
"news" : {
|
|
173
|
-
"articles": [
|
|
174
|
-
{
|
|
175
|
-
"title" : title,
|
|
176
|
-
"description": description,
|
|
177
|
-
"url" : url,
|
|
178
|
-
"picurl" : picurl
|
|
179
|
-
}
|
|
180
|
-
]
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
self.send_msg(data)
|
|
184
|
-
|
|
185
|
-
def send_msg(self, data):
|
|
186
|
-
"""
|
|
187
|
-
发送机器人通用消息到企微群
|
|
188
|
-
:param data: 消息内容json数据
|
|
189
|
-
:return:
|
|
190
|
-
"""
|
|
191
|
-
try:
|
|
192
|
-
header = {
|
|
193
|
-
"Content-Type": "application/json"
|
|
194
|
-
}
|
|
195
|
-
response = requests.post(f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={self.key}", headers=header, data=json.dumps(data))
|
|
196
|
-
response_text = json.loads(response.text)
|
|
197
|
-
if str(response_text.get('errcode')) != '0':
|
|
198
|
-
raise Exception(response_text)
|
|
199
|
-
if response.status_code == 200:
|
|
200
|
-
result = json.loads(response.text)
|
|
201
|
-
return result
|
|
202
|
-
else:
|
|
203
|
-
print("HTTP Error:", response.status_code)
|
|
204
|
-
return None
|
|
205
|
-
except Exception as err:
|
|
206
|
-
raise Exception("Send Chat Message error", err)
|
|
207
|
-
|
|
208
|
-
def img_to_md5(self, img_path):
|
|
209
|
-
# 读取图片文件并计算MD5值
|
|
210
|
-
with open(img_path, 'rb') as image_file:
|
|
211
|
-
image_data = image_file.read()
|
|
212
|
-
return hashlib.md5(image_data).hexdigest()
|
|
213
|
-
|
|
214
|
-
def img_to_base64(self, img_path):
|
|
215
|
-
# 读取图片文件并转换为Base64编码
|
|
216
|
-
with open(img_path, 'rb') as image_file:
|
|
217
|
-
image_data = image_file.read()
|
|
218
|
-
return base64.b64encode(image_data).decode('utf-8')
|
|
219
|
-
|
|
220
|
-
# 通过企微应用发送消息
|
|
221
|
-
class WxWorkAppBot:
|
|
222
|
-
def __init__(self, corpid, corpsecret, agentid):
|
|
223
|
-
self.corpid = corpid
|
|
224
|
-
self.corpsecret = corpsecret
|
|
225
|
-
self.agentid = agentid
|
|
226
|
-
self.access_token = self._getToken()
|
|
227
|
-
|
|
228
|
-
def _getToken(self):
|
|
229
|
-
try:
|
|
230
|
-
if all([self.corpid, self.corpsecret]):
|
|
231
|
-
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpid}&corpsecret={corpsecret}".format(
|
|
232
|
-
corpid=self.corpid, corpsecret=self.corpsecret)
|
|
233
|
-
response = requests.get(url)
|
|
234
|
-
if response.status_code == 200:
|
|
235
|
-
result = json.loads(response.text)
|
|
236
|
-
return result['access_token']
|
|
237
|
-
else:
|
|
238
|
-
print("HTTP Error:", response.status_code)
|
|
239
|
-
return None
|
|
240
|
-
except Exception as err:
|
|
241
|
-
raise Exception("get WeChat access Token error", err)
|
|
242
|
-
|
|
243
|
-
def _send_msg(self, data):
|
|
244
|
-
self._check_token()
|
|
245
|
-
try:
|
|
246
|
-
send_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}".format(
|
|
247
|
-
access_token=self.access_token)
|
|
248
|
-
response = requests.post(send_url, json.dumps(data))
|
|
249
|
-
if response.status_code == 200:
|
|
250
|
-
result = json.loads(response.text)
|
|
251
|
-
return result
|
|
252
|
-
else:
|
|
253
|
-
print("HTTP Error:", response.status_code)
|
|
254
|
-
return None
|
|
255
|
-
except Exception as err:
|
|
256
|
-
raise Exception("send WeChat Message error", err)
|
|
257
|
-
|
|
258
|
-
def _check_token(self):
|
|
259
|
-
if self.access_token is None:
|
|
260
|
-
self._getToken()
|
|
261
|
-
|
|
262
|
-
def send_msg(self, data):
|
|
263
|
-
return self._send_msg(data)
|
|
264
|
-
|
|
265
|
-
def upload_media(self, filetype, filepath, filename):
|
|
266
|
-
"""
|
|
267
|
-
上传临时素材到企微并获取media_id
|
|
268
|
-
:param filetype: 图片(image)、语音(voice)、视频(video),普通文件(file)
|
|
269
|
-
:param filepath:
|
|
270
|
-
:param filename:
|
|
271
|
-
:return: media_id
|
|
272
|
-
"""
|
|
273
|
-
try:
|
|
274
|
-
self._check_token()
|
|
275
|
-
post_file_url = "https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type={filetype}".format(
|
|
276
|
-
filetype=filetype,
|
|
277
|
-
access_token=self.access_token)
|
|
278
|
-
|
|
279
|
-
m = MultipartEncoder(
|
|
280
|
-
fields={filename: (filename, open(filepath + filename, 'rb'), 'text/plain')},
|
|
281
|
-
)
|
|
282
|
-
response = requests.post(url=post_file_url, data=m, headers={'Content-Type': m.content_type})
|
|
283
|
-
if response.status_code == 200:
|
|
284
|
-
result = json.loads(response.text)
|
|
285
|
-
return result['media_id']
|
|
286
|
-
else:
|
|
287
|
-
print("HTTP Error:", response.status_code)
|
|
288
|
-
return None
|
|
289
|
-
except Exception as err:
|
|
290
|
-
raise Exception("upload media error", err)
|
|
291
|
-
|
|
292
|
-
def get_media(self, media_id):
|
|
293
|
-
"""
|
|
294
|
-
获取临时素材
|
|
295
|
-
:param media_id:
|
|
296
|
-
:return: 返回二进制形式
|
|
297
|
-
"""
|
|
298
|
-
try:
|
|
299
|
-
self._check_token()
|
|
300
|
-
url = "https://qyapi.weixin.qq.com/cgi-bin/media/get"
|
|
301
|
-
params = {
|
|
302
|
-
"access_token": self.access_token,
|
|
303
|
-
"media_id" : media_id
|
|
304
|
-
}
|
|
305
|
-
response = requests.get(url=url, params=params)
|
|
306
|
-
if response.status_code == 200:
|
|
307
|
-
content_type = response.headers.get('Content-Type')
|
|
308
|
-
if content_type == 'application/json':
|
|
309
|
-
response_data = json.loads(response.text)
|
|
310
|
-
print("Error:", response_data.get("errmsg"))
|
|
311
|
-
return None
|
|
312
|
-
else:
|
|
313
|
-
return response.content
|
|
314
|
-
else:
|
|
315
|
-
print("HTTP Error:", response.status_code)
|
|
316
|
-
return None
|
|
317
|
-
except Exception as err:
|
|
318
|
-
raise Exception("get media error", err)
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
-------------------------------------------------
|
|
4
|
+
@version : v1.0
|
|
5
|
+
@author : qsir
|
|
6
|
+
@contact : qsir@vxnote.com
|
|
7
|
+
@software : PyCharm
|
|
8
|
+
@filename : wxwork.py
|
|
9
|
+
@create time: 2025/08/03
|
|
10
|
+
@modify time: 2025/08/03
|
|
11
|
+
@describe :
|
|
12
|
+
-------------------------------------------------
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import hashlib
|
|
17
|
+
import base64
|
|
18
|
+
import requests
|
|
19
|
+
from requests_toolbelt import MultipartEncoder
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
# 通过企微群机器人发送消息
|
|
23
|
+
class WxWorkBot:
|
|
24
|
+
def __init__(self, key):
|
|
25
|
+
self.key = key
|
|
26
|
+
|
|
27
|
+
def upload_media(self, filepath):
|
|
28
|
+
"""
|
|
29
|
+
上传临时素材,给企微群里发文件消息时需要先将文件上传至企微临时素材中
|
|
30
|
+
:param filepath:
|
|
31
|
+
:return: 临时素材的media_id
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
headers = {
|
|
35
|
+
'Content-Type': 'multipart/form-data',
|
|
36
|
+
}
|
|
37
|
+
with open(filepath, 'rb') as f:
|
|
38
|
+
files = {
|
|
39
|
+
'media': (os.path.basename(filepath), f.read())
|
|
40
|
+
}
|
|
41
|
+
response = requests.post(
|
|
42
|
+
f'https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key={self.key}&type=file',
|
|
43
|
+
headers=headers, files=files)
|
|
44
|
+
response_text = json.loads(response.text)
|
|
45
|
+
if str(response_text.get('errcode')) != '0':
|
|
46
|
+
raise Exception(response_text)
|
|
47
|
+
if response.status_code == 200:
|
|
48
|
+
result = json.loads(response.text)
|
|
49
|
+
return result['media_id']
|
|
50
|
+
else:
|
|
51
|
+
print("HTTP Error:", response.status_code)
|
|
52
|
+
return None
|
|
53
|
+
except Exception as err:
|
|
54
|
+
raise Exception("upload_media error", err)
|
|
55
|
+
|
|
56
|
+
def send_file(self, file_path):
|
|
57
|
+
if not os.path.exists(file_path):
|
|
58
|
+
print('文件不存在: ', file_path)
|
|
59
|
+
return
|
|
60
|
+
"""
|
|
61
|
+
发送文件到群里
|
|
62
|
+
:param file_path:
|
|
63
|
+
:return:
|
|
64
|
+
"""
|
|
65
|
+
media_id = self.upload_media(file_path)
|
|
66
|
+
data = {
|
|
67
|
+
"msgtype": "file",
|
|
68
|
+
"file" : {
|
|
69
|
+
"media_id": media_id
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return self.send_msg(data)
|
|
73
|
+
|
|
74
|
+
def send_text(self, content, mentioned_list=None, mentioned_mobile_list=None):
|
|
75
|
+
"""
|
|
76
|
+
发送文本消息
|
|
77
|
+
:param content:
|
|
78
|
+
:param mentioned_list: 需要@的人userid
|
|
79
|
+
:param mentioned_mobile_list: 需要@的人手机号
|
|
80
|
+
:return:
|
|
81
|
+
"""
|
|
82
|
+
data = {
|
|
83
|
+
"msgtype": "text",
|
|
84
|
+
"text" : {
|
|
85
|
+
"content": content
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if mentioned_list is not None and mentioned_list:
|
|
89
|
+
data['text'].update({"mentioned_list": mentioned_list})
|
|
90
|
+
if mentioned_mobile_list is not None and mentioned_mobile_list:
|
|
91
|
+
data['text'].update({"mentioned_mobile_list": mentioned_mobile_list})
|
|
92
|
+
|
|
93
|
+
self.send_msg(data)
|
|
94
|
+
|
|
95
|
+
def send_markdown(self, content):
|
|
96
|
+
"""
|
|
97
|
+
发送Markdown消息
|
|
98
|
+
:param content:
|
|
99
|
+
:return:
|
|
100
|
+
"""
|
|
101
|
+
data = {
|
|
102
|
+
"msgtype" : "markdown",
|
|
103
|
+
"markdown": {
|
|
104
|
+
"content": content
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
self.send_msg(data)
|
|
108
|
+
|
|
109
|
+
def send_notify(self, title, sub_title_list, data_list):
|
|
110
|
+
"""
|
|
111
|
+
发送Markdown消息
|
|
112
|
+
:param content:
|
|
113
|
+
:return:
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
|
117
|
+
header = f"{current_date} {title}\n\n"
|
|
118
|
+
|
|
119
|
+
arr_color = ['warning', 'info', 'warning']
|
|
120
|
+
arr_sub_header = [f"<font color='{arr_color[index]}'>{title}</font>" for index, title in enumerate(sub_title_list)]
|
|
121
|
+
sub_header = "\t".join(arr_sub_header) + "\n\n"
|
|
122
|
+
|
|
123
|
+
# 获取每个元素的行索引和列索引
|
|
124
|
+
arr_content = [
|
|
125
|
+
[
|
|
126
|
+
f'{value}' if col_idx == 0 else f"<font color='{arr_color[col_idx - 1]}'>{value}</font>"
|
|
127
|
+
for col_idx, value in enumerate(row)
|
|
128
|
+
] # 每行的元素组成一个子列表
|
|
129
|
+
for row_idx, row in enumerate(data_list) # 外层循环控制行
|
|
130
|
+
]
|
|
131
|
+
# 将二维数组转换为字符串
|
|
132
|
+
content = "\n".join(
|
|
133
|
+
# 对每行的元素列表使用 join(),用 \t 连接
|
|
134
|
+
"\t".join(row) for row in arr_content
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
data = {
|
|
138
|
+
"msgtype" : "markdown",
|
|
139
|
+
"markdown": {
|
|
140
|
+
"content": header + sub_header + content
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
self.send_msg(data)
|
|
144
|
+
|
|
145
|
+
def send_img(self, img_path):
|
|
146
|
+
"""
|
|
147
|
+
发送图片消息
|
|
148
|
+
图片(base64编码前)最大不能超过2M,支持JPG,PNG格式
|
|
149
|
+
:param img_path:
|
|
150
|
+
:return:
|
|
151
|
+
"""
|
|
152
|
+
data = {
|
|
153
|
+
"msgtype": "image",
|
|
154
|
+
"image" : {
|
|
155
|
+
"base64": self.img_to_base64(img_path),
|
|
156
|
+
"md5" : self.img_to_md5(img_path)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
self.send_msg(data)
|
|
160
|
+
|
|
161
|
+
def send_news(self, title, description, url, picurl):
|
|
162
|
+
"""
|
|
163
|
+
发送图文消息
|
|
164
|
+
:param title: 标题
|
|
165
|
+
:param description: 描述
|
|
166
|
+
:param url: 跳转URL
|
|
167
|
+
:param picurl: 图文图片地址
|
|
168
|
+
:return:
|
|
169
|
+
"""
|
|
170
|
+
data = {
|
|
171
|
+
"msgtype": "news",
|
|
172
|
+
"news" : {
|
|
173
|
+
"articles": [
|
|
174
|
+
{
|
|
175
|
+
"title" : title,
|
|
176
|
+
"description": description,
|
|
177
|
+
"url" : url,
|
|
178
|
+
"picurl" : picurl
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
self.send_msg(data)
|
|
184
|
+
|
|
185
|
+
def send_msg(self, data):
|
|
186
|
+
"""
|
|
187
|
+
发送机器人通用消息到企微群
|
|
188
|
+
:param data: 消息内容json数据
|
|
189
|
+
:return:
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
header = {
|
|
193
|
+
"Content-Type": "application/json"
|
|
194
|
+
}
|
|
195
|
+
response = requests.post(f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={self.key}", headers=header, data=json.dumps(data))
|
|
196
|
+
response_text = json.loads(response.text)
|
|
197
|
+
if str(response_text.get('errcode')) != '0':
|
|
198
|
+
raise Exception(response_text)
|
|
199
|
+
if response.status_code == 200:
|
|
200
|
+
result = json.loads(response.text)
|
|
201
|
+
return result
|
|
202
|
+
else:
|
|
203
|
+
print("HTTP Error:", response.status_code)
|
|
204
|
+
return None
|
|
205
|
+
except Exception as err:
|
|
206
|
+
raise Exception("Send Chat Message error", err)
|
|
207
|
+
|
|
208
|
+
def img_to_md5(self, img_path):
|
|
209
|
+
# 读取图片文件并计算MD5值
|
|
210
|
+
with open(img_path, 'rb') as image_file:
|
|
211
|
+
image_data = image_file.read()
|
|
212
|
+
return hashlib.md5(image_data).hexdigest()
|
|
213
|
+
|
|
214
|
+
def img_to_base64(self, img_path):
|
|
215
|
+
# 读取图片文件并转换为Base64编码
|
|
216
|
+
with open(img_path, 'rb') as image_file:
|
|
217
|
+
image_data = image_file.read()
|
|
218
|
+
return base64.b64encode(image_data).decode('utf-8')
|
|
219
|
+
|
|
220
|
+
# 通过企微应用发送消息
|
|
221
|
+
class WxWorkAppBot:
|
|
222
|
+
def __init__(self, corpid, corpsecret, agentid):
|
|
223
|
+
self.corpid = corpid
|
|
224
|
+
self.corpsecret = corpsecret
|
|
225
|
+
self.agentid = agentid
|
|
226
|
+
self.access_token = self._getToken()
|
|
227
|
+
|
|
228
|
+
def _getToken(self):
|
|
229
|
+
try:
|
|
230
|
+
if all([self.corpid, self.corpsecret]):
|
|
231
|
+
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={corpid}&corpsecret={corpsecret}".format(
|
|
232
|
+
corpid=self.corpid, corpsecret=self.corpsecret)
|
|
233
|
+
response = requests.get(url)
|
|
234
|
+
if response.status_code == 200:
|
|
235
|
+
result = json.loads(response.text)
|
|
236
|
+
return result['access_token']
|
|
237
|
+
else:
|
|
238
|
+
print("HTTP Error:", response.status_code)
|
|
239
|
+
return None
|
|
240
|
+
except Exception as err:
|
|
241
|
+
raise Exception("get WeChat access Token error", err)
|
|
242
|
+
|
|
243
|
+
def _send_msg(self, data):
|
|
244
|
+
self._check_token()
|
|
245
|
+
try:
|
|
246
|
+
send_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}".format(
|
|
247
|
+
access_token=self.access_token)
|
|
248
|
+
response = requests.post(send_url, json.dumps(data))
|
|
249
|
+
if response.status_code == 200:
|
|
250
|
+
result = json.loads(response.text)
|
|
251
|
+
return result
|
|
252
|
+
else:
|
|
253
|
+
print("HTTP Error:", response.status_code)
|
|
254
|
+
return None
|
|
255
|
+
except Exception as err:
|
|
256
|
+
raise Exception("send WeChat Message error", err)
|
|
257
|
+
|
|
258
|
+
def _check_token(self):
|
|
259
|
+
if self.access_token is None:
|
|
260
|
+
self._getToken()
|
|
261
|
+
|
|
262
|
+
def send_msg(self, data):
|
|
263
|
+
return self._send_msg(data)
|
|
264
|
+
|
|
265
|
+
def upload_media(self, filetype, filepath, filename):
|
|
266
|
+
"""
|
|
267
|
+
上传临时素材到企微并获取media_id
|
|
268
|
+
:param filetype: 图片(image)、语音(voice)、视频(video),普通文件(file)
|
|
269
|
+
:param filepath:
|
|
270
|
+
:param filename:
|
|
271
|
+
:return: media_id
|
|
272
|
+
"""
|
|
273
|
+
try:
|
|
274
|
+
self._check_token()
|
|
275
|
+
post_file_url = "https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type={filetype}".format(
|
|
276
|
+
filetype=filetype,
|
|
277
|
+
access_token=self.access_token)
|
|
278
|
+
|
|
279
|
+
m = MultipartEncoder(
|
|
280
|
+
fields={filename: (filename, open(filepath + filename, 'rb'), 'text/plain')},
|
|
281
|
+
)
|
|
282
|
+
response = requests.post(url=post_file_url, data=m, headers={'Content-Type': m.content_type})
|
|
283
|
+
if response.status_code == 200:
|
|
284
|
+
result = json.loads(response.text)
|
|
285
|
+
return result['media_id']
|
|
286
|
+
else:
|
|
287
|
+
print("HTTP Error:", response.status_code)
|
|
288
|
+
return None
|
|
289
|
+
except Exception as err:
|
|
290
|
+
raise Exception("upload media error", err)
|
|
291
|
+
|
|
292
|
+
def get_media(self, media_id):
|
|
293
|
+
"""
|
|
294
|
+
获取临时素材
|
|
295
|
+
:param media_id:
|
|
296
|
+
:return: 返回二进制形式
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
self._check_token()
|
|
300
|
+
url = "https://qyapi.weixin.qq.com/cgi-bin/media/get"
|
|
301
|
+
params = {
|
|
302
|
+
"access_token": self.access_token,
|
|
303
|
+
"media_id" : media_id
|
|
304
|
+
}
|
|
305
|
+
response = requests.get(url=url, params=params)
|
|
306
|
+
if response.status_code == 200:
|
|
307
|
+
content_type = response.headers.get('Content-Type')
|
|
308
|
+
if content_type == 'application/json':
|
|
309
|
+
response_data = json.loads(response.text)
|
|
310
|
+
print("Error:", response_data.get("errmsg"))
|
|
311
|
+
return None
|
|
312
|
+
else:
|
|
313
|
+
return response.content
|
|
314
|
+
else:
|
|
315
|
+
print("HTTP Error:", response.status_code)
|
|
316
|
+
return None
|
|
317
|
+
except Exception as err:
|
|
318
|
+
raise Exception("get media error", err)
|
|
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
|