qrpa 1.0.23__tar.gz → 1.0.25__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.23 → qrpa-1.0.25}/PKG-INFO +1 -1
- {qrpa-1.0.23 → qrpa-1.0.25}/pyproject.toml +1 -1
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/__init__.py +6 -1
- qrpa-1.0.25/qrpa/fun_base.py +337 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/fun_excel.py +65 -13
- qrpa-1.0.25/qrpa/shein_excel.py +2005 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/shein_lib.py +436 -0
- qrpa-1.0.25/qrpa/temu_chrome.py +56 -0
- qrpa-1.0.25/qrpa/temu_excel.py +109 -0
- qrpa-1.0.25/qrpa/temu_lib.py +154 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/wxwork.py +6 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa.egg-info/PKG-INFO +1 -1
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa.egg-info/SOURCES.txt +3 -0
- qrpa-1.0.23/qrpa/fun_base.py +0 -107
- qrpa-1.0.23/qrpa/shein_excel.py +0 -590
- {qrpa-1.0.23 → qrpa-1.0.25}/README.md +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/RateLimitedSender.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/db_migrator.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/fun_file.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/fun_web.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/fun_win.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/shein_daily_report_model.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/shein_sqlite.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/shein_ziniao.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/time_utils.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa/time_utils_example.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa.egg-info/dependency_links.txt +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/qrpa.egg-info/top_level.txt +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/setup.cfg +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/setup.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/tests/test_db_migrator.py +0 -0
- {qrpa-1.0.23 → qrpa-1.0.25}/tests/test_wxwork.py +0 -0
|
@@ -3,7 +3,8 @@ from .db_migrator import DatabaseMigrator, DatabaseConfig, RemoteConfig, create_
|
|
|
3
3
|
|
|
4
4
|
from .shein_ziniao import ZiniaoRunner
|
|
5
5
|
|
|
6
|
-
from .fun_base import log, send_exception, md5_string, hostname, get_safe_value, sanitize_filename
|
|
6
|
+
# from .fun_base import log, send_exception, md5_string, hostname, get_safe_value, sanitize_filename, get_file_size, calculate_star_symbols
|
|
7
|
+
from .fun_base import *
|
|
7
8
|
|
|
8
9
|
from .time_utils import TimeUtils
|
|
9
10
|
|
|
@@ -18,3 +19,7 @@ from .shein_excel import SheinExcel
|
|
|
18
19
|
from .shein_lib import SheinLib
|
|
19
20
|
|
|
20
21
|
from .fun_excel import InsertImageV2
|
|
22
|
+
|
|
23
|
+
from .temu_lib import TemuLib
|
|
24
|
+
from .temu_excel import TemuExcel
|
|
25
|
+
from .temu_chrome import temu_chrome_excute
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import os
|
|
3
|
+
import traceback
|
|
4
|
+
import socket
|
|
5
|
+
import hashlib
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from .wxwork import WxWorkBot
|
|
11
|
+
|
|
12
|
+
from .RateLimitedSender import RateLimitedSender
|
|
13
|
+
|
|
14
|
+
from typing import TypedDict
|
|
15
|
+
|
|
16
|
+
# 定义一个 TypedDict 来提供配置结构的类型提示
|
|
17
|
+
|
|
18
|
+
class ZiNiao(TypedDict):
|
|
19
|
+
company: str
|
|
20
|
+
username: str
|
|
21
|
+
password: str
|
|
22
|
+
|
|
23
|
+
class Config(TypedDict):
|
|
24
|
+
wxwork_bot_exception: str
|
|
25
|
+
ziniao: ZiNiao
|
|
26
|
+
auto_dir: str
|
|
27
|
+
|
|
28
|
+
def log(*args, **kwargs):
|
|
29
|
+
"""封装 print 函数,使其行为与原 print 一致,并写入日志文件"""
|
|
30
|
+
stack = inspect.stack()
|
|
31
|
+
fi = stack[1] if len(stack) > 1 else None
|
|
32
|
+
log_message = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}][{os.path.basename(fi.filename) if fi else 'unknown'}:{fi.lineno if fi else 0}:{fi.function if fi else 'unknown'}] " + " ".join(map(str, args))
|
|
33
|
+
|
|
34
|
+
print(log_message, **kwargs)
|
|
35
|
+
|
|
36
|
+
def hostname():
|
|
37
|
+
return socket.gethostname()
|
|
38
|
+
|
|
39
|
+
# ================= WxWorkBot 限频异常发送 =================
|
|
40
|
+
def send_exception(msg=None):
|
|
41
|
+
"""
|
|
42
|
+
发送异常到 WxWorkBot,限制发送频率,支持异步批量
|
|
43
|
+
"""
|
|
44
|
+
# 首次调用时初始化限频发送器
|
|
45
|
+
if not hasattr(send_exception, "_wx_sender"):
|
|
46
|
+
def wxwork_bot_send(message):
|
|
47
|
+
bot_id = os.getenv('wxwork_bot_exception', 'ee5a048a-1b9e-41e4-9382-aa0ee447898e')
|
|
48
|
+
WxWorkBot(bot_id).send_text(message)
|
|
49
|
+
|
|
50
|
+
send_exception._wx_sender = RateLimitedSender(
|
|
51
|
+
sender_func=wxwork_bot_send,
|
|
52
|
+
interval=30, # 10 秒发一次
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 构造异常消息
|
|
56
|
+
error_msg = f'【{hostname()}】{datetime.now():%Y-%m-%d %H:%M:%S}\n{msg}\n'
|
|
57
|
+
error_msg += f'{traceback.format_exc()}'
|
|
58
|
+
print(error_msg)
|
|
59
|
+
|
|
60
|
+
# 异步发送
|
|
61
|
+
send_exception._wx_sender.send(error_msg)
|
|
62
|
+
return error_msg
|
|
63
|
+
|
|
64
|
+
def get_safe_value(data, key, default=0):
|
|
65
|
+
value = data.get(key)
|
|
66
|
+
return default if value is None else value
|
|
67
|
+
|
|
68
|
+
def md5_string(s):
|
|
69
|
+
# 需要先将字符串编码为 bytes
|
|
70
|
+
return hashlib.md5(s.encode('utf-8')).hexdigest()
|
|
71
|
+
|
|
72
|
+
# 将windows文件名不支持的字符替换成下划线
|
|
73
|
+
def sanitize_filename(filename):
|
|
74
|
+
# Windows 文件名非法字符
|
|
75
|
+
illegal_chars = r'\/:*?"<>|'
|
|
76
|
+
for char in illegal_chars:
|
|
77
|
+
filename = filename.replace(char, '_')
|
|
78
|
+
|
|
79
|
+
# 去除首尾空格和点
|
|
80
|
+
filename = filename.strip(' .')
|
|
81
|
+
|
|
82
|
+
# 替换连续多个下划线为单个
|
|
83
|
+
filename = '_'.join(filter(None, filename.split('_')))
|
|
84
|
+
|
|
85
|
+
return filename
|
|
86
|
+
|
|
87
|
+
def add_https(url):
|
|
88
|
+
if url and url.startswith('//'):
|
|
89
|
+
return 'https:' + url
|
|
90
|
+
return url
|
|
91
|
+
|
|
92
|
+
def create_file_path(file_path):
|
|
93
|
+
dir_name = os.path.dirname(file_path)
|
|
94
|
+
if dir_name and not os.path.exists(dir_name):
|
|
95
|
+
os.makedirs(dir_name, exist_ok=True) # 递归创建目录
|
|
96
|
+
return file_path
|
|
97
|
+
|
|
98
|
+
def copy_file(source, destination):
|
|
99
|
+
try:
|
|
100
|
+
shutil.copy2(source, destination)
|
|
101
|
+
print(f"文件已复制到 {destination}")
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
print(f"错误:源文件 '{source}' 不存在")
|
|
104
|
+
except PermissionError:
|
|
105
|
+
print(f"错误:没有权限复制到 '{destination}'")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"错误:发生未知错误 - {e}")
|
|
108
|
+
|
|
109
|
+
def get_file_size(file_path, human_readable=False):
|
|
110
|
+
"""
|
|
111
|
+
获取文件大小
|
|
112
|
+
|
|
113
|
+
:param file_path: 文件路径
|
|
114
|
+
:param human_readable: 是否返回可读格式(KB, MB, GB)
|
|
115
|
+
:return: 文件大小(字节数或可读格式)
|
|
116
|
+
"""
|
|
117
|
+
if not os.path.isfile(file_path):
|
|
118
|
+
raise FileNotFoundError(f"文件不存在: {file_path}")
|
|
119
|
+
|
|
120
|
+
size_bytes = os.path.getsize(file_path)
|
|
121
|
+
|
|
122
|
+
if not human_readable:
|
|
123
|
+
return size_bytes
|
|
124
|
+
|
|
125
|
+
# 转换为可读单位
|
|
126
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
127
|
+
size = float(size_bytes)
|
|
128
|
+
for unit in units:
|
|
129
|
+
if size < 1024:
|
|
130
|
+
return f"{size:.2f} {unit}"
|
|
131
|
+
size /= 1024
|
|
132
|
+
|
|
133
|
+
def calculate_star_symbols(rating):
|
|
134
|
+
"""
|
|
135
|
+
计算星级对应的符号组合(独立评分逻辑函数)
|
|
136
|
+
参数:
|
|
137
|
+
rating (int): 标准化评分(0-5)
|
|
138
|
+
返回:
|
|
139
|
+
str: 星级符号字符串(如★★★⭐☆)
|
|
140
|
+
"""
|
|
141
|
+
full_stars = int(rating)
|
|
142
|
+
empty_stars = 5 - full_stars
|
|
143
|
+
star_string = '★' * full_stars
|
|
144
|
+
star_string += '☆' * empty_stars
|
|
145
|
+
return star_string
|
|
146
|
+
|
|
147
|
+
def remove_columns(matrix, indices):
|
|
148
|
+
"""
|
|
149
|
+
过滤二维列表,移除指定索引的列
|
|
150
|
+
|
|
151
|
+
参数:
|
|
152
|
+
matrix: 二维列表
|
|
153
|
+
indices: 需要移除的列索引列表
|
|
154
|
+
|
|
155
|
+
返回:
|
|
156
|
+
过滤后的二维列表
|
|
157
|
+
"""
|
|
158
|
+
# 创建要保留的索引集合(排除需要移除的索引)
|
|
159
|
+
indices_to_keep = set(range(len(matrix[0]))) - set(indices)
|
|
160
|
+
|
|
161
|
+
# 遍历每行,只保留不在indices中的列
|
|
162
|
+
return [[row[i] for i in indices_to_keep] for row in matrix]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def filter_columns(matrix, indices):
|
|
166
|
+
"""
|
|
167
|
+
过滤二维列表,只保留指定索引的列
|
|
168
|
+
|
|
169
|
+
参数:
|
|
170
|
+
matrix: 二维列表
|
|
171
|
+
indices: 需要保留的列索引列表
|
|
172
|
+
|
|
173
|
+
返回:
|
|
174
|
+
过滤后的二维列表
|
|
175
|
+
"""
|
|
176
|
+
# 转置矩阵,获取每一列
|
|
177
|
+
columns = list(zip(*matrix))
|
|
178
|
+
|
|
179
|
+
# 只保留指定索引的列
|
|
180
|
+
filtered_columns = [columns[i] for i in indices]
|
|
181
|
+
|
|
182
|
+
# 将过滤后的列转回二维列表
|
|
183
|
+
return [list(row) for row in zip(*filtered_columns)]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# # 示例使用
|
|
187
|
+
# matrix = [
|
|
188
|
+
# [1, 2, 3, 4],
|
|
189
|
+
# [5, 6, 7, 8],
|
|
190
|
+
# [9, 10, 11, 12]
|
|
191
|
+
# ]
|
|
192
|
+
#
|
|
193
|
+
# # 只保留索引为 0 和 2 的列
|
|
194
|
+
# filtered = filter_columns(matrix, [0, 2])
|
|
195
|
+
# print(filtered) # 输出: [[1, 3], [5, 7], [9, 11]]
|
|
196
|
+
|
|
197
|
+
def add_column_to_2d_list(data, new_col, index=None):
|
|
198
|
+
"""
|
|
199
|
+
给二维列表增加一列数据
|
|
200
|
+
|
|
201
|
+
:param data: 原始二维列表,例如 [[1, 2], [3, 4]]
|
|
202
|
+
:param new_col: 要添加的新列数据,例如 [10, 20]
|
|
203
|
+
:param index: 插入位置,默认为最后一列之后;支持负数索引
|
|
204
|
+
:return: 增加新列后的二维列表
|
|
205
|
+
"""
|
|
206
|
+
if not data:
|
|
207
|
+
raise ValueError("原始数据为空")
|
|
208
|
+
if len(data) != len(new_col):
|
|
209
|
+
raise ValueError("新列长度必须与原始数据的行数相等")
|
|
210
|
+
|
|
211
|
+
new_data = []
|
|
212
|
+
for i, row in enumerate(data):
|
|
213
|
+
row = list(row) # 防止修改原始数据
|
|
214
|
+
insert_at = index if index is not None else len(row)
|
|
215
|
+
row.insert(insert_at, new_col[i])
|
|
216
|
+
new_data.append(row)
|
|
217
|
+
return new_data
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def add_prefixed_column(data, header, value):
|
|
221
|
+
"""
|
|
222
|
+
给二维列表增加第一列,第一行为 header,后面为 value。
|
|
223
|
+
|
|
224
|
+
:param data: 原始二维列表
|
|
225
|
+
:param header: 新列的标题
|
|
226
|
+
:param value: 新列内容(相同值)
|
|
227
|
+
:return: 增加新列后的二维列表
|
|
228
|
+
"""
|
|
229
|
+
if not data:
|
|
230
|
+
raise ValueError("原始数据不能为空")
|
|
231
|
+
|
|
232
|
+
new_col = [header] + [value] * (len(data) - 1)
|
|
233
|
+
return [[new_col[i]] + row for i, row in enumerate(data)]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def add_suffixed_column(data, header, value):
|
|
237
|
+
"""
|
|
238
|
+
给二维列表增加第一列,第一行为 header,后面为 value。
|
|
239
|
+
|
|
240
|
+
:param data: 原始二维列表
|
|
241
|
+
:param header: 新列的标题
|
|
242
|
+
:param value: 新列内容(相同值)
|
|
243
|
+
:return: 增加新列后的二维列表
|
|
244
|
+
"""
|
|
245
|
+
if not data:
|
|
246
|
+
raise ValueError("原始数据不能为空")
|
|
247
|
+
|
|
248
|
+
new_col = [header] + [value] * (len(data) - 1)
|
|
249
|
+
return [row + [new_col[i]] for i, row in enumerate(data)]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def merge_2d_lists_keep_first_header(data1, data2):
|
|
253
|
+
"""
|
|
254
|
+
合并两个二维列表,只保留第一个列表的标题(即第一行)。
|
|
255
|
+
|
|
256
|
+
:param data1: 第一个二维列表(包含标题)
|
|
257
|
+
:param data2: 第二个二维列表(包含标题)
|
|
258
|
+
:return: 合并后的二维列表
|
|
259
|
+
"""
|
|
260
|
+
if not data1 or not isinstance(data1, list):
|
|
261
|
+
raise ValueError("data1 不能为空并且必须是二维列表")
|
|
262
|
+
if not data2 or not isinstance(data2, list):
|
|
263
|
+
raise ValueError("data2 不能为空并且必须是二维列表")
|
|
264
|
+
|
|
265
|
+
header = data1[0]
|
|
266
|
+
rows1 = data1[1:]
|
|
267
|
+
rows2 = data2[1:]
|
|
268
|
+
|
|
269
|
+
return [header] + rows1 + rows2
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def insert_total_row(data, row_index=1, label="合计"):
|
|
273
|
+
"""
|
|
274
|
+
在指定行插入一行,第一列为 label,其余为空字符串。
|
|
275
|
+
|
|
276
|
+
:param data: 原始二维列表
|
|
277
|
+
:param row_index: 插入位置,默认插在第二行(索引1)
|
|
278
|
+
:param label: 第一列的标签内容,默认为 "合计"
|
|
279
|
+
:return: 新的二维列表
|
|
280
|
+
"""
|
|
281
|
+
if not data or not isinstance(data, list):
|
|
282
|
+
raise ValueError("data 不能为空并且必须是二维列表")
|
|
283
|
+
|
|
284
|
+
num_cols = len(data[0])
|
|
285
|
+
new_row = [label] + [""] * (num_cols - 1)
|
|
286
|
+
|
|
287
|
+
return data[:row_index] + [new_row] + data[row_index:]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def insert_empty_column_after(data, col_index, new_header="单价成本"):
|
|
291
|
+
"""
|
|
292
|
+
在二维列表中指定列的后面插入一个新列,标题为 new_header,其余内容为空字符串。
|
|
293
|
+
|
|
294
|
+
:param data: 原始二维列表
|
|
295
|
+
:param col_index: 要插入的位置(在该列后面插入)
|
|
296
|
+
:param new_header: 新列的标题
|
|
297
|
+
:return: 新的二维列表
|
|
298
|
+
"""
|
|
299
|
+
if not data or not isinstance(data, list):
|
|
300
|
+
raise ValueError("data 不能为空且必须是二维列表")
|
|
301
|
+
|
|
302
|
+
new_data = []
|
|
303
|
+
for i, row in enumerate(data):
|
|
304
|
+
row = list(row) # 复制避免修改原数据
|
|
305
|
+
insert_value = new_header if i == 0 else ""
|
|
306
|
+
row.insert(col_index + 1, insert_value)
|
|
307
|
+
new_data.append(row)
|
|
308
|
+
|
|
309
|
+
return new_data
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def insert_empty_column_after_column_name(data, target_col_name, new_header="单价成本"):
|
|
313
|
+
"""
|
|
314
|
+
在指定列名对应的列后面插入一个新列,标题为 new_header,其余行为空字符串。
|
|
315
|
+
|
|
316
|
+
:param data: 原始二维列表
|
|
317
|
+
:param target_col_name: 要在哪一列之后插入(通过列标题匹配)
|
|
318
|
+
:param new_header: 插入的新列标题
|
|
319
|
+
:return: 新的二维列表
|
|
320
|
+
"""
|
|
321
|
+
if not data or not isinstance(data, list):
|
|
322
|
+
raise ValueError("data 不能为空且必须是二维列表")
|
|
323
|
+
|
|
324
|
+
header = data[0]
|
|
325
|
+
if target_col_name not in header:
|
|
326
|
+
raise ValueError(f"找不到列名:{target_col_name}")
|
|
327
|
+
|
|
328
|
+
col_index = header.index(target_col_name)
|
|
329
|
+
|
|
330
|
+
new_data = []
|
|
331
|
+
for i, row in enumerate(data):
|
|
332
|
+
row = list(row) # 防止修改原始数据
|
|
333
|
+
insert_value = new_header if i == 0 else ""
|
|
334
|
+
row.insert(col_index + 1, insert_value)
|
|
335
|
+
new_data.append(row)
|
|
336
|
+
|
|
337
|
+
return new_data
|
|
@@ -12,6 +12,7 @@ import concurrent.futures
|
|
|
12
12
|
from collections import defaultdict
|
|
13
13
|
import threading
|
|
14
14
|
from playwright.sync_api import sync_playwright
|
|
15
|
+
import psutil
|
|
15
16
|
|
|
16
17
|
from .fun_base import log, sanitize_filename, create_file_path, copy_file, add_https, send_exception
|
|
17
18
|
|
|
@@ -89,13 +90,13 @@ def set_cell_prefix_red(cell, n, color_name):
|
|
|
89
90
|
except Exception as e:
|
|
90
91
|
print(f"设置字体颜色失败: {e}")
|
|
91
92
|
|
|
92
|
-
def sort_by_column(data, col_index,
|
|
93
|
-
if not data or
|
|
93
|
+
def sort_by_column(data, col_index, header_rows=2, reverse=True):
|
|
94
|
+
if not data or header_rows >= len(data):
|
|
94
95
|
return data
|
|
95
96
|
|
|
96
97
|
try:
|
|
97
|
-
header = data[:
|
|
98
|
-
new_data_sorted = data[
|
|
98
|
+
header = data[:header_rows]
|
|
99
|
+
new_data_sorted = data[header_rows:]
|
|
99
100
|
|
|
100
101
|
def get_key(row):
|
|
101
102
|
value = row[col_index]
|
|
@@ -541,11 +542,11 @@ def insert_fixed_scale_image(sheet, cell, image_path, scale=1.0):
|
|
|
541
542
|
|
|
542
543
|
return None
|
|
543
544
|
|
|
544
|
-
def InsertImageV2(
|
|
545
|
+
def InsertImageV2(sheet, columns=None, platform='shein', img_width=150, img_save_key=None, dir_name=None, cell_height_with_img=False, start_row=2):
|
|
545
546
|
if not columns:
|
|
546
547
|
return
|
|
547
548
|
|
|
548
|
-
minimize(app)
|
|
549
|
+
minimize(sheet.book.app)
|
|
549
550
|
|
|
550
551
|
# 清空所有图片
|
|
551
552
|
clear_all_pictures(sheet)
|
|
@@ -574,7 +575,7 @@ def InsertImageV2(app, wb, sheet, columns=None, platform='shein', img_width=150,
|
|
|
574
575
|
|
|
575
576
|
# 预计算所有单元格的合并区域信息 (优化点1)
|
|
576
577
|
area_map = {}
|
|
577
|
-
for row in range(
|
|
578
|
+
for row in range(start_row, last_row + 1):
|
|
578
579
|
log(f'计算 {row}/{last_row}') # 如果数据量非常大,这里的日志会影响性能,可以考虑优化
|
|
579
580
|
for col_letter in col_letter_map.values():
|
|
580
581
|
cell_ref = f'{col_letter}{row}'
|
|
@@ -615,7 +616,7 @@ def InsertImageV2(app, wb, sheet, columns=None, platform='shein', img_width=150,
|
|
|
615
616
|
}
|
|
616
617
|
|
|
617
618
|
# 处理图片插入 (优化点2)
|
|
618
|
-
for row in range(
|
|
619
|
+
for row in range(start_row, last_row + 1):
|
|
619
620
|
for img_col_name, col_letter in col_letter_map.items():
|
|
620
621
|
cell_ref = f'{col_letter}{row}'
|
|
621
622
|
cell_range = sheet.range(cell_ref)
|
|
@@ -1174,8 +1175,8 @@ def write_data(excel_path, sheet_name, data, format_to_text_colunm=None):
|
|
|
1174
1175
|
wb.save()
|
|
1175
1176
|
close_excel(app, wb)
|
|
1176
1177
|
|
|
1177
|
-
def colorize_by_field(
|
|
1178
|
-
minimize(app)
|
|
1178
|
+
def colorize_by_field(sheet, field):
|
|
1179
|
+
minimize(sheet.book.app)
|
|
1179
1180
|
# 读取数据
|
|
1180
1181
|
field_column = find_column_by_data(sheet, 1, field) # 假设 SPU 在 C 列
|
|
1181
1182
|
if field_column is None:
|
|
@@ -2061,6 +2062,21 @@ def format_to_number(sheet, columns=None, decimal_places=2):
|
|
|
2061
2062
|
# else:
|
|
2062
2063
|
# sheet.range(f'{col_name}:{col_name}').number_format = f'0.{"0" * decimal_places}'
|
|
2063
2064
|
|
|
2065
|
+
def hidden_columns(sheet, columns=None):
|
|
2066
|
+
if columns is None:
|
|
2067
|
+
return
|
|
2068
|
+
used_range_col = sheet.range('A1').expand('right')
|
|
2069
|
+
for j, cell in enumerate(used_range_col):
|
|
2070
|
+
col = j + 1
|
|
2071
|
+
col_name = index_to_column_name(col)
|
|
2072
|
+
col_val = sheet.range(f'{col_name}1').value
|
|
2073
|
+
if col_val is None:
|
|
2074
|
+
continue
|
|
2075
|
+
for c in columns:
|
|
2076
|
+
if c in col_val:
|
|
2077
|
+
log(f'设置[{c}] 隐藏')
|
|
2078
|
+
sheet.range(f'{col_name}:{col_name}').column_width = 0
|
|
2079
|
+
|
|
2064
2080
|
def column_to_right(sheet, columns=None):
|
|
2065
2081
|
if columns is None:
|
|
2066
2082
|
return
|
|
@@ -2076,7 +2092,7 @@ def column_to_right(sheet, columns=None):
|
|
|
2076
2092
|
# 水平对齐: # -4108:居中 # -4131:左对齐 # -4152:右对齐
|
|
2077
2093
|
# 垂直对齐: # -4108:居中 # -4160:顶部对齐 # -4107:底部对齐
|
|
2078
2094
|
# 所有列水平居中和垂直居中
|
|
2079
|
-
log(f'设置[{c}]
|
|
2095
|
+
log(f'设置[{c}] 水平右对齐')
|
|
2080
2096
|
sheet.range(f'{col_name}:{col_name}').api.HorizontalAlignment = -4152
|
|
2081
2097
|
sheet.range(f'{col_name}:{col_name}').api.VerticalAlignment = -4108
|
|
2082
2098
|
|
|
@@ -2117,6 +2133,16 @@ def beautify_title(sheet):
|
|
|
2117
2133
|
sheet.range(f'{col_name}:{col_name}').api.VerticalAlignment = -4108
|
|
2118
2134
|
sheet.autofit()
|
|
2119
2135
|
|
|
2136
|
+
def set_body_style(sheet, row_start, row_end=None):
|
|
2137
|
+
if row_end is None:
|
|
2138
|
+
row_end = get_last_used_row(sheet)
|
|
2139
|
+
|
|
2140
|
+
range = sheet.range(f'{row_start}:{row_end}')
|
|
2141
|
+
# 设置字体名称
|
|
2142
|
+
range.font.name = 'Calibri'
|
|
2143
|
+
# 设置字体大小
|
|
2144
|
+
range.font.size = 11
|
|
2145
|
+
|
|
2120
2146
|
def set_title_style(sheet, rows=2):
|
|
2121
2147
|
col = get_max_column_letter(sheet)
|
|
2122
2148
|
range = sheet.range(f'A1:{col}{rows}')
|
|
@@ -2405,6 +2431,7 @@ def format_excel_with_lock(excel_path, sheet_name, format_func, *args, **kwargs)
|
|
|
2405
2431
|
log(f"格式化失败: {e}")
|
|
2406
2432
|
return False
|
|
2407
2433
|
|
|
2434
|
+
# 经过观察 fortmat时 传入函数需要为类函数且第二个参数必须是 sheet
|
|
2408
2435
|
def batch_excel_operations(excel_path, operations):
|
|
2409
2436
|
"""
|
|
2410
2437
|
批量 Excel 操作函数,一次性打开 Excel 执行多个操作
|
|
@@ -2439,17 +2466,19 @@ def batch_excel_operations(excel_path, operations):
|
|
|
2439
2466
|
sheet.activate()
|
|
2440
2467
|
|
|
2441
2468
|
if operation_type == 'write':
|
|
2442
|
-
data, format_to_text_colunm = args[:
|
|
2469
|
+
data, format_to_text_colunm = args[0], args[1:] if len(args) > 1 else None
|
|
2443
2470
|
# 清空工作表
|
|
2444
2471
|
sheet.clear()
|
|
2445
2472
|
# 格式化文本列
|
|
2446
|
-
|
|
2473
|
+
if format_to_text_colunm:
|
|
2474
|
+
format_to_text_v2(sheet, format_to_text_colunm)
|
|
2447
2475
|
# 写入数据
|
|
2448
2476
|
sheet.range('A1').value = data
|
|
2449
2477
|
log(f"批量操作:写入数据到 {sheet_name}")
|
|
2450
2478
|
|
|
2451
2479
|
elif operation_type == 'format':
|
|
2452
2480
|
format_func, format_args = args[0], args[1:] if len(args) > 1 else ()
|
|
2481
|
+
log('格式化入参', *format_args)
|
|
2453
2482
|
# 执行格式化
|
|
2454
2483
|
format_func(sheet, *format_args)
|
|
2455
2484
|
log(f"批量操作:格式化工作表 {sheet_name}")
|
|
@@ -2478,6 +2507,26 @@ def batch_excel_operations(excel_path, operations):
|
|
|
2478
2507
|
finally:
|
|
2479
2508
|
# 释放锁但不关闭 Excel(保持复用)
|
|
2480
2509
|
excel_lock_manager.release_excel_lock(excel_path)
|
|
2510
|
+
close_excel_with_lock(excel_path, app, wb, True)
|
|
2511
|
+
|
|
2512
|
+
def close_excel_file(file_path):
|
|
2513
|
+
file_path = os.path.abspath(file_path).lower()
|
|
2514
|
+
|
|
2515
|
+
for proc in psutil.process_iter(['pid', 'name']):
|
|
2516
|
+
if proc.info['name'] and proc.info['name'].lower() in ['excel.exe', 'wps.exe']: # 只找 Excel
|
|
2517
|
+
try:
|
|
2518
|
+
for f in proc.open_files():
|
|
2519
|
+
if os.path.abspath(f.path).lower() == file_path:
|
|
2520
|
+
print(f"文件被 Excel 占用 (PID: {proc.pid}),正在关闭进程...")
|
|
2521
|
+
proc.terminate()
|
|
2522
|
+
proc.wait(timeout=3)
|
|
2523
|
+
print("已关闭。")
|
|
2524
|
+
return True
|
|
2525
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
2526
|
+
continue
|
|
2527
|
+
|
|
2528
|
+
print("文件没有被 Excel 占用。")
|
|
2529
|
+
return False
|
|
2481
2530
|
|
|
2482
2531
|
def force_close_excel_file(excel_path):
|
|
2483
2532
|
"""
|
|
@@ -2673,3 +2722,6 @@ def get_excel_status(excel_path):
|
|
|
2673
2722
|
'operation_count': excel_lock_manager.get_operation_count(excel_path),
|
|
2674
2723
|
'has_lock' : excel_lock_manager.get_file_lock(excel_path).locked()
|
|
2675
2724
|
}
|
|
2725
|
+
|
|
2726
|
+
def get_last_used_row(sheet):
|
|
2727
|
+
return sheet.used_range.last_cell.row
|