diff --git a/app/amazon/__pycache__/approve.cpython-39.pyc b/app/amazon/__pycache__/approve.cpython-39.pyc index 694a2d5..b1fa641 100644 Binary files a/app/amazon/__pycache__/approve.cpython-39.pyc and b/app/amazon/__pycache__/approve.cpython-39.pyc differ diff --git a/app/amazon/__pycache__/del_brand.cpython-39.pyc b/app/amazon/__pycache__/del_brand.cpython-39.pyc index 8551dba..0e5aacf 100644 Binary files a/app/amazon/__pycache__/del_brand.cpython-39.pyc and b/app/amazon/__pycache__/del_brand.cpython-39.pyc differ diff --git a/app/amazon/__pycache__/match_action.cpython-39.pyc b/app/amazon/__pycache__/match_action.cpython-39.pyc index 7d42202..cf06b6f 100644 Binary files a/app/amazon/__pycache__/match_action.cpython-39.pyc and b/app/amazon/__pycache__/match_action.cpython-39.pyc differ diff --git a/app/amazon/approve.py b/app/amazon/approve.py index 5577327..ce9a933 100644 --- a/app/amazon/approve.py +++ b/app/amazon/approve.py @@ -2,6 +2,7 @@ import time import re import traceback from DrissionPage._pages.chromium_tab import ChromiumTab +import pyautogui from config import runing_task, runing_shop from datetime import datetime @@ -86,10 +87,11 @@ class AmzoneApprove(AmamzonBase): def wait_loaded(self): # 等待加载完成 try: - load_ele = self.tab.ele( + load_ele = self.tab.eles( 'xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//div[contains(@class,"Loader-module__loader")]|//kat-panel[@data-testid="kat-panel-ActionPanelContent"]/div[@data-f1-component]//div[contains(@class,"==")]/div[contains(@class,"==")]/span', timeout=3) - load_ele.wait.deleted(timeout=5, raise_err=False) + if len(load_ele) > 0: + load_ele[0].wait.deleted(timeout=5, raise_err=False) except Exception as e: print("等待加载中消失出错", e) @@ -106,67 +108,317 @@ class AmzoneApprove(AmamzonBase): print("当前操作的tab_id",self.tab.tab_id) self.browser.close_tabs(close_tab) - def handle_repair_product(self,tab:ChromiumTab): - def wait_loaded(): + # def handle_repair_product_old(self,tab:ChromiumTab): + # def wait_loaded(): + # # 等待加载完成 + # try: + # load_ele = tab.ele( + # 'xpath://div[@class="contentWrapper"]//div[contains(@class,"==")]/span[not(node())]', + # timeout=3) + # load_ele.wait.deleted(timeout=5, raise_err=False) + # except Exception as e: + # print("等待加载中消失出错", e) + + # tab.wait.doc_loaded(timeout=60,raise_err=False) + # submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10) + # if len(submit_btn_ls) == 0: + # print("未找到提交按钮,可能页面未加载完成或者页面结构发生变化") + # # return + # for _ in range(3): + # try: + # # todo 遍历 + # for index in range(len(submit_btn_ls)): + # if index > len(submit_btn_ls)-1: + # print("提交按钮数量发生变化,停止处理") + # break + + # submit_btn = submit_btn_ls[index] + + # # 关闭按钮 + # close_x_btn = tab.eles('xpath://div[@class="flyoutPanelContent"]/span',timeout=5) + # if len(close_x_btn) > 0: + # close_x_btn[0].wait.displayed(timeout=5, raise_err=False) + # close_x_btn[0].click() + + # submit_btn.wait.enabled(timeout=5, raise_err=False) + # submit_btn.click() + # time.sleep(0.5) + # self.wait_loaded() + + # wait_loaded() + + # need_input = tab.eles('xpath://div[@class="contentWrapper"]//kat-input[@data-testid="kat-input-dew:ump_epr_resgitration_number_title"]',timeout=5) + # if len(need_input) > 0: + # print("需要输入注册号,不符合操作要求,跳过...") + # continue + + # target_title = tab.eles('xpath://div[@class="contentWrapper"]//section//h4[text()="警告和安全信息"]') + # if len(target_title) > 0: + # not_start = tab.eles('xpath://div[@class="contentWrapper"]//div[@aria-label="安全证明"]//kat-label[@text="未开始"]') + # if len(not_start) == 0: + # print("安全证明-不是未开始状态,不需要操作") + # continue + + # not_start.wait.displayed(timeout=5, raise_err=False) + # not_start.click() + + # wait_loaded() + + # check = tab.ele('xpath://div[@class="contentWrapper"]//kat-checkbox[@name="value"]') + # check.wait.displayed(timeout=5, raise_err=False) + # check.click() + + # save_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]') + # save_btn.wait.enabled(timeout=5, raise_err=False) + # save_btn.click() + + # wait_loaded() + + # close_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@label="关闭"]',timeout=10) + # close_btn.wait.displayed(timeout=5, raise_err=False) + # close_btn.click() + + # save_btn_ls = tab.eles('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]',timeout=5) + # if len(save_btn_ls) > 0: + # option_selection_ls = tab.eles('xpath://div[@class="contentWrapper"]//div[@role="option"]',timeout=5) + # for option in option_selection_ls: + # option.click() + # time.sleep(0.5) + + # save_btn_ls[0].wait.enabled(timeout=5, raise_err=False) + # save_btn_ls[0].click() + + # wait_loaded() + + # close_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@label="关闭"]',timeout=10) + # close_btn.wait.displayed(timeout=5, raise_err=False) + # close_btn.wait.enabled(timeout=5, raise_err=False) + # close_btn.click() + + # submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10) + # break + # except Exception as e: + # print(f"【handle_repair_product】处理修复商品信息异常", traceback.format_exc()) + # tab.refresh() + # tab.wait.doc_loaded(timeout=60,raise_err=False) + # time.sleep(2) + + # print("修复商品信息处理完成!") + # tab.close() + + def handle_repair_product(self,tab:ChromiumTab,headle_type=""): + def wait_loaded(wait_type="contentWrapper"): # 等待加载完成 - try: - load_ele = tab.ele( - 'xpath://div[@class="contentWrapper"]//div[contains(@class,"==")]/span[not(node())]', - timeout=3) - load_ele.wait.deleted(timeout=5, raise_err=False) - except Exception as e: - print("等待加载中消失出错", e) + print("------------【修复商品信息】等待加载完成-----------------") + for _ in range(3): + try: + load_ele = tab.eles( + 'xpath://div[@class="contentWrapper"]//div[contains(@class,"==")]/span[not(node())]|//kat-spinner[@id="ahd-app-page-spinner"]', + timeout=10) + if len(load_ele) > 0: + load_ele[0].wait.deleted(timeout=20, raise_err=False) + if wait_type == "contentWrapper": + contentWrapper = tab.eles('xpath://div[@class="contentWrapper"]//h4',timeout=20) + if len(contentWrapper) > 0: + contentWrapper[0].wait.displayed(timeout=20, raise_err=True) + break + else: + time.sleep(2) + break + if wait_type == "content": + contentWrapper = tab.eles('xpath://div[@class="contentWrapper"]',timeout=20) + if len(contentWrapper) > 0: + contentWrapper[0].wait.displayed(timeout=20, raise_err=True) + break + else: + time.sleep(2) + break + if wait_type == "section": + section = tab.eles('xpath://section[@class="ahd-product-policy__section"]',timeout=20) + if len(section) > 0: + section[0].wait.displayed(timeout=20, raise_err=True) + break + except Exception as e: + print("等待加载中消失出错", e) + print("-----------------------------") + + def close_contentWrapper(): + # 关闭按钮 + # close_x_btn = tab.eles('xpath://div[@class="flyoutPanelContent"]/span',timeout=5) + # if len(close_x_btn) > 0: + # close_x_btn[0].wait.displayed(timeout=5, raise_err=False) + # close_x_btn[0].click() + # time.sleep(1) + # close_x_btn[0].wait.deleted(timeout=5, raise_err=False) + close_x_btn = tab.eles('xpath://div[@class="flyoutPanelContent"]/span',timeout=5) + contentWrapper = tab.eles('xpath://div[@class="flyoutPanelContent"]/div[@class="contentWrapper"]',timeout=5) + if len(close_x_btn) > 0 and len(contentWrapper)>0 : + if len(contentWrapper[0].children()) > 0: + close_x_btn[0].wait.displayed(timeout=5, raise_err=False) + close_x_btn[0].click() tab.wait.doc_loaded(timeout=60,raise_err=False) - submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10) - if len(submit_btn_ls) == 0: - print("未找到提交按钮,可能页面未加载完成或者页面结构发生变化") + + wait_loaded(wait_type="section") + # product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy"]') + product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy" and @class="ahd-product-policy-table-row-wrapper"]|//div[@class="ahd-product-policy-table-row-wrapper-nested" and @data-testid="ahd-desktop-product-policy"]') + if len(product_policy) == 0: + print("未找到提交处理区域,可能页面未加载完成或者页面结构发生变化") + time.sleep(5) + # product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy"]') + product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy" and @class="ahd-product-policy-table-row-wrapper"]|//div[@class="ahd-product-policy-table-row-wrapper-nested" and @data-testid="ahd-desktop-product-policy"]') + + print("等待5秒后再次查找提交处理区域:",len(product_policy)) + + # submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10) + # if len(submit_btn_ls) == 0: # return + now_index = 0 + submit_index = 0 for _ in range(3): + try: + # 先全部点开 + chevron_right_ls = tab.eles('xpath://kat-icon[@name="chevron-right"]',timeout=10) + for ele in chevron_right_ls: + ele.click() + time.sleep(0.5) + except Exception as e: + print("【修复商品信息】全部点开失败",e) try: # todo 遍历 - for index in range(len(submit_btn_ls)): - if index > len(submit_btn_ls)-1: - print("提交按钮数量发生变化,停止处理") + print("【商品修复产品卡片】数量",len(product_policy)) + print("【商品修复产品卡片】列表",product_policy) + for index in range(now_index,len(product_policy)): + product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy" and @class="ahd-product-policy-table-row-wrapper"]|//div[@class="ahd-product-policy-table-row-wrapper-nested" and @data-testid="ahd-desktop-product-policy"]') + now_index = index + if index > len(product_policy)-1: + print("提交按钮区域发生变化,停止处理") break + message_link = product_policy[index].eles('xpath:.//a[@data-testid="ahd-reason-message-link"]') + message = message_link[0].text - submit_btn = submit_btn_ls[index] + if headle_type in ['查看冻结原因']: + need_heanle = any(i in message for i in ["制造商详细联系信息","警告和安全信息","负责人详细联系信息"]) + else: + need_heanle = True + + if not need_heanle: + print(f"【{headle_type}】【{message}】不需要处理,跳过") + continue + spread_btn = product_policy[index].eles('xpath:.//kat-icon[@name="chevron-right"]',timeout=5) + print(f"{index},是否存在展开按钮",len(spread_btn)) + if len(spread_btn) > 0: + spread_btn[0].click() + time.sleep(0.5) + + submit_btn_ls = product_policy[index].eles('xpath:.//kat-button[@label="提交"]',timeout=5) + # try: + # submit_btn = product_policy[index].ele('xpath:.//kat-button[@label="提交"]',timeout=5) + # except Exception as e: + # print(datetime.now().strftime("%Y-%m-%d %H:%M:%S") ,"没有查找到提交按钮,跳过!") + # close_contentWrapper() + # continue + # print("提交按钮数量:",len(submit_btn)) + print("提交按钮列表:",submit_btn_ls) + if len(submit_btn_ls) > 0: + print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"),"查找到提交按钮") + # submit_btn = submit_btn[0] + submit_btn = tab.eles('xpath://kat-button[@label="提交"]',timeout=10)[submit_index] + submit_index += 1 + else: + print(datetime.now().strftime("%Y-%m-%d %H:%M:%S") ,"没有查找到提交按钮,跳过!") + close_contentWrapper() + continue + # time.sleep(10) # 关闭按钮 close_x_btn = tab.eles('xpath://div[@class="flyoutPanelContent"]/span',timeout=5) - if len(close_x_btn) > 0: - close_x_btn[0].wait.displayed(timeout=5, raise_err=False) - close_x_btn[0].click() + contentWrapper = tab.eles('xpath://div[@class="flyoutPanelContent"]/div[@class="contentWrapper"]',timeout=5) + if len(close_x_btn) > 0 and len(contentWrapper)>0 : + if len(contentWrapper[0].children()) > 0: + close_x_btn[0].wait.displayed(timeout=5, raise_err=False) + close_x_btn[0].click() + print("开始操作",submit_btn) + print("操作的ID 为:",submit_btn.attr('id')) submit_btn.wait.enabled(timeout=5, raise_err=False) submit_btn.click() - time.sleep(0.5) - self.wait_loaded() + + # 先直接等待contentWrapper出现,不要进行复杂的元素查找 + try: + # 使用简单的等待,避免触发页面事件 + for check_count in range(10): + contentWrapper_exists = tab.eles('xpath://div[@class="contentWrapper"]', timeout=2) + if len(contentWrapper_exists) > 0: + print(f"【弹窗已出现】第{check_count+1}次检查") + time.sleep(2) # 再等待2秒让内容完全加载 + break + time.sleep(1) + except Exception as e: + print(f"等待弹窗出现异常: {e}") + + # wait_loaded(wait_type="content") + + for j in range(4): + if j == 0: + timeout = 5 + else: + timeout = 10 + contentWrapper = tab.eles('xpath://div[@class="contentWrapper"]//h4',timeout=timeout) + print("标题的数量",len(contentWrapper)) + if len(contentWrapper) > 0: + contentWrapper[0].wait.displayed(timeout=20, raise_err=True) + break + else: + # 关闭按钮 + close_contentWrapper() + time.sleep(2) + submit_btn.click() - wait_loaded() need_input = tab.eles('xpath://div[@class="contentWrapper"]//kat-input[@data-testid="kat-input-dew:ump_epr_resgitration_number_title"]',timeout=5) if len(need_input) > 0: print("需要输入注册号,不符合操作要求,跳过...") + close_contentWrapper() continue - target_title = tab.eles('xpath://div[@class="contentWrapper"]//section//h4[text()="警告和安全信息"]') + target_title = tab.eles('xpath://div[@class="contentWrapper"]//section//h4[text()="警告和安全信息"]',timeout=10) + print(f"序号{index}【警告和安全信息】数量 ->",len(target_title)) if len(target_title) > 0: - not_start = tab.eles('xpath://div[@class="contentWrapper"]//div[@aria-label="安全证明"]//kat-label[@text="未开始"]') - if len(not_start) == 0: - print("安全证明-不是未开始状态,不需要操作") + is_Continue = False + for j in range(10): + not_start = tab.eles('xpath://div[@class="contentWrapper"]//div[@aria-label="安全证明"]//kat-label',timeout=2) + if len(not_start) > 0: + status_lebel = not_start[0].attr("text") + print(f"序号{index}【警告和安全信息】状态 ->",status_lebel) + if status_lebel and str(status_lebel).strip() != "" and status_lebel != "未开始": + print("安全证明-不是未开始状态,不需要操作") + close_contentWrapper() + continue + if status_lebel == "未开始": + is_Continue = True + break + else: + time.sleep(1) + # not_start = tab.eles('xpath://div[@class="contentWrapper"]//div[@aria-label="安全证明"]//kat-label[@text="未开始"]',timeout=10) + # if len(not_start) == 0: + # print("安全证明-不是未开始状态,不需要操作") + # close_contentWrapper() + # continue + print(f"序号{index}【警告和安全信息】是否需要继续 ->",is_Continue) + if not is_Continue: continue - + not_start = not_start[0] not_start.wait.displayed(timeout=5, raise_err=False) not_start.click() wait_loaded() - check = tab.ele('xpath://div[@class="contentWrapper"]//kat-checkbox[@name="value"]') + check = tab.ele('xpath://div[@class="contentWrapper"]//kat-checkbox[@name="value"]',timeout=10) check.wait.displayed(timeout=5, raise_err=False) check.click() - save_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]') + save_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]',timeout=10) save_btn.wait.enabled(timeout=5, raise_err=False) save_btn.click() @@ -175,8 +427,11 @@ class AmzoneApprove(AmamzonBase): close_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@label="关闭"]',timeout=10) close_btn.wait.displayed(timeout=5, raise_err=False) close_btn.click() + print(f"序号{index}【警告和安全信息】操作完成") + continue - save_btn_ls = tab.eles('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]',timeout=5) + save_btn_ls = tab.eles('xpath://div[@class="contentWrapper"]//kat-button[@variant="primary"]',timeout=10) + print(f"序号{index}【选择保存】是否存在->",len(save_btn_ls)) if len(save_btn_ls) > 0: option_selection_ls = tab.eles('xpath://div[@class="contentWrapper"]//div[@role="option"]',timeout=5) for option in option_selection_ls: @@ -187,29 +442,39 @@ class AmzoneApprove(AmamzonBase): save_btn_ls[0].click() wait_loaded() + close_contentWrapper() + print(f"序号{index}【选择保存】操作完成") + continue - close_btn = tab.ele('xpath://div[@class="contentWrapper"]//kat-button[@label="关闭"]',timeout=10) - close_btn.wait.displayed(timeout=5, raise_err=False) - close_btn.wait.enabled(timeout=5, raise_err=False) - close_btn.click() submit_btn_ls = tab.eles('xpath://div[@id="ahd-product-policies-table"]/div//div[@data-testid="nextStepsMetricWrapper"]//kat-button[@label="提交"]',timeout=10) + if len(submit_btn_ls) > 0: + submit_btn_ls[0].click() + submit_btn_ls[0].wait.deleted(timeout=5, raise_err=False) + wait_loaded() + + close_contentWrapper() break except Exception as e: print(f"【handle_repair_product】处理修复商品信息异常", traceback.format_exc()) tab.refresh() tab.wait.doc_loaded(timeout=60,raise_err=False) time.sleep(2) + wait_loaded(wait_type="section") + # product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy"]') + product_policy = tab.eles('xpath://div[@data-testid="ahd-desktop-product-policy" and @class="ahd-product-policy-table-row-wrapper"]|//div[@class="ahd-product-policy-table-row-wrapper-nested" and @data-testid="ahd-desktop-product-policy"]') + print("修复商品信息处理完成!") tab.close() + def run_page_action(self): print("开始执行") num = 0 retry_num = 0 already_asin = set() - + now_page = None while retry_num < 3: # 最多重试3次 # if num > 3: #测试 # return @@ -235,20 +500,27 @@ class AmzoneApprove(AmamzonBase): print(f"当前页码: {current_page} / 总页数: {total_page}") except Exception as e: print("获取页码失败", e) - + print("==========商品风险解决==============") sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=10) print(f"获取到 {len(sku_ls)}") + if len(sku_ls) == 0: + print(f"搜索不出SKU停止") + break # for sku_ele in sku_ls[0:2]: - for sku_ele in sku_ls: + for sku_ele in sku_ls[1:]: # solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]') asin = sku_ele.ele('xpath:.//div[contains(@class,"JanusSplitBox-module__container")]//div[contains(@class,"JanusSplitBox-module__panel--") and contains(string(.),"ASIN")]/..//div[last()]').text + print(f"*************** {asin} *****************") print(f"ASIN {asin} 存在问题,正在点击解决...") if asin in already_asin: print(f"{asin} 已经处理过了,跳过") continue - solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]|.//kat-link[@label="修复被禁止显示的商品"]|.//kat-link[@label="解决商品移除风险"]') + xp_ls = [f'.//kat-link[@label="{i}"]' for i in ["解决商品信息问题","修复被禁止显示的商品","解决商品移除风险","查看冻结原因"]] + xp = "|".join(xp_ls) + # solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]|.//kat-link[@label="修复被禁止显示的商品"]|.//kat-link[@label="解决商品移除风险"]') + solve_problem = sku_ele.eles(f'xpath:{xp}') if len(solve_problem) == 0: # //a[text0=”添加缺失的商品详情”] lack_info = sku_ele.eles('xpath:.//a[text()="添加缺失的商品详情"]') @@ -264,6 +536,9 @@ class AmzoneApprove(AmamzonBase): solve_problem[0].click() + label = solve_problem[0].attr('label') + print("【当前类别】",label) + action_panel = self.tab.ele('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]') action_panel.wait.displayed(timeout=5, raise_err=False) @@ -274,15 +549,34 @@ class AmzoneApprove(AmamzonBase): # 如果存在“请求批准”按钮,则直接返回跳过 request_approval_btn = self.tab.eles('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//div[@data-testid="section-header" and contains(string(.),"移除")]',timeout=5) if len(request_approval_btn) > 0: - print(f"ASIN {asin} 存在请求批准按钮,不需要处理,跳过...") - panel_close_btn = self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]', - timeout=5).sr('xpath:.//button[@class="close"]') - panel_close_btn.click() + # 新增"查看冻结原因" 的情况下,需要点击修复商品详情 + if "查看冻结原因" in label: + repair_produ_btn_xp = '//kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//kat-button[@variant="secondary" and @class="action-button" and contains(string(.),"修复商品信息")]' + repair_produ_btn = self.tab.eles(f'xpath:{repair_produ_btn_xp}',timeout=5) + print(f"{asin}【查看冻结原因】修复商品信息数量",len(repair_produ_btn)) + if len(repair_produ_btn) > 0: + print(f"{asin}【查看冻结原因】修复商品信息数量,开始操作") + self.clear_tab() + repair_produ_btn[0].click() + time.sleep(3) + # 获取最新的tab + new_tab = self.browser.latest_tab + if isinstance(new_tab, str): + new_tab = self.browser.get_tab(id_or_num=new_tab) - action_panel.wait.deleted(timeout=3, raise_err=False) - yield (asin,"请求批准") - already_asin.add(asin) - continue + self.handle_repair_product(new_tab,headle_type="查看冻结原因") + else: + print(f"{i}【查看冻结原因】修复商品信息没有,不需要操作") + else: + print(f"ASIN {asin} 存在请求批准按钮,不需要处理,跳过...") + panel_close_btn = self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]', + timeout=5).sr('xpath:.//button[@class="close"]') + panel_close_btn.click() + + action_panel.wait.deleted(timeout=3, raise_err=False) + yield (asin,"请求批准") + already_asin.add(asin) + continue # 无需采取任何操作 not_operate = self.tab.eles('xpath://kat-alert[contains(@description,"如果您之前已提交更改,则这些更改当前正在处理中") and not(@dismissed)]',timeout=3) @@ -310,7 +604,7 @@ class AmzoneApprove(AmamzonBase): # TODO 增加 “出现警告和安全信息时候下滑” 的情况 safe_handle_ls = self.tab.eles('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//div[@aria-label="安全证明"]//kat-label[@text="未开始"]', - timeout=5) + timeout=15) if len(safe_handle_ls) > 0: print("存在安全证明,正在处理...") safe_handle_ls[0].click() @@ -329,17 +623,19 @@ class AmzoneApprove(AmamzonBase): time.sleep(0.5) # 点击保存 - confirm_btn = self.tab.ele('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//kat-button[@variant="primary"]') - confirm_btn.wait.displayed(timeout=3, raise_err=False) - confirm_btn.click() + confirm_btn = self.tab.eles('xpath://kat-panel-wrapper[@data-testid="kat-panel-wrapper-ActionPanelContent"]//kat-button[@variant="primary"]') + if len(confirm_btn) > 0: + confirm_btn[0].wait.displayed(timeout=3, raise_err=False) + confirm_btn[0].click() self.wait_loaded() # 等待关闭按钮出现 try: - close_btn = self.tab.ele('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//kat-button[@label="关闭"]', + close_btn = self.tab.eles('xpath://kat-panel[@data-testid="kat-panel-ActionPanelContent"]//kat-button[@label="关闭"]', timeout=10) - close_btn.click() + if len(close_btn)>0: + close_btn[0].click() except Exception as e: print("点击关闭按钮失败",e) @@ -351,7 +647,7 @@ class AmzoneApprove(AmamzonBase): print(f"ASIN {asin} 存在修复商品信息按钮,正在点击...") self.clear_tab() repair_product[0].click() - time.sleep(1) + time.sleep(3) # 获取最新的tab new_tab = self.browser.latest_tab if isinstance(new_tab, str): @@ -366,6 +662,7 @@ class AmzoneApprove(AmamzonBase): yield (asin,"处理完成") already_asin.add(asin) + print(f"*********************{asin} 处理完成*************************") try: # 全部操作完成,关闭 @@ -556,6 +853,8 @@ class ApproveTask: items = data.get("items", []) country_codes = data.get("country_codes", []) risk_listing_filter = data.get("risk_listing_filter", "") + # 测试 + # risk_listing_filter = "DetailPageRemoved" #data.get("risk_listing_filter", "") if not task_id: self.log("任务ID为空,跳过", "WARNING") @@ -993,14 +1292,14 @@ if __name__ == '__main__': "username": "自动化_Robot", "password": "#20zsg25" } - shop_name = "魏振峰" + shop_name = "郭亚芳" country = "德国" kill_process('v6') driver = AmzoneApprove(user_info) browser = driver.open_shop(shop_name) sw_suc = driver.SwitchingCountries(country) driver.SwitchPage() - risk_listing_filter = "ApprovalRequired" + risk_listing_filter = "DetailPageRemoved" for _ in range(3): try: sku_ls = driver.search(filter_type=risk_listing_filter) diff --git a/app/amazon/del_brand.py b/app/amazon/del_brand.py index f5e453b..fd0a311 100644 --- a/app/amazon/del_brand.py +++ b/app/amazon/del_brand.py @@ -314,6 +314,7 @@ class ZiniaoDriver: self.open_launcher_page(ret_json.get("launcherPage"), self.browser) else: print("IP检测不通过") + raise RuntimeError("IP检测不通过,可能是因为网络环境变化导致的,为了店铺安全不打开店铺") return self.browser def close_store(self, browser_oauth=None): @@ -499,26 +500,55 @@ class AmamzonBase(ZiniaoDriver): def login(self,password,username=""): try: - pwd_input = self.tab.eles('xpath://input[@type="password"]',timeout=10) - if len(pwd_input) > 0: - pwd_input[0].input(password,clear=True) - submit_btn = self.tab.eles('xpath://input[@id="signInSubmit"]',timeout=10) - if len(submit_btn) > 0: - submit_btn[0].click() - self.tab.wait.doc_loaded() + self.tab.wait.doc_loaded(timeout=30,raise_err=False) + for _ in range(4): + pwd_input = self.tab.eles('xpath://input[@type="password"]',timeout=5) + if len(pwd_input) > 0: + pwd_input[0].input(password,clear=True) + submit_btn = self.tab.eles('xpath://input[@id="signInSubmit"]',timeout=5) + if len(submit_btn) > 0: + submit_btn[0].click() + self.tab.wait.doc_loaded(timeout=30,raise_err=False) - opt_code_input = self.tab.ele('xpath://input[@name="otpCode"]',timeout=60) - opt_code_input.wait.displayed(timeout=10,raise_err=False) - for _ in range(30): - if opt_code_input.value is not None and opt_code_input.value.strip() != "": - print("检测到验证码输入完成") - submit_btn = self.tab.ele('xpath://input[@id="auth-signin-button"]',timeout=10) - submit_btn.click() - self.tab.wait.doc_loaded() - return True - time.sleep(1) - submit_btn = self.tab.ele('xpath://input[@id="auth-signin-button"]',timeout=10) - submit_btn.click() + send_code = self.tab.eles('xpath://span[@id="auth-send-code" and contains(string(.),"发送一次性密码")]',timeout=5) + print("发送一次性密码:",len(send_code)) + if len(send_code) > 0: + print("检测到 发送一次性密码") + send_code[0].click() + time.sleep(1) + self.tab.wait.doc_loaded(timeout=30,raise_err=False) + else: + for j in range(5): + opt_code_input = self.tab.eles('xpath://input[@name="otpCode"]',timeout=30) + print("验证码输入框:",len(opt_code_input)) + if len(opt_code_input) > 0: + opt_code_input[0].wait.displayed(timeout=10,raise_err=False) + for _ in range(30): + if opt_code_input[0].value is not None and opt_code_input[0].value.strip() != "": + print("检测到验证码输入完成") + submit_btn = self.tab.eles('xpath://input[@id="auth-signin-button"]',timeout=10) + if len(submit_btn) > 0: + submit_btn[0].click() + self.tab.wait.doc_loaded(timeout=20,raise_err=False) + # return True + error_mes = self.tab.eles('xpath://div[@id="auth-error-message-box"]',timeout=10) + if len(error_mes)>0: + print("验证码输入错误") + print("验证码输入错误提示:",error_mes[0].text) + self.tab.refresh() + else: + print("登入成功") + return True + + time.sleep(1) + + else: + break + + + submit_btn = self.tab.eles('xpath://input[@id="auth-signin-button"]',timeout=10) + if len(submit_btn) > 0: + submit_btn[0].click() except Exception as e: print("登录过程中发生异常:" + traceback.format_exc()) return False diff --git a/app/amazon/match_action.py b/app/amazon/match_action.py index 1ade99a..0eb6319 100644 --- a/app/amazon/match_action.py +++ b/app/amazon/match_action.py @@ -93,6 +93,7 @@ class AmzoneMatchAction(AmamzonBase): num = 0 retry_num = 0 already_asin = set() + get_page_faild = 0 while retry_num < 3: # 最多重试3次 # if num > 3: #测试 @@ -117,9 +118,13 @@ class AmzoneMatchAction(AmamzonBase): else: total_page = 0 print(f"【{self.mark_name}】当前页码: {current_page} / 总页数: {total_page}") + get_page_faild = 0 except Exception as e: print(f"【{self.mark_name}】获取页码失败", e) - + get_page_faild+= 1 + if get_page_faild > 2: + show_notification(f"【{self.mark_name}】获取页码失败超3次停止任务!") + break sku_ls = self.tab.eles("xpath://div[@data-sku]",timeout=10) print(f"【{self.mark_name}】获取到 {len(sku_ls)}") @@ -127,13 +132,13 @@ class AmzoneMatchAction(AmamzonBase): for sku_ele in sku_ls: # solve_problem = sku_ele.eles('xpath:.//kat-link[@label="解决商品信息问题"]') - asin = sku_ele.ele('xpath:.//div[contains(@class,"JanusSplitBox-module__container")]//div[contains(@class,"JanusSplitBox-module__panel--") and contains(string(.),"ASIN")]/..//div[last()]',timeout=10).text + asin = sku_ele.ele('xpath:.//div[contains(@class,"JanusSplitBox-module__container")]//div[contains(@class,"JanusSplitBox-module__panel--") and contains(string(.),"ASIN")]/..//div[last()]',timeout=3).text print(f"【{self.mark_name}】ASIN {asin} 找到....") if asin in already_asin: print(f"【{self.mark_name}】{asin} 已经处理过了,跳过") continue - price_match = sku_ele.eles('xpath:.//div[@data-test-id="FeaturedOfferPrice"]//a[text()="匹配"]',timeout=5) + price_match = sku_ele.eles('xpath:.//div[@data-test-id="FeaturedOfferPrice"]//a[text()="匹配"]',timeout=2) if len(price_match) == 0: print(f"【{self.mark_name}】{asin},没有推荐价格匹配按钮") yield (asin,"无需处理") @@ -230,6 +235,9 @@ class MatchTak: stage_index = data.get("stage_index") final_stage = bool(data.get("final_stage", True)) + # 用于测试 + limit = data.get("limit",None) + if not task_id: self.log("任务ID为空,跳过", "WARNING") return @@ -272,7 +280,7 @@ class MatchTak: show_notification(f"开始处理店铺: {shop_name}", "info") try: - self.process_shop(shop_item, country_codes, task_id, risk_listing_filter, user_id, stage_index, final_stage) + self.process_shop(shop_item, country_codes, task_id, risk_listing_filter, user_id, stage_index, final_stage,limit=limit) # self.process_shop(shop_item, country_codes, task_id,risk_listing_filter) # 更新已处理店铺数 @@ -302,7 +310,8 @@ class MatchTak: runing_task[task_id]["error"] = str(e) # def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str): - def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str, user_id=None, stage_index=None, final_stage: bool = True): + def process_shop(self, shop_item: dict, country_codes: list, task_id: int, risk_listing_filter: str, + user_id=None, stage_index=None, final_stage: bool = True,limit:str=None): """处理单个店铺 Args: @@ -420,7 +429,7 @@ class MatchTak: break try: - self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter) + self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter,limit) except Exception as e: import traceback self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR") @@ -449,7 +458,7 @@ class MatchTak: del runing_shop[shop_name] self.log(f"店铺 {shop_name} 已从执行列表中移除") - def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str): + def process_country(self, driver: AmzoneMatchAction, country_code: str, task_id: int, shop_name: str, risk_listing_filter: str,limit:str=None): """处理单个国家的审批任务 Args: @@ -582,6 +591,7 @@ class MatchTak: runing_task[task_id]["processed_countries"] += 1 self.log(f"国家 {country_name} 处理完成") + def post_stage_finished(self, task_id: int, user_id, stage_index): import requests from config import DELETE_BRAND_API_BASE diff --git a/app/amazon/price_match.py b/app/amazon/price_match.py new file mode 100644 index 0000000..a8d9916 --- /dev/null +++ b/app/amazon/price_match.py @@ -0,0 +1,364 @@ +import time +import re +import traceback +from datetime import datetime +from DrissionPage import Chromium, ChromiumOptions + +from amazon.del_brand import AmamzonBase, kill_process +from amazon.tool import show_notification,get_shop_info + +from config import runing_task, runing_shop,base_dir + + +class ChromeAmzone: + + mark_name = "亚马逊详情采集" + country_info = { + "英国": { + "url": "https://www.amazon.co.uk/dp/B0CJ8SNXXV", + "zip_code": "SW1A 1AA" + }, + "德国": { + "url": "https://www.amazon.de/dp/B0CC8CW9G2?th=1", + "zip_code": "10115" + }, + "法国": { + "url": "https://www.amazon.fr/dp/B0FRG1MJ8H?th=1", + "zip_code": "75001" + }, + "西班牙": { + "url": "https://www.amazon.es/dp/B08ZXVNYNN", + "zip_code": "28001" + }, + "意大利": { + "url": "https://www.amazon.it/dp/B0D1P17T2Q", + "zip_code": "20121" + } + } + + def __init__(self): + """ + 杀死当前谷歌浏览器进程,并使用 drissionpage 启动谷歌浏览器,使用系统安装的浏览器默认用户文件夹 + """ + # 杀死现有的Chrome进程 + print("正在关闭现有的Chrome浏览器进程...") + import os + os.system('taskkill /f /t /im chrome.exe') + time.sleep(2) + + + print("正在启动Chrome浏览器...") + # sellersprite_plug_path = os.path.join(base_dir,"app_resource","sellersprite-extension-mv3") + sellersprite_plug_path = os.path.join("D:\\私单交付\\maixiang_AI\\","app_resource","sellersprite-extension-mv3") + print(sellersprite_plug_path) + # 配置浏览器选项 + co = ChromiumOptions() + # co.use_system_user_path(on_off=True) + co.set_user_data_path(r"D:\私单交付\maixiang_AI\user_data\chrome_data") + co.set_local_port(port=19890) + co.add_extension(sellersprite_plug_path) + # co.set_argument('--disable-features=DisableLoadExtensionCommandLineSwitch') + # co.set_argument('--load-extension',sellersprite_plug_path) + co.set_browser_path(r'D:\私单交付\maixiang_AI\app_resource\chrome-win\chrome.exe') + + # 使用系统默认的用户数据目录 + # co.set_user_data_path(r'C:\Users\{}\AppData\Local\Google\Chrome\User Data'.format(os.getenv('USERNAME'))) + self.browser = Chromium(co) + self.tab = self.browser.latest_tab + print("Chrome浏览器启动成功") + + def close_init_popup(self): + """ + 关闭所有的初始化弹窗 + """ + self.tab.wait.doc_loaded(timeout=60,raise_err=True) + footbar = self.tab.eles('xpath://footer[@class="el-dialog__footer"]',timeout=5) + if len(footbar) > 0: + do_not_remind = footbar[0].eles('xpath:.//input[@class="el-checkbox__original"]') + if len(do_not_remind) > 0: + do_not_remind[0].check() + resume_immediately = footbar[0].eles('xpath:.//button') + if len(resume_immediately) > 0: + resume_immediately[0].click() + + self.tab.wait.doc_loaded(timeout=60,raise_err=True) + accept_btn = self.tab.eles('xpath://input[@id="sp-cc-accept"]',timeout=10) + if len(accept_btn) > 0: + accept_btn[0].click() + + + def run(self, country, asin): + """ + 运行亚马逊详情采集任务 + + Args: + country: 国家名称(如:英国、德国、法国、西班牙、意大利) + asin: 亚马逊商品ASIN码 + + Returns: + dict: 包含采集到的数据 + """ + try: + # 验证国家是否支持 + if country not in self.country_info: + error_msg = f"不支持的国家: {country},支持的国家有: {list(self.country_info.keys())}" + print(error_msg) + show_notification(error_msg, "error") + return None + + # 获取国家配置 + country_config = self.country_info[country] + zip_code = country_config["zip_code"] + + # 1. 根据国家和ASIN拼接链接 + base_url = country_config["url"] + # 提取域名部分 + domain = base_url.split("/dp/")[0] + # 拼接新的URL + product_url = f"{domain}/dp/{asin}" + print(f"正在访问: {product_url}") + + # 打开链接 + self.tab.get(product_url) + time.sleep(3) # 等待页面初步加载 + self.tab.wait.doc_loaded(timeout=30, raise_err=False) + + # 2. 切换国家/设置邮编 + print(f"正在检查并设置邮编: {zip_code}") + self._set_zip_code(zip_code) + + self.close_init_popup() + + + # 3. 抓取数据 + print("正在抓取商品数据...") + data = self._scrape_data() + + # 添加基本信息 + data['country'] = country + data['asin'] = asin + data['url'] = product_url + data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + print(f"数据抓取完成: {data}") + return data + + except Exception as e: + error_msg = f"运行出错: {traceback.format_exc()}" + print(error_msg) + show_notification(f"采集失败: {str(e)}", "error") + return None + + def _set_zip_code(self, zip_code): + """ + 设置邮编 + + Args: + zip_code: 目标邮编 + """ + try: + # 检查当前邮编 + zip_display = self.tab.ele('xpath://div[@id="glow-ingress-block"]', timeout=10) + + if zip_display: + current_text = zip_display.text + print(f"当前地址信息: {current_text}") + + # 检查是否已经包含目标邮编 + if zip_code in current_text: + print(f"邮编已经设置为: {zip_code},无需修改") + return True + + # 需要设置邮编 + print(f"正在设置邮编为: {zip_code}") + + # 点击地址选择按钮 + location_link = self.tab.ele('xpath://a[@id="nav-global-location-popover-link"]', timeout=10) + if not location_link: + print("找不到地址设置按钮") + return False + + location_link.click() + time.sleep(1) + + # 等待邮编输入框出现 + zip_input = self.tab.ele('xpath://input[@id="GLUXZipUpdateInput"]', timeout=10) + if not zip_input: + print("找不到邮编输入框") + return False + + # 输入邮编 + zip_input.input(zip_code, clear=True) + time.sleep(0.5) + + # 点击提交按钮 + submit_btn = self.tab.ele('xpath://input[@aria-labelledby="GLUXZipUpdate-announce"]', timeout=10) + if not submit_btn: + print("找不到提交按钮") + return False + + submit_btn.click() + + # 等待提交按钮消失(表示请求已发送) + print("等待邮编更新...") + time.sleep(2) + + # 等待页面加载完成 + self.tab.wait.doc_loaded(timeout=30, raise_err=False) + time.sleep(2) + + # 验证邮编是否设置成功 + zip_display_after = self.tab.ele('xpath://div[@id="glow-ingress-block"]', timeout=10) + if zip_display_after: + updated_text = zip_display_after.text + print(f"更新后的地址信息: {updated_text}") + + if zip_code in updated_text: + print(f"邮编设置成功: {zip_code}") + return True + else: + print(f"邮编设置可能失败,当前显示: {updated_text}") + return False + + return True + + except Exception as e: + print(f"设置邮编时出错: {traceback.format_exc()}") + return False + + def _scrape_data(self): + """ + 抓取商品数据 + + Returns: + dict: 抓取到的数据 + """ + data = { + 'top_sellers': [], # 前两名卖家信息 + 'cart_seller': None # 购物车所属卖家 + } + + try: + # 等待页面加载 + time.sleep(3) + + # 1. 获取前两名卖家的价格和库存(来自 sellersprite 插件) + print("正在抓取卖家排名数据...") + surplus_tables = self.tab.eles( + 'xpath://div[@id="sellersprite-extension-Inventory-surplus-count"]//div[@class="surplus-table"]', + timeout=10 + ) + + if surplus_tables: + print(f"找到 {len(surplus_tables)} 个卖家数据") + # 只获取前两个 + for idx, table in enumerate(surplus_tables[:2]): + try: + # 提取价格和库存信息 + # 注意:需要根据实际的HTML结构调整选择器 + text_content = table.text + print(f"第{idx+1}名卖家数据: {text_content}") + + seller_info = { + 'rank': idx + 1, + 'raw_data': text_content + } + + # 尝试提取更结构化的数据 + # 这里需要根据实际HTML结构来解析 + # 可能需要查找子元素 + try: + # 示例:查找价格和库存的具体子节点 + price_ele = table.ele('xpath:.//span[contains(@class,"price")]', timeout=2) + stock_ele = table.ele('xpath:.//span[contains(@class,"stock")]', timeout=2) + + if price_ele: + seller_info['price'] = price_ele.text.strip() + if stock_ele: + seller_info['stock'] = stock_ele.text.strip() + except: + # 如果找不到具体元素,就使用原始文本 + pass + + data['top_sellers'].append(seller_info) + + except Exception as e: + print(f"解析第{idx+1}名卖家数据失败: {str(e)}") + else: + print("未找到 sellersprite 插件数据,可能插件未启用") + + # 2. 获取购物车所属卖家 + print("正在抓取购物车卖家信息...") + cart_seller = self.tab.ele( + 'xpath://div[@data-csa-c-content-id="desktop-merchant-info"]//a[@id="sellerProfileTriggerId"]', + timeout=10 + ) + + if cart_seller: + seller_name = cart_seller.text.strip() + print(f"购物车所属卖家: {seller_name}") + data['cart_seller'] = seller_name + else: + print("未找到购物车卖家信息") + # 尝试其他可能的选择器 + try: + alt_seller = self.tab.ele('xpath://a[@id="sellerProfileTriggerId"]', timeout=5) + if alt_seller: + data['cart_seller'] = alt_seller.text.strip() + print(f"购物车所属卖家(备用方式): {data['cart_seller']}") + except: + pass + + return data + + except Exception as e: + print(f"抓取数据时出错: {traceback.format_exc()}") + return data + + def close(self): + """关闭浏览器""" + try: + if self.browser: + self.browser.quit() + print("浏览器已关闭") + except Exception as e: + print(f"关闭浏览器时出错: {str(e)}") + + +class AmzonePriceMatch(AmamzonBase): + mark_name = "跟价" + + pass + + +if __name__ == '__main__': + # 使用示例 + print("=" * 50) + print("亚马逊详情采集示例") + print("=" * 50) + + try: + # 创建ChromeAmzone实例 + chrome = ChromeAmzone() + + # 示例1:采集英国站点的商品信息 + print("\n示例1:采集英国站点商品") + result1 = chrome.run(country="英国", asin="B0CJ8SNXXV") + if result1: + print(f"采集成功: {result1}") + + # 示例2:采集德国站点的商品信息 + # print("\n示例2:采集德国站点商品") + # result2 = chrome.run(country="德国", asin="B0CC8CW9G2") + # if result2: + # print(f"采集成功: {result2}") + + # # 关闭浏览器 + # print("\n正在关闭浏览器...") + # chrome.close() + + except Exception as e: + print(f"运行出错: {traceback.format_exc()}") + + + diff --git a/app/config.py b/app/config.py index cf3f6fc..0b5d8f9 100644 --- a/app/config.py +++ b/app/config.py @@ -59,6 +59,9 @@ os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL # 队列 JSON_TASK_QUEUE = Queue() + +base_dir = os.getcwd() + #删除品牌,正在执行中的任务 runing_task = { diff --git a/app/main.py b/app/main.py index dac45e7..0286722 100644 --- a/app/main.py +++ b/app/main.py @@ -19,7 +19,6 @@ import threading import time import requests import subprocess -import atexit from amazon.del_brand import kill_process @@ -40,21 +39,6 @@ print(f""" BASE_DIR = os.path.dirname(os.path.abspath(__file__)) APP_URL = "http://127.0.0.1:5123" PORT = 5123 -_shutdown_started = False - - -def cleanup_before_exit(): - global _shutdown_started - if _shutdown_started: - return - _shutdown_started = True - try: - kill_process('v6') - except Exception as e: - print(f"【退出前】关闭紫鸟浏览器进程异常: {str(e)}") - - -atexit.register(cleanup_before_exit) def generate_images(params): diff --git a/desktop/README.md b/desktop/README.md deleted file mode 100644 index f2ca34e..0000000 --- a/desktop/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# 桌面端启动 - -## Python 要求 - -桌面端当前依赖 `pywebview`,在 Windows 下建议使用: - -- `Python 3.12` -- `Python 3.11` - -不要直接用 `Python 3.14`。你当前遇到的 `pythonnet` 构建失败就是这个兼容性问题。 - -## 开发模式 - -前置条件: - -- `frontend-vue` 的 Vite 开发服务已启动在 `http://127.0.0.1:5173` -- 后端 API 已启动在 `http://127.0.0.1:8000` -- 本机已安装 `Python 3.12` 或 `Python 3.11` - -启动命令: - -```powershell -cd F:\project\crawler-plugin -.\desktop\run_desktop_dev.ps1 -``` - -脚本会自动: - -- 选择兼容的 Python 解释器 -- 安装 `desktop/requirements.txt` -- 启动 `pywebview` 桌面窗口 - -如果脚本检测不到兼容 Python,会直接提示并退出,不再继续错误安装。 - -## 发布模式 - -先构建前端: - -```powershell -cd F:\project\crawler-plugin\frontend-vue -npm run build -``` - -再启动桌面端: - -```powershell -cd F:\project\crawler-plugin -.\desktop\run_desktop_dist.ps1 -``` - -## 手动安装依赖 - -如果要手动安装,请显式使用兼容版本,例如: - -```powershell -py -3.12 -m pip install -r .\desktop\requirements.txt -``` - -## 当前桌面能力 - -- 选择品牌 Excel 文件 -- 选择文件夹 -- 选择保存目录 -- 另存模板文件 -- 按 URL 下载结果文件到本地 - -## 说明 - -发布模式下,桌面宿主会: - -- 本地托管 `frontend-vue/dist` -- 将 `/api`、`/login`、`/logout`、`/static` 代理到 `--backend-url` - -所以后端仍然需要运行。 diff --git a/desktop/__init__.py b/desktop/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/desktop/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/desktop/__pycache__/__init__.cpython-312.pyc b/desktop/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 74a00d4..0000000 Binary files a/desktop/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/desktop/__pycache__/__init__.cpython-314.pyc b/desktop/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index bae2de3..0000000 Binary files a/desktop/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 6ec347a..0000000 Binary files a/desktop/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/desktop/__pycache__/app.cpython-314.pyc b/desktop/__pycache__/app.cpython-314.pyc deleted file mode 100644 index bb0d42d..0000000 Binary files a/desktop/__pycache__/app.cpython-314.pyc and /dev/null differ diff --git a/desktop/app.py b/desktop/app.py deleted file mode 100644 index b05d9d8..0000000 --- a/desktop/app.py +++ /dev/null @@ -1,1017 +0,0 @@ -from __future__ import annotations - -import argparse -import contextlib -import json -import mimetypes -import os -import re -import socket -import subprocess -import threading -import time -from dataclasses import dataclass -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from tkinter import Tk, filedialog -from typing import Iterable -from urllib.parse import urljoin -from uuid import uuid4 - -try: - import requests -except ModuleNotFoundError as exc: - raise SystemExit( - "缺少依赖 requests。\n" - "请先执行:python -m pip install -r .\\desktop\\requirements.txt" - ) from exc - -try: - import webview -except ModuleNotFoundError as exc: - raise SystemExit( - "缺少依赖 pywebview。\n" - "请先执行:python -m pip install -r .\\desktop\\requirements.txt" - ) from exc - -try: - from openpyxl import load_workbook -except ModuleNotFoundError as exc: - raise SystemExit( - "缺少依赖 openpyxl。\n" - "请先执行:python -m pip install -r .\\desktop\\requirements.txt" - ) from exc - - -ROOT_DIR = Path(__file__).resolve().parents[1] -FRONTEND_DIR = ROOT_DIR / "frontend-vue" -DIST_DIR = FRONTEND_DIR / "dist" -INDEX_FILE = DIST_DIR / "index.html" -PROXY_PREFIXES = ("/api", "/login", "/logout", "/static") -TEMPLATE_STORE_PATH = ROOT_DIR / "desktop" / "txt_templates.json" -TXT_TEMPLATES = { - "uk_offer": { - "id": "uk_offer", - "label": "英国 Offer 模板", - "output_filename": "英国.txt", - "is_default": True, - "built_in": True, - "preamble_lines": ["TemplateType=Offer\tVersion=1.4"], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available", - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": ["ASIN", "价格"], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4", - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN", - }, - } -} - - -def find_free_port() -> int: - with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def ensure_dist_exists() -> None: - if not INDEX_FILE.exists(): - raise FileNotFoundError( - f"未找到前端构建产物:{INDEX_FILE}\n" - "请先进入 frontend-vue 执行 npm run build。" - ) - - -def resolve_url(base_url: str, maybe_relative_url: str) -> str: - if maybe_relative_url.startswith(("http://", "https://")): - return maybe_relative_url - return urljoin(base_url.rstrip("/") + "/", maybe_relative_url.lstrip("/")) - - -def run_file_dialog(fn): - root = Tk() - root.withdraw() - root.attributes("-topmost", True) - try: - return fn(root) - finally: - root.destroy() - - -def normalize_cell_text(value: object) -> str: - if value is None: - return "" - text = str(value).replace("\ufeff", "").replace("\u3000", " ") - text = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ").replace("\t", " ") - text = re.sub(r"\s+", " ", text) - return text.strip() - - -def build_output_path(output_dir: str, source_path: str) -> str: - source = Path(source_path) - candidate = Path(output_dir) / f"{source.stem}_cleaned{source.suffix}" - index = 2 - while candidate.exists(): - candidate = Path(output_dir) / f"{source.stem}_cleaned_{index}{source.suffix}" - index += 1 - return str(candidate) - - -def build_named_output_path(output_dir: str, filename: str) -> str: - candidate = Path(output_dir) / filename - if not candidate.exists(): - return str(candidate) - stem = candidate.stem - suffix = candidate.suffix - index = 2 - while True: - next_candidate = Path(output_dir) / f"{stem}_{index}{suffix}" - if not next_candidate.exists(): - return str(next_candidate) - index += 1 - - -def should_keep_id(value: object, keep_integer_ids: bool, keep_underscore_ids: bool) -> bool: - text = normalize_cell_text(value) - if not text: - return False - if keep_integer_ids and text.isdigit(): - return True - if keep_underscore_ids and re.fullmatch(r"\d+_\d+", text): - return True - return False - - -def generate_snowflake_like_id() -> int: - return int(time.time() * 1000) - - -def load_user_templates() -> tuple[dict[str, dict], str | None]: - if not TEMPLATE_STORE_PATH.exists(): - return {}, None - try: - payload = json.loads(TEMPLATE_STORE_PATH.read_text(encoding="utf-8")) - templates = payload.get("templates") or {} - default_template_id = payload.get("default_template_id") - return templates, default_template_id - except Exception: - return {}, None - - -def save_user_templates(templates: dict[str, dict], default_template_id: str | None) -> None: - payload = { - "default_template_id": default_template_id, - "templates": templates, - } - TEMPLATE_STORE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def build_effective_templates() -> tuple[dict[str, dict], str]: - builtins = {key: dict(value) for key, value in TXT_TEMPLATES.items()} - user_templates, persisted_default = load_user_templates() - templates = {**builtins, **user_templates} - default_template_id = persisted_default or next((key for key, value in builtins.items() if value.get("is_default")), "uk_offer") - for key, value in templates.items(): - value["is_default"] = key == default_template_id - return templates, default_template_id - - -def recognize_txt_template(template_text: str, custom_name: str | None = None) -> dict | None: - lines = [line.rstrip("\n") for line in template_text.splitlines()] - non_empty_lines = [line for line in lines if line.strip()] - if len(non_empty_lines) < 2: - return None - - first_line = non_empty_lines[0] - header_line = non_empty_lines[1] - header_columns = header_line.split("\t") - uk_template = TXT_TEMPLATES["uk_offer"] - - if not first_line.startswith("TemplateType=Offer"): - return None - if header_columns != uk_template["header_columns"]: - return None - - template_id = f"user_{int(time.time() * 1000)}" - output_filename = custom_name or Path("英国.txt").name - if not output_filename.endswith(".txt"): - output_filename = f"{output_filename}.txt" - - return { - "id": template_id, - "label": custom_name or "自定义 Offer 模板", - "output_filename": output_filename, - "is_default": False, - "built_in": False, - "preamble_lines": [first_line], - "header_columns": header_columns, - "blank_header_rows_after_schema": 1, - "required_source_columns": ["ASIN", "价格"], - "defaults": dict(uk_template["defaults"]), - "field_mapping": dict(uk_template["field_mapping"]), - } - - -@dataclass -class DesktopConfig: - mode: str - frontend_url: str - backend_url: str - host: str - port: int - width: int - height: int - title: str - - -class DesktopApi: - def __init__(self, backend_base_url: str) -> None: - self.app_base_url = backend_base_url.rstrip("/") - self.session = requests.Session() - - def select_brand_xlsx_files(self) -> list[str]: - files = run_file_dialog( - lambda _root: filedialog.askopenfilenames( - title="选择 Excel 文件", - filetypes=[("Excel files", "*.xlsx")], - ) - ) - return list(files) - - def select_clean_xlsx_files(self) -> list[str]: - files = run_file_dialog( - lambda _root: filedialog.askopenfilenames( - title="选择待清洗 Excel 文件", - filetypes=[("Excel files", "*.xlsx")], - ) - ) - return list(files) - - def select_brand_folder(self) -> str | None: - folder = run_file_dialog( - lambda _root: filedialog.askdirectory(title="选择文件夹") - ) - return folder or None - - def select_clean_folder(self) -> str | None: - folder = run_file_dialog( - lambda _root: filedialog.askdirectory(title="选择待清洗文件夹") - ) - return folder or None - - def get_excel_headers(self, file_path: str) -> dict: - try: - workbook = load_workbook(file_path, read_only=True, data_only=True) - sheet = workbook[workbook.sheetnames[0]] - first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) - workbook.close() - if not first_row: - return {"success": False, "error": "Excel 表头为空"} - - headers: list[str] = [] - seen: set[str] = set() - for cell in first_row: - if cell is None: - continue - raw_value = str(cell).replace("\ufeff", "").strip() - if not raw_value: - continue - value = raw_value.split("idASIN国家状态价格变体数量", 1)[0].strip() or raw_value - if value in seen: - continue - seen.add(value) - headers.append(value) - if value == "缩略图地址8": - break - - if not headers: - return {"success": False, "error": "未读取到有效表头"} - return {"success": True, "headers": headers} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_excel_info(self, file_path: str) -> dict: - try: - workbook = load_workbook(file_path, read_only=True, data_only=True) - sheet = workbook[workbook.sheetnames[0]] - first_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) - if not first_row: - workbook.close() - return {"success": False, "error": "Excel 表头为空"} - - headers: list[str] = [] - seen: set[str] = set() - for cell in first_row: - if cell is None: - continue - raw_value = str(cell).replace("\ufeff", "").strip() - if not raw_value: - continue - value = raw_value.split("idASIN国家状态价格变体数量", 1)[0].strip() or raw_value - if value in seen: - continue - seen.add(value) - headers.append(value) - if value == "缩略图地址8": - break - - total_rows = max(sheet.max_row - 1, 0) - workbook.close() - return {"success": True, "headers": headers, "total_rows": total_rows} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def list_txt_templates(self) -> list[dict]: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/convert/templates"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success"): - return [] - items = payload.get("data") or [] - return [ - { - "id": str(item.get("templateCode") or item.get("id") or ""), - "label": item.get("templateName") or "", - "output_filename": item.get("outputFilename") or "", - "is_default": bool(item.get("isDefault")), - "built_in": bool(item.get("builtIn")), - } - for item in items - ] - except Exception: - templates, _default_template_id = build_effective_templates() - return [ - { - "id": template["id"], - "label": template["label"], - "output_filename": template["output_filename"], - "is_default": template.get("is_default", False), - "built_in": template.get("built_in", False), - } - for template in templates.values() - ] - - def import_txt_template(self, name: str | None = None) -> dict: - file_path = run_file_dialog( - lambda _root: filedialog.askopenfilename( - title="选择 txt 模板文件", - filetypes=[("TXT files", "*.txt")], - ) - ) - if not file_path: - return {"success": False, "error": "用户取消"} - - try: - try: - template_text = Path(file_path).read_text(encoding="utf-8") - except UnicodeDecodeError: - template_text = Path(file_path).read_text(encoding="utf-8-sig") - - response = self.session.post( - resolve_url(self.app_base_url, "/api/convert/templates/import"), - json={ - "templateName": (name or "").strip() or Path(file_path).stem, - "templateContent": template_text, - }, - timeout=120, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "导入失败"} - item = payload.get("data") or {} - return { - "success": True, - "template": { - "id": str(item.get("templateCode") or item.get("id") or ""), - "label": item.get("templateName") or "", - "output_filename": item.get("outputFilename") or "", - "is_default": bool(item.get("isDefault")), - "built_in": bool(item.get("builtIn")), - }, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def set_default_txt_template(self, template_id: str) -> dict: - try: - response = self.session.post( - resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def delete_txt_template(self, template_id: str) -> dict: - try: - response = self.session.delete( - resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def split_excel_files(self, payload: dict) -> dict: - paths = payload.get("paths") or [] - selected_columns = payload.get("selected_columns") or [] - split_mode = payload.get("split_mode") or "rows_per_file" - rows_per_file = payload.get("rows_per_file") - parts = payload.get("parts") - - if not isinstance(paths, list) or not paths: - return {"success": False, "error": "未选择待拆分文件"} - if not isinstance(selected_columns, list) or not selected_columns: - return {"success": False, "error": "请至少选择一列"} - if split_mode not in {"rows_per_file", "parts"}: - return {"success": False, "error": "拆分模式不合法"} - if split_mode == "rows_per_file" and (not isinstance(rows_per_file, int) or rows_per_file <= 0): - return {"success": False, "error": "每份条数不合法"} - if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0): - return {"success": False, "error": "拆分份数不合法"} - - try: - uploaded_files = [] - for path in paths: - with open(path, "rb") as file_obj: - response = self.session.post( - resolve_url(self.app_base_url, "/api/files/upload"), - files={"file": (Path(path).name, file_obj)}, - timeout=180, - ) - response.raise_for_status() - upload_payload = response.json() - if not upload_payload.get("success") or not upload_payload.get("data"): - return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} - upload_data = upload_payload.get("data") or {} - uploaded_files.append({ - "fileKey": upload_data.get("fileKey"), - "originalFilename": upload_data.get("originalFilename") or Path(path).name, - }) - - run_response = self.session.post( - resolve_url(self.app_base_url, "/api/split/run"), - json={ - "files": uploaded_files, - "selectedColumns": selected_columns, - "splitMode": split_mode, - "rowsPerFile": rows_per_file, - "parts": parts, - }, - timeout=600, - ) - run_response.raise_for_status() - run_payload = run_response.json() - if not run_payload.get("success") or not run_payload.get("data"): - return {"success": False, "error": run_payload.get("message") or "数据拆分失败"} - - data = run_payload.get("data") or {} - result_items = [] - for item in data.get("items") or []: - if item.get("success") and item.get("downloadUrl"): - result_items.append({ - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": True, - "row_count": item.get("rowCount"), - }) - else: - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "success": False, - "error": item.get("error") or "数据拆分失败", - }) - - return { - "success": True, - "summary": { - "total": data.get("total") or len(paths), - "success_count": data.get("successCount") or 0, - "failed_count": data.get("failedCount") or 0, - }, - "items": result_items, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def delete_history_item(self, module_type: str, result_id: int) -> dict: - try: - response = self.session.delete( - resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_convert_history(self) -> dict: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/convert/history"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "获取历史失败"} - - items = payload.get("data", {}).get("items") or [] - return { - "success": True, - "items": [ - { - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": bool(item.get("success", True)), - } - for item in items - ], - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_split_history(self) -> dict: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/split/history"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "获取历史失败"} - - items = payload.get("data", {}).get("items") or [] - return { - "success": True, - "items": [ - { - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": bool(item.get("success", True)), - "row_count": item.get("rowCount"), - } - for item in items - ], - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def get_dedupe_history(self) -> dict: - try: - response = self.session.get( - resolve_url(self.app_base_url, "/api/dedupe/history"), - timeout=60, - ) - response.raise_for_status() - payload = response.json() - if not payload.get("success") or not payload.get("data"): - return {"success": False, "error": payload.get("message") or "获取历史失败"} - - items = payload.get("data", {}).get("items") or [] - return { - "success": True, - "items": [ - { - "result_id": item.get("resultId"), - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": bool(item.get("success", True)), - } - for item in items - ], - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def clean_excel_files(self, payload: dict) -> dict: - paths = payload.get("paths") or [] - selected_columns = payload.get("selected_columns") or [] - keep_integer_ids = bool(payload.get("keep_integer_ids", True)) - keep_underscore_ids = bool(payload.get("keep_underscore_ids", True)) - - if not isinstance(paths, list) or not paths: - return {"success": False, "error": "未选择待处理文件"} - if not isinstance(selected_columns, list) or not selected_columns: - return {"success": False, "error": "请至少选择一列"} - if not keep_integer_ids and not keep_underscore_ids: - return {"success": False, "error": "请至少选择一种 ID 保留规则"} - - try: - uploaded_files = [] - for path in paths: - with open(path, "rb") as file_obj: - response = self.session.post( - resolve_url(self.app_base_url, "/api/files/upload"), - files={"file": (Path(path).name, file_obj)}, - timeout=180, - ) - response.raise_for_status() - upload_payload = response.json() - if not upload_payload.get("success") or not upload_payload.get("data"): - return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} - upload_data = upload_payload.get("data") or {} - uploaded_files.append({ - "fileKey": upload_data.get("fileKey"), - "originalFilename": upload_data.get("originalFilename") or Path(path).name, - }) - - run_response = self.session.post( - resolve_url(self.app_base_url, "/api/dedupe/run"), - json={ - "files": uploaded_files, - "selectedColumns": selected_columns, - "keepIntegerIds": keep_integer_ids, - "keepUnderscoreIds": keep_underscore_ids, - }, - timeout=600, - ) - run_response.raise_for_status() - run_payload = run_response.json() - if not run_payload.get("success") or not run_payload.get("data"): - return {"success": False, "error": run_payload.get("message") or "去重失败"} - - data = run_payload.get("data") or {} - result_items = [] - for item in data.get("items") or []: - if item.get("success") and item.get("downloadUrl"): - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": True, - }) - else: - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "success": False, - "error": item.get("error") or "去重失败", - }) - - return { - "success": True, - "summary": { - "total": data.get("total") or len(paths), - "success_count": data.get("successCount") or 0, - "failed_count": data.get("failedCount") or 0, - }, - "items": result_items, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def convert_excel_to_uk_txt(self, payload: dict) -> dict: - paths = payload.get("paths") or [] - template_id = payload.get("template_id") or "uk_offer" - - if not isinstance(paths, list) or not paths: - return {"success": False, "error": "未选择待转换文件"} - - try: - templates = self.list_txt_templates() - selected_template = next((item for item in templates if item.get("id") == template_id), None) - if not selected_template: - return {"success": False, "error": "模板不存在"} - - uploaded_files = [] - for path in paths: - with open(path, "rb") as file_obj: - response = self.session.post( - resolve_url(self.app_base_url, "/api/files/upload"), - files={"file": (Path(path).name, file_obj)}, - timeout=180, - ) - response.raise_for_status() - upload_payload = response.json() - if not upload_payload.get("success") or not upload_payload.get("data"): - return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} - upload_data = upload_payload.get("data") or {} - uploaded_files.append({ - "fileKey": upload_data.get("fileKey"), - "originalFilename": upload_data.get("originalFilename") or Path(path).name, - }) - - run_response = self.session.post( - resolve_url(self.app_base_url, "/api/convert/run"), - json={ - "files": uploaded_files, - "templateId": str(template_id), - }, - timeout=600, - ) - run_response.raise_for_status() - run_payload = run_response.json() - if not run_payload.get("success") or not run_payload.get("data"): - return {"success": False, "error": run_payload.get("message") or "格式转换失败"} - - data = run_payload.get("data") or {} - result_items = [] - for item in data.get("items") or []: - if item.get("success") and item.get("downloadUrl"): - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "output_path": item.get("downloadUrl") or "", - "success": True, - }) - else: - result_items.append({ - "source_path": item.get("sourceFilename") or "", - "success": False, - "error": item.get("error") or "格式转换失败", - }) - - return { - "success": True, - "summary": { - "total": data.get("total") or len(paths), - "success_count": data.get("successCount") or 0, - "failed_count": data.get("failedCount") or 0, - }, - "items": result_items, - } - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def open_path(self, path: str) -> dict: - try: - if not path: - return {"success": False, "error": "路径为空"} - target = Path(path) - if not target.exists(): - return {"success": False, "error": "路径不存在"} - os.startfile(str(target)) - return {"success": True} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - def select_folder(self) -> str | None: - folder = run_file_dialog( - lambda _root: filedialog.askdirectory(title="选择文件夹") - ) - return folder or None - - def save_template_xlsx(self) -> dict: - return self._download_to_selected_path( - "/static/品牌文档格式_模板.xlsx", - "品牌文档格式_模板.xlsx", - ) - - def save_template_zip(self) -> dict: - return self._download_to_selected_path( - "/static/模板2-以文件夹方式上传.zip", - "模板2-以文件夹方式上传.zip", - ) - - def save_file_from_url(self, url: str, filename: str) -> dict: - return self._download_to_selected_path(url, filename) - - def _download_to_selected_path(self, source_url: str, default_filename: str) -> dict: - path = run_file_dialog( - lambda _root: filedialog.asksaveasfilename( - title="选择保存位置", - initialfile=default_filename, - defaultextension=Path(default_filename).suffix or None, - ) - ) - if not path: - return {"success": False, "error": "用户取消"} - - try: - resolved_url = resolve_url(self.app_base_url, source_url) - with self.session.get(resolved_url, timeout=120, stream=True) as response: - response.raise_for_status() - with open(path, "wb") as file: - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - file.write(chunk) - return {"success": True, "path": path} - except Exception as exc: # noqa: BLE001 - return {"success": False, "error": str(exc)} - - -class DistRequestHandler(BaseHTTPRequestHandler): - backend_base_url = "" - - def do_GET(self) -> None: # noqa: N802 - self._dispatch() - - def do_POST(self) -> None: # noqa: N802 - self._dispatch() - - def do_PUT(self) -> None: # noqa: N802 - self._dispatch() - - def do_DELETE(self) -> None: # noqa: N802 - self._dispatch() - - def do_OPTIONS(self) -> None: # noqa: N802 - self._dispatch() - - def _dispatch(self) -> None: - if self.path.startswith(PROXY_PREFIXES): - self._proxy_to_backend() - return - self._serve_dist_file() - - def _proxy_to_backend(self) -> None: - target_url = resolve_url(self.backend_base_url, self.path) - body = self._read_request_body() - headers = {k: v for k, v in self.headers.items() if k.lower() != "host"} - - try: - response = requests.request( - method=self.command, - url=target_url, - headers=headers, - data=body, - stream=True, - timeout=120, - allow_redirects=False, - ) - except requests.RequestException as exc: - payload = f"后端代理请求失败:{exc}".encode("utf-8") - self.send_response(502) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - self.wfile.write(payload) - return - - self.send_response(response.status_code) - excluded_headers = {"content-encoding", "transfer-encoding", "connection"} - for key, value in response.headers.items(): - if key.lower() in excluded_headers: - continue - self.send_header(key, value) - self.end_headers() - for chunk in response.iter_content(chunk_size=1024 * 128): - if chunk: - self.wfile.write(chunk) - - def _serve_dist_file(self) -> None: - request_path = self.path.split("?", 1)[0].lstrip("/") - if not request_path: - file_path = INDEX_FILE - else: - file_path = (DIST_DIR / request_path).resolve() - if not str(file_path).startswith(str(DIST_DIR.resolve())): - self.send_error(403) - return - if not file_path.exists() or file_path.is_dir(): - file_path = INDEX_FILE - - try: - content = file_path.read_bytes() - except FileNotFoundError: - self.send_error(404) - return - - mime_type, _ = mimetypes.guess_type(str(file_path)) - self.send_response(200) - self.send_header("Content-Type", mime_type or "application/octet-stream") - self.send_header("Content-Length", str(len(content))) - self.end_headers() - self.wfile.write(content) - - def _read_request_body(self) -> bytes | None: - content_length = self.headers.get("Content-Length") - if not content_length: - return None - return self.rfile.read(int(content_length)) - - def log_message(self, format: str, *args) -> None: # noqa: A003 - return - - -class DistServer: - def __init__(self, host: str, port: int, backend_url: str) -> None: - handler = type( - "ConfiguredDistRequestHandler", - (DistRequestHandler,), - {"backend_base_url": backend_url.rstrip("/")}, - ) - self.server = ThreadingHTTPServer((host, port), handler) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.base_url = f"http://{host}:{port}" - - def start(self) -> None: - self.thread.start() - - def stop(self) -> None: - self.server.shutdown() - self.server.server_close() - self.thread.join(timeout=2) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Crawler Plugin 桌面端启动器") - parser.add_argument("--mode", choices=("dev", "dist"), default="dev") - parser.add_argument("--frontend-url", default="http://127.0.0.1:5173") - parser.add_argument("--backend-url", default="http://127.0.0.1:8000") - parser.add_argument("--host", default="127.0.0.1") - parser.add_argument("--port", type=int, default=0) - parser.add_argument("--width", type=int, default=1440) - parser.add_argument("--height", type=int, default=960) - parser.add_argument("--title", default="数富AI") - return parser - - -def parse_args() -> DesktopConfig: - args = build_parser().parse_args() - port = args.port or find_free_port() - return DesktopConfig( - mode=args.mode, - frontend_url=args.frontend_url.rstrip("/"), - backend_url=args.backend_url.rstrip("/"), - host=args.host, - port=port, - width=args.width, - height=args.height, - title=args.title, - ) - - -def ensure_dev_server_ready(frontend_url: str) -> None: - try: - response = requests.get(frontend_url, timeout=5) - response.raise_for_status() - except requests.RequestException as exc: - raise SystemExit( - "Dev server is not reachable.\n" - f"Expected frontend URL: {frontend_url}\n" - "Start Vite first with:\n" - " cd frontend-vue\n" - " npm run dev" - ) from exc - - -def main() -> None: - config = parse_args() - dist_server = None - - if config.mode == "dist": - ensure_dist_exists() - dist_server = DistServer(config.host, config.port, config.backend_url) - dist_server.start() - app_url = dist_server.base_url - else: - ensure_dev_server_ready(config.frontend_url) - app_url = config.frontend_url - - api = DesktopApi(config.backend_url) - window = webview.create_window( - title=config.title, - url=app_url, - js_api=api, - width=config.width, - height=config.height, - min_size=(1200, 760), - ) - - try: - webview.start() - finally: - if dist_server is not None: - dist_server.stop() - - -if __name__ == "__main__": - main() diff --git a/desktop/requirements.txt b/desktop/requirements.txt deleted file mode 100644 index ce8b9a8..0000000 --- a/desktop/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pywebview>=5.1 -requests>=2.32.0 -openpyxl>=3.1.5 diff --git a/desktop/run_desktop_dev.ps1 b/desktop/run_desktop_dev.ps1 deleted file mode 100644 index eda8ff8..0000000 --- a/desktop/run_desktop_dev.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent $PSScriptRoot -Set-Location $root - -function Get-CompatiblePythonCommand { - $pyLauncher = Get-Command py -ErrorAction SilentlyContinue - if ($pyLauncher) { - foreach ($version in @("3.12", "3.11")) { - try { - & py "-$version" --version *> $null - if ($LASTEXITCODE -eq 0) { - return @("py", "-$version") - } - } catch { - } - } - } - - $pythonCommand = Get-Command python -ErrorAction SilentlyContinue - if ($pythonCommand) { - try { - $pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim() - if ($pythonVersion -in @("3.12", "3.11")) { - return @("python") - } - } catch { - } - } - - return $null -} - -function Invoke-Python { - param( - [string[]]$PythonCommand, - [string[]]$Arguments - ) - - $exe = $PythonCommand[0] - $prefix = @() - if ($PythonCommand.Length -gt 1) { - $prefix = $PythonCommand[1..($PythonCommand.Length - 1)] - } - - & $exe @prefix @Arguments -} - -$pythonCmd = Get-CompatiblePythonCommand -if (-not $pythonCmd) { - Write-Host "" - Write-Host "No compatible Python version was found." - Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows." - Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet." - Write-Host "" - Write-Host "Install Python 3.12 first, then run:" - Write-Host " .\desktop\run_desktop_dev.ps1" - exit 1 -} - -Write-Host "Using Python: $($pythonCmd -join ' ')" -Write-Host "Ensuring desktop dependencies..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt") - -Write-Host "Starting desktop app..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:18080") diff --git a/desktop/run_desktop_dist.ps1 b/desktop/run_desktop_dist.ps1 deleted file mode 100644 index 39025d2..0000000 --- a/desktop/run_desktop_dist.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$root = Split-Path -Parent $PSScriptRoot -Set-Location $root - -function Get-CompatiblePythonCommand { - $pyLauncher = Get-Command py -ErrorAction SilentlyContinue - if ($pyLauncher) { - foreach ($version in @("3.12", "3.11")) { - try { - & py "-$version" --version *> $null - if ($LASTEXITCODE -eq 0) { - return @("py", "-$version") - } - } catch { - } - } - } - - $pythonCommand = Get-Command python -ErrorAction SilentlyContinue - if ($pythonCommand) { - try { - $pythonVersion = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null).Trim() - if ($pythonVersion -in @("3.12", "3.11")) { - return @("python") - } - } catch { - } - } - - return $null -} - -function Invoke-Python { - param( - [string[]]$PythonCommand, - [string[]]$Arguments - ) - - $exe = $PythonCommand[0] - $prefix = @() - if ($PythonCommand.Length -gt 1) { - $prefix = $PythonCommand[1..($PythonCommand.Length - 1)] - } - - & $exe @prefix @Arguments -} - -$pythonCmd = Get-CompatiblePythonCommand -if (-not $pythonCmd) { - Write-Host "" - Write-Host "No compatible Python version was found." - Write-Host "Desktop mode requires Python 3.12 or 3.11 for pywebview on Windows." - Write-Host "Only Python 3.14 was detected, which will fail when installing pythonnet." - Write-Host "" - Write-Host "Install Python 3.12 first, then run:" - Write-Host " .\desktop\run_desktop_dist.ps1" - exit 1 -} - -Write-Host "Using Python: $($pythonCmd -join ' ')" -Write-Host "Ensuring desktop dependencies..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt") - -Write-Host "Starting desktop app..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dist", "--backend-url", "http://127.0.0.1:8000") diff --git a/desktop/txt_templates.json b/desktop/txt_templates.json deleted file mode 100644 index da5deed..0000000 --- a/desktop/txt_templates.json +++ /dev/null @@ -1,140 +0,0 @@ -{ - "default_template_id": null, - "templates": { - "user_1773929220357": { - "id": "user_1773929220357", - "label": "自定义 Offer 模板", - "output_filename": "英国.txt", - "is_default": false, - "built_in": false, - "preamble_lines": [ - "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" - ], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available" - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": [ - "ASIN", - "价格" - ], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4" - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN" - } - }, - "user_1773929734779": { - "id": "user_1773929734779", - "label": "测试", - "output_filename": "测试.txt", - "is_default": false, - "built_in": false, - "preamble_lines": [ - "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" - ], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available" - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": [ - "ASIN", - "价格" - ], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4" - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN" - } - }, - "user_1773982097249": { - "id": "user_1773982097249", - "label": "默认", - "output_filename": "默认.txt", - "is_default": false, - "built_in": false, - "preamble_lines": [ - "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" - ], - "header_columns": [ - "sku", - "price", - "quantity", - "product-id", - "product-id-type", - "condition-type", - "condition-note", - "ASIN-hint", - "title", - "product-tax-code", - "operation-type", - "sale-price", - "sale-start-date", - "sale-end-date", - "leadtime-to-ship", - "launch-date", - "is-giftwrap-available", - "is-gift-message-available" - ], - "blank_header_rows_after_schema": 1, - "required_source_columns": [ - "ASIN", - "价格" - ], - "defaults": { - "quantity": "100", - "product-id-type": "ASIN", - "condition-type": "new", - "leadtime-to-ship": "4" - }, - "field_mapping": { - "price": "价格", - "product-id": "ASIN" - } - } - } -} \ No newline at end of file