From 4e5ed76cb6bd7000ae6aa76833501f78af31f928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=93=AD=E5=9D=A4?= <10372463+ming-kun@user.noreply.gitee.com> Date: Fri, 17 Apr 2026 10:04:24 +0800 Subject: [PATCH 1/3] modified: amazon/__pycache__/approve.cpython-39.pyc modified: amazon/__pycache__/del_brand.cpython-39.pyc modified: amazon/__pycache__/match_action.cpython-39.pyc modified: amazon/approve.py modified: amazon/del_brand.py modified: amazon/match_action.py new file: amazon/price_match.py modified: main.py --- app/amazon/__pycache__/approve.cpython-39.pyc | Bin 26738 -> 32208 bytes .../__pycache__/del_brand.cpython-39.pyc | Bin 17688 -> 18508 bytes .../__pycache__/match_action.cpython-39.pyc | Bin 18706 -> 18786 bytes app/amazon/approve.py | 413 +++++++++++++++--- app/amazon/del_brand.py | 68 ++- app/amazon/match_action.py | 17 +- app/amazon/price_match.py | 333 ++++++++++++++ app/main.py | 16 - 8 files changed, 749 insertions(+), 98 deletions(-) create mode 100644 app/amazon/price_match.py diff --git a/app/amazon/__pycache__/approve.cpython-39.pyc b/app/amazon/__pycache__/approve.cpython-39.pyc index 694a2d5cee1d64f680bb9ed8b176ebd347fd507a..b1fa641ac02a44dcd9e42eaa70b176431d6509a6 100644 GIT binary patch delta 11734 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 K6W)s#*8c}iTn^a) delta 6645 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 Hj}ZAkw)#T5 diff --git a/app/amazon/__pycache__/del_brand.cpython-39.pyc b/app/amazon/__pycache__/del_brand.cpython-39.pyc index 8551dba5aa59039591424dd554590a51627a4257..0e5aacf5b2585477caac6042e2f30e0c05800b31 100644 GIT binary patch delta 1821 zcmZ`(Yj9IV6y9_5x=GVCw6r`dCB`z)p`|z~l=3RH)^Qve2UM_HJ83u2gmRPay_ZKx zNg?uVYtvbVM^Tzk@HNgvCg?Cw&|i)}9Dn)8j{YM_%MY1h^uK3sQYcz;C*R#Y-#OwELw=A#bN0(2+Cf?xLoK`&#C1^^2_`(E0XIfg3kf1j` zvGNswb?M(${qBIl^mi}ql_1BLt!s)Fp}Z)(8UN}DiIBL|*Mb$2AOs}}RHCL~32Qe| za|~LLZTI$@4BkvF)Jko`QomWVM3ANOelyz{F;jU730fmS?bIP+3*JQqZwq=`3f_Iu zh()+3Y)MjZd`7kq>MVp>HJKJ;xHSebQIs( zMMW_ct_s?#`)vg=H?CT&O2(86P(pqph9e;smsn{;HdYD2B3e49M$^`Z&N-Q>x zo=lfS>_*8h(K!*7D8ZgbiLwx#mI|l*vzt)+E=8j88B0%~bK>8|C={apdqBKbs5uv!7tbUJBB#${1NB+H*tR+;!&!ch& zi{qJWWv3Bm%n1DEdN26+uj?D5`c9l54*Rt3#`=0S>{H%v3ee47pRRS+seXmlb%m(k z>+vbn(-l%QUqDe;sTvC?`&O-~sr6YVcI*T0sVQxCv6~%YH>V)j<_d*1V^Z+68-41)KCym2T3*&^=?U!% zD1QUqWqU9y0W*|>3*6wuw?KSLZnQW-#=iq9B*D7yS%~v$wC35omzX&v^pxBt*<@tl zsST}FpJHlO@+`Q;^d!QWbXnsq2`=)VHtm7>^z)mYU`wFAm5uY4w+zC0{==5>@{7X# zNjF(iOfPV4>l(PmC$_$U7x<&CL4a}Iw(VMx!5W)%k=wTSz`I=A{&wSpi1|#wmjdRi z=PDAlc&V#04rDM(@&dVnUFA}9tM!Tq8sj^f>)|>-*8C$}<*&A^-gsS@M-bYq>Y)x* o?_y7NZV1je2gd0zsr&W3_7{RE5nI6Tx4dZ4+)=WE|J73c58j0q4FCWD delta 904 zcmZWmO-vI}5Pq}WZI}LRTae#?63_@r0DmA*ApE!f1TRQX)R2aU($bW=-Kr(6B-Vrk z0@h4K1e6w{5>M)yc=6)JlZi(YHC#-H@n|@D@VyNXjhpQE=FNOF-A#qV%f z3H#pO{Tf)h^h)U~g%bU8sRC|UGf^c4KoTTq=_l3B^;DUFKM%YE4?Zt%)3?fh2+*#7 zs$2)~>(N6Q0V4Xoss?}t{Y>>YE6nJJPhJ&Z3+Edt@vre@#kpG|-|cOQ@t z{}%)@NT}3LG)G3Et}L6{^?9Sszw%D+CJvHtqewJo8sZ)xNeht4QYPlsTp4$k6cRr4 zL2{8$7Q1Z;v&ZqXp41a+WsAr?hgx$5jNrp&4~9ME4)qpWpjVw{sHX9SXF%M~OiZ*? zm~zzueA!Y`H^XDI9O4YV4rZP8&$_Rp_2jjohShrmDXacX)P*H zCL-GK$OIi74um5yO|9X)B;L=@Ym!0}*8<^fw%KSc^AZrj1$M|a&<=LNWmrWZvr}df z{xb%CZ}q|w9%(CuY2DZMMt}wU-F^Yw`ap*rERR_!ueyR`owIY5@lR-HQd2UDlr_5c6? diff --git a/app/amazon/__pycache__/match_action.cpython-39.pyc b/app/amazon/__pycache__/match_action.cpython-39.pyc index 7d422023faded7214fc3bb0dfdbbc3754ccf5aed..cf06b6fd20432e633a659bf27e05cf3dd8f4a495 100644 GIT binary patch delta 5297 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 X?3`r>JM$o-67lS|CgK$DZ)^G=2FNxW delta 5238 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 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..e2b2b96 100644 --- a/app/amazon/match_action.py +++ b/app/amazon/match_action.py @@ -127,13 +127,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 +230,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 +275,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 +305,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 +424,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 +453,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 +586,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..d02ee8d --- /dev/null +++ b/app/amazon/price_match.py @@ -0,0 +1,333 @@ +import time +import re +import traceback + +from config import runing_task, runing_shop +from datetime import datetime +from amazon.del_brand import AmamzonBase, kill_process + +from amazon.tool import show_notification,get_shop_info + +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) + + # 使用 DrissionPage 启动浏览器(使用默认用户数据) + from DrissionPage import Chromium, ChromiumOptions + print("正在启动Chrome浏览器...") + + # 配置浏览器选项 + co = ChromiumOptions() + # 使用系统默认的用户数据目录 + # 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 accept_cookie(self): + pass + + 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) + + # 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/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): From f87c37528b832bfb67c0ad23148c7e524f44389c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=93=AD=E5=9D=A4?= <10372463+ming-kun@user.noreply.gitee.com> Date: Sun, 19 Apr 2026 00:18:28 +0800 Subject: [PATCH 2/3] modified: amazon/match_action.py modified: amazon/price_match.py modified: config.py --- app/amazon/match_action.py | 7 ++++- app/amazon/price_match.py | 63 ++++++++++++++++++++++++++++---------- app/config.py | 3 ++ 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/app/amazon/match_action.py b/app/amazon/match_action.py index e2b2b96..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)}") diff --git a/app/amazon/price_match.py b/app/amazon/price_match.py index d02ee8d..a8d9916 100644 --- a/app/amazon/price_match.py +++ b/app/amazon/price_match.py @@ -1,14 +1,17 @@ import time import re import traceback - -from config import runing_task, runing_shop from datetime import datetime -from amazon.del_brand import AmamzonBase, kill_process +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 = { "英国": { @@ -43,21 +46,46 @@ class ChromeAmzone: os.system('taskkill /f /t /im chrome.exe') time.sleep(2) - # 使用 DrissionPage 启动浏览器(使用默认用户数据) - from DrissionPage import Chromium, ChromiumOptions + 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 accept_cookie(self): - pass + 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): """ @@ -98,6 +126,9 @@ class ChromeAmzone: # 2. 切换国家/设置邮编 print(f"正在检查并设置邮编: {zip_code}") self._set_zip_code(zip_code) + + self.close_init_popup() + # 3. 抓取数据 print("正在抓取商品数据...") @@ -317,14 +348,14 @@ if __name__ == '__main__': print(f"采集成功: {result1}") # 示例2:采集德国站点的商品信息 - print("\n示例2:采集德国站点商品") - result2 = chrome.run(country="德国", asin="B0CC8CW9G2") - if result2: - print(f"采集成功: {result2}") + # print("\n示例2:采集德国站点商品") + # result2 = chrome.run(country="德国", asin="B0CC8CW9G2") + # if result2: + # print(f"采集成功: {result2}") - # 关闭浏览器 - print("\n正在关闭浏览器...") - chrome.close() + # # 关闭浏览器 + # 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 = { From 2ae720ae097973dd389074865b5d25949c9a11c2 Mon Sep 17 00:00:00 2001 From: supernijia <2903208875a@gmail.com> Date: Sat, 18 Apr 2026 16:23:13 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=96=87=E4=BB=B6=20desk?= =?UTF-8?q?top?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- desktop/README.md | 74 -- desktop/__init__.py | 1 - desktop/__pycache__/__init__.cpython-312.pyc | Bin 142 -> 0 bytes desktop/__pycache__/__init__.cpython-314.pyc | Bin 144 -> 0 bytes desktop/__pycache__/app.cpython-312.pyc | Bin 51068 -> 0 bytes desktop/__pycache__/app.cpython-314.pyc | Bin 22571 -> 0 bytes desktop/app.py | 1017 ------------------ desktop/requirements.txt | 3 - desktop/run_desktop_dev.ps1 | 67 -- desktop/run_desktop_dist.ps1 | 67 -- desktop/txt_templates.json | 140 --- 11 files changed, 1369 deletions(-) delete mode 100644 desktop/README.md delete mode 100644 desktop/__init__.py delete mode 100644 desktop/__pycache__/__init__.cpython-312.pyc delete mode 100644 desktop/__pycache__/__init__.cpython-314.pyc delete mode 100644 desktop/__pycache__/app.cpython-312.pyc delete mode 100644 desktop/__pycache__/app.cpython-314.pyc delete mode 100644 desktop/app.py delete mode 100644 desktop/requirements.txt delete mode 100644 desktop/run_desktop_dev.ps1 delete mode 100644 desktop/run_desktop_dist.ps1 delete mode 100644 desktop/txt_templates.json 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 74a00d43d8d163b6dddb7233512a3adcdb5aa1ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 142 zcmX@j%ge<81g~>lIYq;;_lhPbtkwwJTx; T>IRu#3}Sp@W@Kb6Vg|ARb%h~9 diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 6ec347af519426930e271e6cf7b65bb61a2d5284..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51068 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 Ti?vNU^;;{9O+3_Bii9M@d>}Ie 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 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