fix: stabilize Shopee cover replacement

This commit is contained in:
chengma
2026-07-01 08:35:46 +08:00
parent f7ea9ec0fa
commit 483bec427c
18 changed files with 782 additions and 330 deletions
+3 -12
View File
@@ -12,9 +12,9 @@ import urllib.error
import urllib.request
import uuid
from . import appconfig, db, diagnostics
from . import appconfig, db, diagnostics, image_paths
from . import prompts as prompt_module
from .config import make_slug
class AIError(RuntimeError):
@@ -390,16 +390,7 @@ def _account_name(task, account_by_alias):
def _new_cover_path(task, account_by_alias, image_root):
alias = str(getattr(task, "alias", "") or "").strip()
account = account_by_alias.get(alias)
slug = getattr(account, "slug", None) if account is not None else None
if not slug:
slug = make_slug(alias or getattr(task, "account_name", "") or "unknown")
return os.path.abspath(
os.path.join(
image_root,
slug,
"%s_new.jpg" % getattr(task, "item_id", ""),
)
)
return image_paths.task_image_path(image_root, task, account, "new")
def _mark_generate_failed(task, exc, db_path, on_task_update):
+107 -69
View File
@@ -5,6 +5,7 @@ import os
import time
from urllib.parse import urlparse
from . import image_paths
from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get
@@ -55,8 +56,8 @@ JS_UPLOAD_STATE = (
"var upload=manager?manager.querySelector('.shopee-image-manager__upload input[type=file]'):null;"
"var uploadBox=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;"
"var busy=manager?[].slice.call(manager.querySelectorAll('[class*=loading],[class*=Loading],[class*=spinner],[class*=Spinner],[class*=progress],[class*=Progress],[class*=uploading],[class*=Uploading]')).filter(visible):[];"
"var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|error|fail|invalid|unsupported)/i;"
"var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过)/i;"
"var errRe=/(失敗|失败|錯誤|错误|不支援|不支持|格式|大小|尺寸|超過|超过|重複|重复|duplicate|error|fail|invalid|unsupported)/i;"
"var uploadRe=/(圖片|图片|封面|照片|相片|圖像|图像|image|photo|cover|upload|上傳|上传|檔案|文件|file|格式|大小|尺寸|像素|解析度|分辨率|超過|超过|重複|重复|duplicate)/i;"
"var texts=manager?[].slice.call(manager.querySelectorAll('*')).filter(visible).map(text).filter(Boolean):[];"
"var errors=texts.filter(function(t){return errRe.test(t);}).slice(0,8);"
"var roots=[].slice.call(document.querySelectorAll('.eds-modal__content,.eds-modal__box,[role=dialog]')).filter(visible);"
@@ -84,6 +85,19 @@ JS_UPLOAD_STATE = (
"});})()"
)
JS_CLICK_UPLOAD_TILE = (
"(function(){"
"function visible(e){if(!e)return false;var r=e.getBoundingClientRect();var s=getComputedStyle(e);"
"return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}"
"var manager=document.querySelector('.shopee-image-manager');"
"var box=manager?manager.querySelector('.shopee-image-manager__upload,[class*=image-manager__upload]'):null;"
"if(!box)return JSON.stringify({clicked:false,reason:'NO_UPLOAD_TILE'});"
"if(!visible(box))return JSON.stringify({clicked:false,reason:'UPLOAD_TILE_HIDDEN'});"
"box.scrollIntoView({block:'center',inline:'center'});"
"box.click();"
"return JSON.stringify({clicked:true,reason:null});"
"})()"
)
JS_TITLE_STATE = (
"(function(){"
f"var r=document.evaluate({json.dumps(TITLE_XPATH)},document,null,"
@@ -317,6 +331,14 @@ def _upload_state(cdp):
return _json_value(cdp, JS_UPLOAD_STATE, default={}) or {}
def _click_upload_tile(cdp):
return _json_value(
cdp,
JS_CLICK_UPLOAD_TILE,
default={"clicked": False, "reason": "NO_UPLOAD_TILE"},
) or {"clicked": False, "reason": "NO_UPLOAD_TILE"}
def _post_update_state(cdp):
return _json_value(cdp, JS_POST_UPDATE_STATE, default=None) or {}
@@ -431,9 +453,24 @@ def _wait_image_manager_stable(
}
def _has_duplicate_upload_error(state):
texts = []
for key in ("errors", "error_toasts", "toasts", "page_error_toasts"):
values = (state or {}).get(key) or []
texts.extend(str(value) for value in values)
return any(
token in text.lower()
for text in texts
for token in ("重複", "重复", "duplicate")
)
def _cover_upload_error_message(result):
reason = result.get("reason") or "COVER_UPDATE_FAILED"
state = result.get("upload_state") or {}
if reason == "UPLOAD_DUPLICATE_IMAGE":
details = state.get("errors") or state.get("error_toasts") or state.get("toasts") or state.get("page_error_toasts") or []
return "新封面与现有商品图片重复:" + ";".join(map(str, details[:3])) if details else "新封面与现有商品图片重复"
if reason == "UPLOAD_PAGE_ERROR":
details = state.get("errors") or state.get("error_toasts") or state.get("toasts") or []
return "新封面上传失败:" + ";".join(map(str, details[:3])) if details else "新封面上传失败"
@@ -443,6 +480,8 @@ def _cover_upload_error_message(result):
return "新封面上传仍在处理中,未取得 Shopee CDN 地址"
if reason == "UPLOAD_TIMEOUT":
return "新封面上传超时,未取得 Shopee CDN 地址"
if reason == "UPLOAD_TILE_NOT_READY":
return "新封面上传入口未可点击,未开始上传新封面"
if reason == "IMAGE_MANAGER_BUSY":
return "商品图片区域仍在加载,未开始上传新封面"
if reason == "IMAGE_COUNT_NOT_READY":
@@ -634,7 +673,7 @@ def collect(account, task, on_step=None) -> dict:
old_cover_src = read_cover_src(cdp)
out_path = _get(task, "old_cover_path")
if not out_path:
out_path = os.path.join(_image_root(account), f"{item_id}_old.jpg")
out_path = image_paths.task_image_path("images", task, account, "old")
_notify_collect_step(on_step, "download_cover")
old_cover_path = download_cover(old_cover_src, out_path)
return {
@@ -694,8 +733,8 @@ def change_title(cdp, new_title) -> dict:
def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict:
"""Upload an image and drag it to the first position.
When the image manager is full, only delete the current first image if the
old cover backup from the collect stage exists locally.
Cover replacement always deletes the current first Shopee image first, and
only proceeds when the old cover backup from the collect stage exists.
"""
image_win_path = os.path.abspath(str(image_win_path))
@@ -726,61 +765,52 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
before = stable.get("rects") or _image_rects(cdp)
count_before = len(before)
delete_result = None
if len(before) >= 9:
backup_path = _validated_old_cover_backup(old_cover_path)
if not backup_path:
return {
"ok": False,
"reason": "OLD_COVER_BACKUP_MISSING",
"count_before": count_before,
"old_cover_path": old_cover_path,
}
delete_result = _delete_first_cover(cdp, before)
if not delete_result.get("ok"):
return {
"ok": False,
"reason": delete_result.get("reason"),
"count_before": count_before,
"delete": delete_result,
}
stable = _wait_image_manager_stable(
cdp,
expected_count=delete_result.get("count_after"),
timeout=30,
settle_seconds=2.0,
require_upload_input=True,
)
if not stable.get("ok"):
return {
"ok": False,
"reason": stable.get("reason"),
"count_before": count_before,
"count_after": stable.get("count_after"),
"delete": delete_result,
"upload_state": stable.get("upload_state"),
"stable": stable,
}
before = stable.get("rects") or _image_rects(cdp)
else:
stable = _wait_image_manager_stable(
cdp,
expected_count=len(before),
timeout=20,
settle_seconds=1.0,
require_upload_input=True,
)
if not stable.get("ok"):
return {
"ok": False,
"reason": stable.get("reason"),
"count_before": count_before,
"count_after": stable.get("count_after"),
"upload_state": stable.get("upload_state"),
"stable": stable,
}
before = stable.get("rects") or _image_rects(cdp)
backup_path = _validated_old_cover_backup(old_cover_path)
if not backup_path:
return {
"ok": False,
"reason": "OLD_COVER_BACKUP_MISSING",
"count_before": count_before,
"old_cover_path": old_cover_path,
}
delete_result = _delete_first_cover(cdp, before)
if not delete_result.get("ok"):
return {
"ok": False,
"reason": delete_result.get("reason"),
"count_before": count_before,
"delete": delete_result,
}
stable = _wait_image_manager_stable(
cdp,
expected_count=delete_result.get("count_after"),
timeout=30,
settle_seconds=2.0,
require_upload_input=True,
)
if not stable.get("ok"):
return {
"ok": False,
"reason": stable.get("reason"),
"count_before": count_before,
"count_after": stable.get("count_after"),
"delete": delete_result,
"upload_state": stable.get("upload_state"),
"stable": stable,
}
before = stable.get("rects") or _image_rects(cdp)
before_srcs = {r.get("src") for r in before}
upload_click = _click_upload_tile(cdp)
if not upload_click.get("clicked"):
return {
"ok": False,
"reason": "UPLOAD_TILE_NOT_READY",
"count_before": count_before,
"delete": delete_result,
"upload_click": upload_click,
"upload_state": _upload_state(cdp),
}
time.sleep(1)
oid = cdp.object_id("document.querySelector('.shopee-image-manager__upload input[type=file]')")
if not oid:
return {
@@ -788,6 +818,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"reason": "NO_UPLOAD_INPUT",
"count_before": count_before,
"delete": delete_result,
"upload_click": upload_click,
"upload_state": _upload_state(cdp),
}
@@ -807,6 +838,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"reason": "UPLOAD_INPUT_NOT_READY",
"count_before": count_before,
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
}
blob_seen = bool(last_state.get("blob_count"))
@@ -816,6 +848,18 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
cur = _image_rects(cdp)
last_state = _upload_state(cdp)
blob_seen = blob_seen or any(str(r.get("src") or "").startswith("blob:") for r in cur)
if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"):
reason = "UPLOAD_DUPLICATE_IMAGE" if _has_duplicate_upload_error(last_state) else "UPLOAD_PAGE_ERROR"
return {
"ok": False,
"reason": reason,
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
"file_size": file_size,
}
ready = [
r for r in cur
if r.get("src") not in before_srcs
@@ -826,16 +870,6 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
if ready and (len(cur) > len(before) or len(ready) == 1):
new_src = ready[-1]["src"]
break
if (last_state.get("errors") or last_state.get("error_toasts")) and not last_state.get("busy_count"):
return {
"ok": False,
"reason": "UPLOAD_PAGE_ERROR",
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_state": last_state,
"file_size": file_size,
}
if last_state.get("crop_modal"):
return {
"ok": False,
@@ -843,6 +877,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
"file_size": file_size,
}
@@ -856,6 +891,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"count_before": count_before,
"count_after": len(cur),
"delete": delete_result,
"upload_click": upload_click,
"upload_state": last_state,
"blob_seen": blob_seen,
"file_size": file_size,
@@ -881,6 +917,7 @@ def replace_cover(cdp, image_win_path, old_cover_path=None, timeout=180) -> dict
"count_before": count_before,
"count_after": len(after),
"delete": delete_result,
"upload_click": upload_click,
}
@@ -1069,6 +1106,7 @@ def _close_applied_product(cdp, close_success_tab=False):
finally:
if close_success_tab and created_by_app and target_id:
try:
time.sleep(2)
close_tab(target_id, host=host)
except Exception:
pass
+167 -114
View File
@@ -85,7 +85,7 @@ QTabBar::tab:hover:!selected {
if QT_IMPORT_ERROR is None:
from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, prompts
from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts
from . import config as account_config
@@ -1157,7 +1157,7 @@ if QT_IMPORT_ERROR is None:
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.risk_label = QLabel("点击「开始更新」后会先确认当前筛选范围;确认后才允许后续任务提交线上。")
self.risk_label = QLabel("可先点击「预览本轮更新」检查当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。")
self.task_table = QTableView()
self.model = ApplyTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
@@ -1172,6 +1172,8 @@ if QT_IMPORT_ERROR is None:
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("运行日志")
self.preview_update_button = QPushButton("预览本轮更新")
self.preview_update_button.setObjectName("previewUpdateButton")
self.start_update_button = QPushButton("开始更新")
self.stop_update_button = QPushButton("停止")
self.reset_update_button = QPushButton("重置更新状态")
@@ -1181,6 +1183,7 @@ if QT_IMPORT_ERROR is None:
self.write_back_button.setEnabled(False)
action_layout = QHBoxLayout()
action_layout.addWidget(self.preview_update_button)
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addWidget(self.reset_update_button)
@@ -1202,6 +1205,7 @@ if QT_IMPORT_ERROR is None:
self.item_filter.textChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.preview_update_button.clicked.connect(self.preview_update)
self.start_update_button.clicked.connect(self.start_update)
self.stop_update_button.clicked.connect(self.stop_update)
self.reset_update_button.clicked.connect(self.reset_apply_status)
@@ -1249,6 +1253,12 @@ if QT_IMPORT_ERROR is None:
self._update_write_back_button()
def start_update(self, checked=False):
self._start_update(dry_run=False)
def preview_update(self, checked=False):
self._start_update(dry_run=True)
def _start_update(self, dry_run=False):
if self.apply_thread is not None:
self._set_status("更新正在进行...")
return
@@ -1260,7 +1270,7 @@ if QT_IMPORT_ERROR is None:
self._set_status("当前筛选结果没有可更新任务")
return
update_cfg = self._shopee_update_config()
dry_run = bool(update_cfg.get("dry_run", False))
dry_run = bool(dry_run)
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
if safety_error:
QMessageBox.warning(self, "更新安全开关", safety_error)
@@ -1268,14 +1278,15 @@ if QT_IMPORT_ERROR is None:
return
answer = QMessageBox.question(
self,
"确认开始更新",
self._confirmation_message(tasks),
"确认预览本轮更新" if dry_run else "确认开始更新",
self._confirmation_message(tasks, dry_run=dry_run),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消开始更新")
self._set_status("已取消预览本轮更新" if dry_run else "已取消开始更新")
return
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
worker = ApplyWorker(
tasks,
db_path=self.db_path,
@@ -1287,6 +1298,7 @@ if QT_IMPORT_ERROR is None:
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
),
batch_size=batch_size,
)
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
@@ -1301,9 +1313,9 @@ if QT_IMPORT_ERROR is None:
self._set_apply_running(True)
self.run_log_view.clear()
if dry_run:
self._set_status(f"开始 dry-run 预览:{len(tasks)} 条")
self._set_status(f"开始预览本轮更新:{len(tasks)} 条")
else:
self._set_status(f"开始更新:{len(tasks)} 条")
self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行")
thread.start()
def stop_update(self, checked=False):
@@ -1466,34 +1478,39 @@ if QT_IMPORT_ERROR is None:
return task.status == "skipped"
return True
def _confirmation_message(self, tasks):
def _confirmation_message(self, tasks, dry_run=False):
update_cfg = self._shopee_update_config()
cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许"
close_text = "是" if update_cfg.get("close_success_tab") else "否"
dry_run_text = "开启" if update_cfg.get("dry_run") else "关闭"
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
parallel_text = (
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
if update_cfg.get("parallel_accounts")
else "关闭"
)
intro = (
"即将预览当前筛选结果。\n\n"
if dry_run
else "即将按当前筛选结果分批更新 Shopee 线上商品。\n\n"
)
return (
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
f"批次:{self._batch_filter_label()}\n"
f"店铺:{self._shop_filter_label()}\n"
f"商品ID:{self._item_filter_label()}\n"
f"状态:{self._status_label()}\n"
f"任务数:{len(tasks)}\n\n"
"安全设置:"
f"测试商品ID={update_cfg.get('test_item_id') or '未配置'},"
f"封面更新={cover_text},"
f"最大条数={update_cfg.get('max_items_per_run', 1)},"
f"成功后关闭新页={close_text},"
f"dry-run={dry_run_text},"
f"多账号并行={parallel_text}\n\n"
intro
+ f"批次:{self._batch_filter_label()}\n"
+ f"店铺:{self._shop_filter_label()}\n"
+ f"商品ID:{self._item_filter_label()}\n"
+ f"状态:{self._status_label()}\n"
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n\n"
+ "安全设置:"
+ f"封面更新={cover_text},"
+ f"每批最大更新条数={batch_size},"
+ f"成功后关闭新页={close_text},"
+ f"多账号并行={parallel_text}\n\n"
+ (
"dry-run 开启时只写运行日志和预览,不打开 Shopee、不点击「更新」、不改任务状态。"
if update_cfg.get("dry_run")
else "确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。"
"预览只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态。"
if dry_run
else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。"
)
)
@@ -1503,20 +1520,6 @@ if QT_IMPORT_ERROR is None:
return None
if not update_cfg.get("allow_real_submit", False):
return "设置未开启「允许真实提交线上商品」,已阻止本次更新。"
max_items = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
if len(tasks) > max_items:
return f"当前筛选结果有 {len(tasks)} 条,超过单次最大更新条数 {max_items}。"
test_item_id = str(update_cfg.get("test_item_id", "")).strip()
if not test_item_id:
return "未配置测试商品ID,已阻止真实提交。"
mismatched = [
str(getattr(task, "item_id", ""))
for task in tasks
if str(getattr(task, "item_id", "")) != test_item_id
]
if mismatched:
shown = "、".join(mismatched[:5])
return f"当前任务包含非测试商品ID:{shown}。只允许更新测试商品 {test_item_id}。"
if not update_cfg.get("allow_cover_update", False):
cover_tasks = [
str(getattr(task, "item_id", ""))
@@ -1537,6 +1540,7 @@ if QT_IMPORT_ERROR is None:
return merged
def _set_apply_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
self.reset_update_button.setEnabled(not running)
@@ -1548,6 +1552,7 @@ if QT_IMPORT_ERROR is None:
self._update_write_back_button()
def _set_result_write_back_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
@@ -1588,7 +1593,7 @@ if QT_IMPORT_ERROR is None:
self._show_apply_blocked(payload)
return
self.last_apply_summary = dict(payload)
prefix = "dry-run 预览完成:" if payload.get("dry_run") else "更新完成:"
prefix = "预览本轮更新完成:" if payload.get("dry_run") else "更新完成:"
message = prefix + self._apply_progress_text(payload)
batch_ids = payload.get("batch_ids") or self._active_batch_ids()
if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids:
@@ -1665,6 +1670,7 @@ if QT_IMPORT_ERROR is None:
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n"
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
@@ -1775,14 +1781,14 @@ if QT_IMPORT_ERROR is None:
def _show_apply_summary(self, apply_summary, write_back_payload=None):
QMessageBox.information(
self,
"dry-run 预览完成" if apply_summary.get("dry_run") else "更新完成",
"预览本轮更新完成" if apply_summary.get("dry_run") else "更新完成",
self._apply_summary_message(apply_summary, write_back_payload),
)
def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None):
dry_run = bool(apply_summary.get("dry_run"))
lines = [
"dry-run 预览完成,未打开 Shopee、未提交线上、未改任务状态。"
"预览本轮更新完成,未打开 Shopee、未提交线上、未改任务状态。"
if dry_run
else "更新完成。",
"{success_label}:{applied},失败:{failed},略过:{skipped}".format(
@@ -1933,6 +1939,7 @@ if QT_IMPORT_ERROR is None:
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"本轮采集已中止,不会自动打开账号 Chrome。\n"
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
@@ -2577,6 +2584,7 @@ if QT_IMPORT_ERROR is None:
dry_run=False,
parallel_accounts=False,
max_parallel_accounts=1,
batch_size=None,
):
super().__init__()
self.tasks = list(tasks)
@@ -2587,6 +2595,9 @@ if QT_IMPORT_ERROR is None:
self.dry_run = bool(dry_run)
self.parallel_accounts = bool(parallel_accounts)
self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1))
self.batch_size = None if batch_size is None else max(1, int(batch_size or 1))
self._current_batch_size = None
self._batch_count = 0
self._progress_lock = threading.Lock()
self._run_id = None
@@ -2600,21 +2611,23 @@ if QT_IMPORT_ERROR is None:
eligible = [task for task in self.tasks if self._is_actionable_task(task)]
batch_ids = self._batch_ids(eligible)
total = len(eligible)
applied = 0
skipped = 0
failed = 0
done = 0
batch_size = self._effective_batch_size(total)
batches = self._task_batches(eligible, batch_size)
self._current_batch_size = batch_size
self._batch_count = len(batches)
counters = {
"done": done,
"applied": applied,
"skipped": skipped,
"failed": failed,
"done": 0,
"applied": 0,
"skipped": 0,
"failed": 0,
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
"运行开始:{mode},任务{total},{parallel}".format(
mode="dry-run 预览" if self.dry_run else "真实更新",
"运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
mode="预览本轮更新" if self.dry_run else "真实更新",
total=total,
batch_size=batch_size,
batch_count=len(batches),
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
if self.parallel_accounts
@@ -2637,20 +2650,24 @@ if QT_IMPORT_ERROR is None:
self._finish_run_log("blocked", summary)
return summary
if self.dry_run:
for task in eligible:
if self.should_cancel():
break
outcome = self._preview_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
elif self.parallel_accounts and self.max_parallel_accounts > 1:
self._run_parallel_by_account(eligible, account_by_alias, counters, total)
else:
for task in eligible:
if self.should_cancel():
break
outcome = self._apply_one_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
for batch_index, batch_tasks in enumerate(batches, start=1):
if self.should_cancel():
break
self._log_batch_start(batch_index, len(batches), batch_tasks, counters, total)
if self.dry_run:
for task in batch_tasks:
if self.should_cancel():
break
outcome = self._preview_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
elif self.parallel_accounts and self.max_parallel_accounts > 1:
self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total)
else:
for task in batch_tasks:
if self.should_cancel():
break
outcome = self._apply_one_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
summary = self._summary(
ok=counters["failed"] == 0,
@@ -2729,6 +2746,27 @@ if QT_IMPORT_ERROR is None:
)
return duplicates
def _effective_batch_size(self, total):
if self.batch_size is None:
return max(1, int(total or 1))
return self.batch_size
def _task_batches(self, tasks, batch_size):
if not tasks:
return []
return [
tasks[index:index + batch_size]
for index in range(0, len(tasks), batch_size)
]
def _log_batch_start(self, batch_index, batch_count, batch_tasks, counters, total):
first = counters["done"] + 1
last = min(first + len(batch_tasks) - 1, total)
label = "预览批次" if self.dry_run else "更新批次"
self._log_run_event(
f"{label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}"
)
def _run_parallel_by_account(self, eligible, account_by_alias, counters, total):
groups = self._group_tasks_by_alias(eligible)
max_workers = min(self.max_parallel_accounts, len(groups))
@@ -2773,7 +2811,7 @@ if QT_IMPORT_ERROR is None:
if account is None:
reason = "别名未匹配账号"
self._log_run_event(
f"dry-run:任务 {task.id} 商品 {task.item_id} 将略过:{reason}",
f"预览:任务 {task.id} 商品 {task.item_id} 将略过:{reason}",
task=task,
level="warning",
)
@@ -2785,7 +2823,7 @@ if QT_IMPORT_ERROR is None:
action_parts.append("封面")
action_text = "+".join(action_parts) or "无变更"
self._log_run_event(
"dry-run:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format(
"预览:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format(
task_id=task.id,
item_id=task.item_id,
alias=account.alias,
@@ -2903,6 +2941,8 @@ if QT_IMPORT_ERROR is None:
"skipped": skipped,
"failed": failed,
"dry_run": self.dry_run,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
}
)
@@ -2938,6 +2978,8 @@ if QT_IMPORT_ERROR is None:
"batch_ids": batch_ids,
"dry_run": self.dry_run,
"parallel_accounts": self.parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"run_id": self._run_id,
}
if blocked:
@@ -2958,6 +3000,8 @@ if QT_IMPORT_ERROR is None:
"dry_run": self.dry_run,
"parallel_accounts": self.parallel_accounts,
"max_parallel_accounts": self.max_parallel_accounts,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
},
path=self.db_path,
)
@@ -3284,13 +3328,7 @@ if QT_IMPORT_ERROR is None:
def _old_cover_path(self, account, task):
image_root = appconfig.image_dir(self.config)
return os.path.abspath(
os.path.join(
image_root,
account.slug,
f"{task.item_id}_old.jpg",
)
)
return image_paths.task_image_path(image_root, task, account, "old")
def _batch_ids(self, tasks):
batch_ids = []
@@ -3630,6 +3668,7 @@ if QT_IMPORT_ERROR is None:
self.save_config_button = QPushButton("保存设置")
self.test_item_id_edit = QLineEdit()
self.test_item_id_edit.setObjectName("testItemIdEdit")
self.test_item_id_edit.setVisible(False)
self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品")
self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox")
self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
@@ -3637,15 +3676,27 @@ if QT_IMPORT_ERROR is None:
self.max_items_per_run_spin = QSpinBox()
self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin")
self.max_items_per_run_spin.setRange(1, 9999)
self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。")
self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
self.dry_run_checkbox = QCheckBox("dry-run 只预览不提交")
self.dry_run_checkbox = QCheckBox("预览本轮更新")
self.dry_run_checkbox.setObjectName("dryRunCheckbox")
self.dry_run_checkbox.setVisible(False)
self.parallel_accounts_checkbox = QCheckBox("多账号并行更新")
self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox")
self.max_parallel_accounts_spin = QSpinBox()
self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin")
self.max_parallel_accounts_spin.setRange(1, 16)
self.max_parallel_accounts_label = QLabel("最大并行账号数")
self.parallel_accounts_group = QWidget()
self.parallel_accounts_group.setObjectName("parallelAccountsGroup")
parallel_accounts_layout = QHBoxLayout(self.parallel_accounts_group)
parallel_accounts_layout.setContentsMargins(0, 0, 0, 0)
parallel_accounts_layout.setSpacing(12)
parallel_accounts_layout.addWidget(self.parallel_accounts_checkbox)
parallel_accounts_layout.addWidget(self.max_parallel_accounts_label)
parallel_accounts_layout.addWidget(self.max_parallel_accounts_spin)
parallel_accounts_layout.addStretch(1)
model_picker_layout = QHBoxLayout()
model_picker_layout.addWidget(self.model_combo, 1)
@@ -3657,7 +3708,7 @@ if QT_IMPORT_ERROR is None:
action_layout.addWidget(self.test_connection_button)
action_layout.addStretch(1)
form = self._two_column_form(
form = self._three_column_form(
[
("状态", self.enabled_checkbox),
("服务商名", self.name_edit),
@@ -3670,7 +3721,7 @@ if QT_IMPORT_ERROR is None:
]
)
ai_form = self._two_column_form(
ai_form = self._three_column_form(
[
("标题大模型", self.default_text_model_combo),
("图片大模型", self.default_image_model_combo),
@@ -3691,7 +3742,7 @@ if QT_IMPORT_ERROR is None:
port_range_widget = QWidget()
port_range_widget.setLayout(port_range_layout)
path_form = self._two_column_form(
path_form = self._three_column_form(
[
("Chrome路径", self.chrome_path_edit, True),
("账号数据根目录", self.user_data_root_edit),
@@ -3703,22 +3754,20 @@ if QT_IMPORT_ERROR is None:
]
)
update_form = self._two_column_form(
self.shopee_update_form_layout = self._three_column_form(
[
("测试商品ID", self.test_item_id_edit),
("单次最大更新条数", self.max_items_per_run_spin),
("每批最大更新条数", self.max_items_per_run_spin),
("", self.allow_real_submit_checkbox),
("", self.allow_cover_update_checkbox),
("", self.close_success_tab_checkbox),
("", self.dry_run_checkbox),
("", self.parallel_accounts_checkbox),
("最大并行账号数", self.max_parallel_accounts_spin),
("", self.allow_cover_update_checkbox),
("", self.parallel_accounts_group, 2),
]
)
panel = QWidget()
panel.setMaximumWidth(1800)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.setContentsMargins(13, 18, 13, 18)
panel_layout.addWidget(QLabel("AI 模型"))
panel_layout.addLayout(model_picker_layout)
panel_layout.addSpacing(14)
@@ -3734,13 +3783,19 @@ if QT_IMPORT_ERROR is None:
panel_layout.addLayout(path_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("Shopee 更新安全"))
panel_layout.addLayout(update_form)
panel_layout.addLayout(self.shopee_update_form_layout)
panel_layout.addWidget(self.save_config_button)
panel_layout.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(panel)
scroll_content = QWidget()
scroll_layout = QHBoxLayout(scroll_content)
scroll_layout.setContentsMargins(0, 0, 0, 0)
scroll_layout.addStretch(1)
scroll_layout.addWidget(panel)
scroll_layout.addStretch(1)
scroll.setWidget(scroll_content)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
@@ -3759,46 +3814,43 @@ if QT_IMPORT_ERROR is None:
self.refresh_models()
self._populate_app_settings()
def _two_column_form(self, fields):
def _three_column_form(self, fields):
layout = QGridLayout()
layout.setHorizontalSpacing(18)
layout.setVerticalSpacing(8)
layout.setColumnStretch(1, 1)
layout.setColumnStretch(3, 1)
for column in (1, 3, 5):
layout.setColumnStretch(column, 1)
row = 0
column_pair = 0
for field in fields:
label = field[0]
widget = field[1]
full_width = len(field) > 2 and bool(field[2])
if full_width and column_pair:
span_pairs = self._form_field_span_pairs(field)
if span_pairs > 3 - column_pair:
row += 1
column_pair = 0
column = column_pair * 2
self._add_form_field(layout, row, column, label, widget, full_width)
if full_width:
row += 1
column_pair = 0
elif column_pair == 0:
column_pair = 1
else:
self._add_form_field(layout, row, column, label, widget, span_pairs)
column_pair += span_pairs
if column_pair >= 3:
row += 1
column_pair = 0
return layout
def _add_form_field(self, layout, row, column, label, widget, full_width):
if full_width:
if label:
layout.addWidget(QLabel(label), row, 0)
layout.addWidget(widget, row, 1, 1, 3)
else:
layout.addWidget(widget, row, 0, 1, 4)
return
def _form_field_span_pairs(self, field):
if len(field) <= 2:
return 1
span = field[2]
if isinstance(span, bool):
return 3 if span else 1
return max(1, min(3, int(span or 1)))
def _add_form_field(self, layout, row, column, label, widget, span_pairs):
if label:
layout.addWidget(QLabel(label), row, column)
layout.addWidget(widget, row, column + 1)
layout.addWidget(widget, row, column + 1, 1, span_pairs * 2 - 1)
else:
layout.addWidget(widget, row, column, 1, 2)
layout.addWidget(widget, row, column, 1, span_pairs * 2)
def _set_status(self, message):
if self.status_callback is not None:
@@ -3939,6 +3991,7 @@ if QT_IMPORT_ERROR is None:
self._replace_config(saved)
self._populate_app_settings()
self._set_status("设置已保存")
QMessageBox.information(self, "保存设置", "设置已保存")
def _app_settings_values(self):
start_port = self.debug_port_start_spin.value()
@@ -3991,7 +4044,7 @@ if QT_IMPORT_ERROR is None:
"allow_cover_update": self.allow_cover_update_checkbox.isChecked(),
"max_items_per_run": self.max_items_per_run_spin.value(),
"close_success_tab": self.close_success_tab_checkbox.isChecked(),
"dry_run": self.dry_run_checkbox.isChecked(),
"dry_run": False,
"parallel_accounts": self.parallel_accounts_checkbox.isChecked(),
"max_parallel_accounts": self.max_parallel_accounts_spin.value(),
},
@@ -4059,7 +4112,7 @@ if QT_IMPORT_ERROR is None:
self.close_success_tab_checkbox.setChecked(
bool(update_cfg.get("close_success_tab", False))
)
self.dry_run_checkbox.setChecked(bool(update_cfg.get("dry_run", False)))
self.dry_run_checkbox.setChecked(False)
self.parallel_accounts_checkbox.setChecked(
bool(update_cfg.get("parallel_accounts", False))
)
+48
View File
@@ -0,0 +1,48 @@
import os
from .config import make_slug
def task_image_path(image_root, task, account=None, suffix="old", ext=".jpg"):
batch_id = _safe_component(_get(task, "batch_id"), "unknown_batch")
slug = _account_slug(account, task)
task_id = _safe_component(_get(task, "id"), "task")
item_id = _safe_component(_get(task, "item_id"), "item")
suffix = _safe_component(suffix, "image")
ext = str(ext or ".jpg")
if not ext.startswith("."):
ext = "." + ext
return os.path.abspath(
os.path.join(
str(image_root or "images"),
batch_id,
slug,
f"{task_id}_{item_id}_{suffix}{ext}",
)
)
def _account_slug(account, task):
slug = _get(account, "slug")
if slug:
return _safe_component(slug, "unknown_account")
alias = _get(task, "alias") or _get(account, "alias") or _get(task, "account_name") or _get(account, "account_name")
if alias:
return _safe_component(make_slug(alias), "unknown_account")
return "unknown_account"
def _get(obj, name, default=None):
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(name, default)
return getattr(obj, name, default)
def _safe_component(value, default):
text = str(value or "").strip()
if not text:
text = str(default)
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in text).strip("_")
return safe or str(default)