diff --git a/.gitignore b/.gitignore index 953c124..06a45e0 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ MANIFEST .installed.cfg # Build / packaging +app.zip build/ dist/ develop-eggs/ @@ -108,4 +109,4 @@ xlsx/ OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md 架构.md *ts.% -.omc \ No newline at end of file +.omc diff --git a/app/amazon/amazon_base.py b/app/amazon/amazon_base.py index 43ceaba..54b8fd1 100644 --- a/app/amazon/amazon_base.py +++ b/app/amazon/amazon_base.py @@ -417,8 +417,8 @@ class AmamzonBase(ZiniaoDriver): """ try: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if level == "ERROR": - show_notification(message, "error") + # if level == "ERROR": + # show_notification(message, "error") print(f"[{timestamp}] [{self.mark_name}] [{level}] {message}") except Exception as e: print(f"输出出错,{e}") @@ -689,8 +689,8 @@ class TaskBase: """ try: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if level == "ERROR": - show_notification(message, "error") + # if level == "ERROR": + # show_notification(message, "error") print(f"[{timestamp}] [{self.task_name}] [{level}] {message}") except Exception as e: print(f"输出出错,{e}") @@ -739,7 +739,7 @@ class TaskBase: if not shop_data: mes = f"获取店铺凭证失败,响应数据: {shop_data.get('message', '未知错误')}" self.log(mes, "ERROR") - show_notification(mes, "ERROR") + # show_notification(mes, "ERROR") continue password = shop_data["data"]["password"] diff --git a/app/amazon/approve.py b/app/amazon/approve.py index 8ebe73a..d079001 100644 --- a/app/amazon/approve.py +++ b/app/amazon/approve.py @@ -674,12 +674,12 @@ class ApproveTask(TaskBase): return current_url = None + max_retries = 200 #只要没有完成,一直重试 for _ in range(max_retries): try: self.process_country(driver, country_code, task_id, shop_name,risk_listing_filter,target_url=current_url) driver.reset_already_asin() driver.close_store() - break except Exception as e: self.log(f"处理国家 {country_code} 失败: {str(e)}", "ERROR") @@ -777,7 +777,7 @@ class ApproveTask(TaskBase): } result.append(country_data) - if len(result) > 20: + if len(result) > 10: self.post_result_batch(task_id, shop_name, country_code, result) result = [] diff --git a/app/amazon/chrome_base.py b/app/amazon/chrome_base.py index e0d4241..cc7d756 100644 --- a/app/amazon/chrome_base.py +++ b/app/amazon/chrome_base.py @@ -9,7 +9,7 @@ from datetime import datetime from DrissionPage import Chromium, ChromiumOptions from collections import defaultdict -from config import base_dir +from config import base_dir,debug from amazon.tool import get_shop_info,show_notification @@ -46,7 +46,8 @@ class ChromeAmzoneBase: 杀死当前谷歌浏览器进程,并使用 drissionpage 启动谷歌浏览器,使用系统安装的浏览器默认用户文件夹 """ print("正在关闭现有的chromium浏览器进程...") - os.system('taskkill /f /t /im chrome.exe') + if not debug: + os.system('taskkill /f /t /im chrome.exe') time.sleep(2) print("正在启动chromium浏览器...") @@ -70,8 +71,8 @@ class ChromeAmzoneBase: """ try: timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if level == "ERROR": - show_notification(message, "error") + # if level == "ERROR": + # show_notification(message, "error") print(f"[{timestamp}] [{self.mark_name}] [{level}] {message}") except Exception as e: print(f"输出出错,{e}") diff --git a/app/amazon/detail_spider.py b/app/amazon/detail_spider.py index 0dd8669..6db0607 100644 --- a/app/amazon/detail_spider.py +++ b/app/amazon/detail_spider.py @@ -79,7 +79,8 @@ class ChromeAmzone(ChromeAmzoneBase): except Exception as e: error_msg = f"运行出错: {traceback.format_exc()}" self.log(error_msg) - show_notification(f"采集失败: {str(e)}", "error") + # show_notification(f"采集失败: {str(e)}", "error") + raise RuntimeError(error_msg) return {} def _scrape_data(self): @@ -100,8 +101,27 @@ class ChromeAmzone(ChromeAmzoneBase): title_ele = self.tab.ele('xpath://h1[@id="title"]',timeout=30) title = title_ele.text data["title"] = title + sku_ele_ls = self.tab.eles('xpath://ul[@class="a-unordered-list a-vertical a-spacing-mini"]', timeout=20) + if len(sku_ele_ls) > 0: + data["title"] += sku_ele_ls[0].text imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20) - image_url = imge_ele.attr("src") + + image_url = "" + data_a_dynamic_image = imge_ele.attr("data-a-dynamic-image") + if data_a_dynamic_image: + dynamic_image_json = json.loads(data_a_dynamic_image) + self.log(f"图片信息:{dynamic_image_json}") + max_area = 0 + + for url, (width, height) in dynamic_image_json.items(): + area = width * height + if area > max_area: + max_area = area + image_url = url + + if not image_url: + image_url = imge_ele.attr("src") + data["image_url"] = image_url return data @@ -263,7 +283,7 @@ class SpiderTask(TaskBase): result.append(res) print("================") is_done = gp_index == len(groups)-1 - if len(result) > 20 or is_done: + if len(result) > 10 or is_done: self.post_result(task_id=task_id,chunkIndex=gp_index+1,chunkTotal=len(groups), asin=asin,item_data=result,is_done=is_done) result = [] diff --git a/app/amazon/match_action.py b/app/amazon/match_action.py index 5304085..9ea7cde 100644 --- a/app/amazon/match_action.py +++ b/app/amazon/match_action.py @@ -378,7 +378,7 @@ class MatchTak(TaskBase): "done": False }) - if len(result) > 20: + if len(result) > 10: self.post_result_batch(task_id, shop_name, country_code,result) result = [] diff --git a/app/amazon/price_match.py b/app/amazon/price_match.py index 9711aaf..a513b33 100644 --- a/app/amazon/price_match.py +++ b/app/amazon/price_match.py @@ -1063,7 +1063,7 @@ class PriceTask(TaskBase): "priceChangePrice") else "", } ) - if len(result) > 20: + if len(result) > 10: self.post_result_batch(task_id, shop_name, country_code, result) result = [] @@ -1297,4 +1297,9 @@ if __name__ == '__main__': task_data = {'type': 'price-track-run', 'ts': 1777746140975, 'data': {'task_id': 5658, 'result_id': 7627, 'shop_name': '郭锋滨', 'shop_id': '27769139291498', 'platform': '亚马逊', 'company_name': '尾号5578的公司115', 'shop_mall_name': '', 'matched': True, 'match_status': 'MATCHED', 'match_message': None, 'success': False, 'error': None, 'output_filename': None, 'download_url': None, 'task_status': 'RUNNING', 'loop_run_id': 235, 'round_index': 1, 'country_codes': ['UK', 'DE', 'FR', 'ES', 'IT'], 'mode': 'status', 'skip_asins': {'DE': ['B0FMKM1BYH', 'B0DJT78FNR', 'B0DJLJ73RB', 'B0D6QL2JMY', 'B0CFKLPKF5', 'B09BMWX6Q1', 'B07C4MNWFL', 'B0FHDGLQK7', 'B0FHD9S7K7', 'B0FB2FB6G6', 'B0F9W8VXWQ', 'B0F9W81F2F', 'B0F9W7KLNM', 'B0F2ZJWJNC', 'B0D49F7LM4', 'B0CXSP3PDP', 'B0CLXZHM2G', 'B0CG8RNWL6', 'B0C4SZ8GZW', 'B0BVM53J3B', 'B0BNHN9N9X', 'B0BMXBNMP5', 'B0BLN7D2JZ', 'B09MYVN5YK', 'B09MBNG7P4', 'B0DXTSTZC6', 'B0D25871WL', 'B0CL69XRTY', 'B0CKXMDBN8', 'B0CGDF2G8V', 'B0CFTGXVYF', 'B0CFT6XPPD', 'B09YXTN8JP', 'B0DHX32XJH', 'B0DHL7FQJM', 'B0DHL63M79', 'B0CWXYY74V', 'B0CWXYRGZW', 'B0CPLR6YHB', 'B0BXP4RC25', 'B0BFRRZDCH', 'B0B65R3N47', 'B0F36ZSWTQ', 'B0DT1DYWC6', 'B0DNZ5WX1L', 'B0DNZ5N7HY', 'B0DCJJ3PCM', 'B0D8JV9MB9', 'B0D2KCRLJ1', 'B0CRVRDS8C', 'B0CQK7QWJB', 'B0CQK5SRXW', 'B0BCL2DLPC', 'B0B9JBHPFY', 'B09TVY4V8Z', 'B0FJBD6Q3D', 'B0DG8LQLXZ', 'B0CD7Q1BX3', 'B0BV1SR48Y', 'B0F29CFJJ9', 'B0DSL6PKR7', 'B0CPV21W4L', 'B0CNQZ7FXY', 'B0CLY9XT5M', 'B0CGTMZ8GP', 'B0CF881PG4', 'B0CF4K1G4R', 'B0CB5T79XG', 'B0C73PQ9KM', 'B0C28TBN79', 'B09T31ZRJF', 'B01LBOYWNO', 'B0DDTB25YZ', 'B0D1Q8GL1Y', 'B0CW9JC84B', 'B0CLGJG36H', 'B0CF9JWKHJ', 'B0C2J3V92Q', 'B0BVVYZ6TN', 'B0B8J8VF7C', 'B0B4BFVZD1', 'B09R1CPKVX', 'B0924BNVH2', 'B0F54M2147', 'B0F1BZNBY6', 'B0F1BXV9WG', 'B0DC957VHC', 'B0D8778SZB', 'B0CP9WVSRY', 'B0CKNQP1PT', 'B0CJ4PW758', 'B0BYFD87C6', 'B0B492K9KT', 'B09968XMC7', 'B0FGJ8RCW1', 'B0FGJ4WK8B', 'B0FFMYGWHP', 'B0FD3DLFKL', 'B0FBWRWXG2', 'B0F99RW38N', 'B0F62LZ4G3', 'B0F629FLVV', 'B0F2N88DS9', 'B0F23Y11GD', 'B0F1YV8T2S', 'B0DXK9PR5X', 'B0DRV7QZ85', 'B0DRV7LN4V', 'B0DR4QYC2M', 'B0D5QL869M', 'B0D41VK3YL', 'B0D3XBW4KT', 'B0D2KW1ZRL', 'B0CPPC6PDW', 'B0CHVPSJ61', 'B0C5C1V5JD', 'B0C232Z6C6', 'B0C22KBMJC', 'B0BZVL6Y9X', 'B0BX58MW7N', 'B0B9M86GMC', 'B08DQVJ2VB', 'B07WKYLJ3T', 'B092D7P9BP', 'B0F9W2MZJY', 'B09KXV2C3T', 'B092D7TM65', 'B0CR3P2F5L', 'B0BWCSCYFL', 'B08JLW8S3Y', 'B0D4H73BJH', 'B08D77WTYG', 'B08D73XW2J', 'B09H4RB4ZL', 'B09WMQ29GH', 'B0DJ2M2RYC', 'B0BGF2QCKT', 'B0DS6FC8M7', 'B098QFLVDZ', 'B0DMD16GTD', 'B0CNVLLQ3J', 'B0CP3JZ1P5', 'B0C1GR4K6W', 'B0CBM792D2', 'B09NJK6K5T', 'B0D8Q5MK31', 'B0CYSCV19T', 'B0C5D5XPLJ', 'B09R3NFGH2', 'B09BG1QD9M', 'B092QY1823', 'B0C55PGK51', 'B0DRN8MXRG', 'B08152JKFB', 'B09Y1P7GK7', 'B0CG62X5GT', 'B0DHJKKZDD', 'B0DYHZ59YL', 'B0D2D5WS2D', 'B0G6LHVRKG', 'B0DQ53F84K', 'ASDASD', 'B0CJTCGDB7232', 'B0CJTCGDB7213', 'B0CJTCGDB79', 'B0CJTCGDB78', 'B0CJTCGDB77', 'B0CJTCGDB76', 'B0CJTCGDB75', 'B0CJTCGDB74', 'B0CJTCGDB73', 'B0CJTCGDB72', 'B0CJTCGDB71', 'B0CJTCGDB7', '1'], 'UK': ['B0FGJD2P9Q', 'B0DKBSWJ93', 'B0DJT78FNR', 'B0DFCR79L5', 'B0DCBJ3WY5', 'B0D9LPW1YB', 'B0D7VDQG19', 'B0CP5K31TL', 'B0CG3RG7RQ', 'B0C8DSPMNC', 'B0B6H8RCHC', 'B08TVWB36K', 'B08FZMZQ11', 'B0G6LHVRKG', 'B0FF36SY6Q', 'B0DYTNBMB2', 'B0DQNLGSMQ', 'B0DNSNN7NH', 'B0DJWHHFBS', 'B0DJHSJ9LS', 'B0D2D5WS2D', 'B0CG8RNWL6', 'B0C5D5XPLJ', 'B09WHYGBSR', 'B09WHV9JCJ', 'B09P4VCL2V', 'B09MYVN5YK', 'B09MBNG7P4', 'B0DM63394K', 'B0D1Q4RR99', 'B0CZD1HLY7', 'B0CWD9R2FS', 'B0CS6WYRFW', 'B0CRVTSQGQ', 'B0CR8FTHYP', 'B0CCB5Q5ZY', 'B0CC94W5G7', 'B0CC948Q8Q', 'B0CC93R18S', 'B0CC92F33P', 'B0BZ4DNWPY', 'B0BZ4CMQDL', 'B07Q4RBT32', 'B0DHX32XJH', 'B0DHX1HR7X', 'B0DHTT1X71', 'B0DHTRLQ2L', 'B0DHL7FQJM', 'B0D3V1BJX9', 'B0D3TZ45C3', 'B0D2SDNYS5', 'B0CQY5SM94', 'B0CQLN2FC1', 'B0CQF6W2JT', 'B0BY85GDF5', 'B0BY82BRTZ', 'B0B73K6QZQ', 'B0B4MDDD7D', 'B0F1X5Q9Q6', 'B0DYHPWTVJ', 'B0DQNJPS5M', 'B0DQ4B5SF7', 'B0DNZ5WX1L', 'B0DKX28SNX', 'B0DHVTGXPD', 'B0DHDBN98L', 'B0DFXRP8X1', 'B0DFGPGXY6', 'B0D8JV9MB9', 'B0D2KCRLJ1', 'B0CW63X5XY', 'B0CRVRDS8C', 'B0CRHWLDY4', 'B0CLZBR5CM', 'B0CL67FJFL', 'B0CG2C89ZZ', 'B0BP7YMCXH', 'B0BM8Y94QC', 'B0BJKBRTK7', 'B0BCL2DLPC', 'B0B6PSLTPF', 'B0B6PS4RQ1', 'B0B5L4HC95', 'B08ZT1PJ2K', 'B0FGQ31VWT', 'B0FFT33WRF', 'B0FD3S6DRN', 'B0F2GBR7MP', 'B0F2G5TG4S', 'B0F2FZVS7X', 'B0F2FYW3HN', 'B0F2B6924H', 'B0DSJMMMXN', 'B0DJT68ZM3', 'B0DJT66VG3', 'B0DJT5YJXD', 'B0DCGSFTYX', 'B0D8C6KL4G', 'B0C9H8MJC3', 'B0C85JQNRC', 'B0C5QQFN1G', 'B0BZ8FXRZG', 'B0BS8SW341', 'B0FLVP9N4Y', 'B0F2MPWSCQ', 'B0F29CFJJ9', 'B0DSKC1GQB', 'B0DSCBZSGS', 'B0DSCB329P', 'B0DLNXW8W5', 'B0DJQHVBZZ', 'B0DGCW7WBY', 'B0DFTG85NG', 'B0D99N1DZY', 'B0D8B849BJ', 'B0D89YCGCR', 'B0D89WK6JF', 'B0D89PMKKP', 'B0D7X1634D', 'B0D7V3W7D1', 'B0CYT3R89Z', 'B0CY7P61VD', 'B0CQ8B9PZM', 'B0CNK1SV4P', 'B0CF4K1G4R', 'B0CF4JJXHY', 'B0CF4BJ42D', 'B0C6T346MQ', 'B0BX2KDVMY', 'B0BR4YWSF9', 'B0BR4XHP5Z', 'B09Z68FK4Q', 'B09SL3PTTM', 'B09JBQZQQL', 'B08PSSRS3C', 'B08G4P4F76', 'B0DRD7T6TQ', 'B0DQXT78DT', 'B0DQXRV47R', 'B0DQXRV18S', 'B0DQXRLK5X', 'B0DQXQNL6Z', 'B0D2KQVS2Y', 'B0CXJC74TK', 'B0CW9JC84B', 'B0CSYBNXWX', 'B0CRHSTGX6', 'B0CPPJTX5C', 'B0CJB9WFHD', 'B0CF9JWKHJ', 'B0BVVYZ6TN', 'B0BND2YKJF', 'B09TRCQNQH', 'B09H5J6YQX', 'B09CNRP5FJ', 'B08BZ9LW9Z', 'B0F54M2147', 'B0F388HQLY', 'B0F1BZNBY6', 'B0F1BXV9WG', 'B0DSD949P4', 'B0DFQ2FP96', 'B0D9YXGFQW', 'B0CP9WVSRY', 'B0CKZ35SJC', 'B0CHYNDX87', 'B0CH66SNFC', 'B0BHSWP8RD', 'B0BGRW6858', 'B0B6JKD25B', 'B0B1LJB3M5', 'B09P3S4SH5', 'B09NLSRHGV', 'B0FVDPV5NS', 'B0FQJ5CDL2', 'B0FLJLWTLV', 'B0FGQ1VP2J', 'B0FD6YL7MM', 'B0FBWP9GJL', 'B0F99RW38N', 'B0F3Y3M538', 'B0F27ZM2TG', 'B0F23Y11GD', 'B0F23WZ78P', 'B0F1MY4PF6', 'B0DXKC6H1G', 'B0DQLNTLF4', 'B0DM8DYG77', 'B0DHJNBFVH', 'B0DH1XKG95', 'B0DFYG7C4K', 'B0DFTR1CMN', 'B0DB8FM915', 'B0D5H65VW3', 'B0D3XBW4KT', 'B0CRKJWJ4X', 'B0CPPGX6FQ', 'B0CPPGSBHD', 'B0CL9PVZD1', 'B0C232Z6C6', 'B0C22KBMJC', 'B0BX58MW7N', 'B0B6T3N5MG', 'B0B4S9QYWM', 'B0B4S93T7Q', 'B09R1PMW3J', 'B08MLN92JM', 'B0BHN71FWM', 'B09NKRWXPC', 'B0C89TNVTN', 'B0D1VDMZR1', 'B0DS2QT1YZ', 'B09Y29P5YF', 'B0DBQDSHVZ', 'B0CFF62JY3', 'B0BHPGTWQR', 'B0D4F4D3W2', 'B0F13BRF4V', 'B0D1FXTY99', 'B00KY0LLFO', 'B0B7MJXDZC', 'B0B4DV8NLV', 'B0DJXRFCF7', 'B0BPHMFD7V', 'B0F281ZQWR', 'B0D7V2FNW3', 'B0D57X7KV9', 'B0CJ2SST5Z', 'B0CZ92LPP4', 'B0CG5Z2Z44', 'B0D6T9H5LX', 'B0CQJNR9MP', 'ZXCA', 'B0CJTGDB7232', 'B0CJTGDB7213', 'B0CJTGDB79', 'B0CJTGDB78', 'B0CJTGDB77', 'B0CJCGDB76', 'B0CJCGDB75', 'B0CJCGDB74', 'B0CJCGDB73', 'B0CJCGDB72', 'B0CJCGDB71', 'B0CJCGDB7', 'B0CTCGDB7', 'B0FGTNLTG3', 'WQDASDAS', 'SADAD', 'DFHFGH78979'], 'FR': ['B0FY6JY4BF', 'B0DZNRB5Q1', 'B0DNSKFLGK', 'B0DGLCQDTR', 'B0D9W93869', 'B0CG8RNWL6', 'B0BX94RYT1', 'B0BLN7D2JZ', 'B09WHV9JCJ', 'B0DKX28SNX', 'B0CZCVTMRJ', 'B0CC95H28M', 'B0CC942XSZ', 'B0CC93WW6S', 'B0CC93TDB9', 'B0816GGCFY', 'B0CQY5SM94', 'B0DQTKRNLH', 'B0DCB85F3J', 'B0D8JR3W53', 'B0D2KCRLJ1', 'B0CQK7QWJB', 'B0B9XM29C9', 'B0B5L4HC95', 'B0F9FCT8RD', 'B0BZ8J16JM', 'B0BV1TRMGN', 'B09G68LMB3', 'B0DCRJSMJP', 'B093Y447R2', 'B0D8Y5KWB8', 'B0BJK43Z6X', 'B0DXK7YZ5D', 'B0C232Z6C6', 'B0BZVL6Y9X', 'B0B4S98HLZ', 'B09VPMBMB8', 'B0D17L6YKN'], 'IT': ['B0DTK87KJ5', 'B0DNSNN7NH', 'B0D9W93869', 'B0CLXZHM2G', 'B0CG8RNWL6', 'B0BX94RYT1', 'B0BVM53J3B', 'B0BLN7D2JZ', 'B0F9KVS6Y2', 'B0F8QGHMLL', 'B0CY5KW8RY', 'B0CC94P4H1', 'B0CC94K2HN', 'B0CC922QN8', 'B0C7Q51PL1', 'B0D8JR3W53', 'B0B5L4HC95', 'B0C5C53444', 'B0F5PRVRLF', 'B0BJK43Z6X', 'B09WKFD3G8', 'B08NT2BNGH', 'B0DRV6D214', 'B08QRKHD3C', 'B09JMW3F1R', 'B0CKYHTDJ5', 'B098QFLVDZ', '32EGDFGS4', 'B087JP6PWL89'], 'ES': ['B0D2J11TKY', 'B0CLY2THTY', 'B0D8JS8NPH', 'B0CYGSBXSQ', 'B0CKXMDBN8', 'B0CJ5F9J4Z', 'B0CC95XMD4', 'B0CC93ZNH2', 'B07L281N1M', 'B0DNZ5N7HY', 'B0C7V4BFSZ', 'B0B5L4HC95', 'B0DZCC4B4T', 'B0DZBQ4ZNC', 'B0C5QQFN1G', 'B0BFXD4LYN', 'B0F3122Q81', 'B0BG73XTFC', 'B0CNM269LZ', 'B08QS37RHQ', 'B087JP6PWL89']}, 'skip_asins_by_country': {'DE': ['B0FMKM1BYH', 'B0DJT78FNR', 'B0DJLJ73RB', 'B0D6QL2JMY', 'B0CFKLPKF5', 'B09BMWX6Q1', 'B07C4MNWFL', 'B0FHDGLQK7', 'B0FHD9S7K7', 'B0FB2FB6G6', 'B0F9W8VXWQ', 'B0F9W81F2F', 'B0F9W7KLNM', 'B0F2ZJWJNC', 'B0D49F7LM4', 'B0CXSP3PDP', 'B0CLXZHM2G', 'B0CG8RNWL6', 'B0C4SZ8GZW', 'B0BVM53J3B', 'B0BNHN9N9X', 'B0BMXBNMP5', 'B0BLN7D2JZ', 'B09MYVN5YK', 'B09MBNG7P4', 'B0DXTSTZC6', 'B0D25871WL', 'B0CL69XRTY', 'B0CKXMDBN8', 'B0CGDF2G8V', 'B0CFTGXVYF', 'B0CFT6XPPD', 'B09YXTN8JP', 'B0DHX32XJH', 'B0DHL7FQJM', 'B0DHL63M79', 'B0CWXYY74V', 'B0CWXYRGZW', 'B0CPLR6YHB', 'B0BXP4RC25', 'B0BFRRZDCH', 'B0B65R3N47', 'B0F36ZSWTQ', 'B0DT1DYWC6', 'B0DNZ5WX1L', 'B0DNZ5N7HY', 'B0DCJJ3PCM', 'B0D8JV9MB9', 'B0D2KCRLJ1', 'B0CRVRDS8C', 'B0CQK7QWJB', 'B0CQK5SRXW', 'B0BCL2DLPC', 'B0B9JBHPFY', 'B09TVY4V8Z', 'B0FJBD6Q3D', 'B0DG8LQLXZ', 'B0CD7Q1BX3', 'B0BV1SR48Y', 'B0F29CFJJ9', 'B0DSL6PKR7', 'B0CPV21W4L', 'B0CNQZ7FXY', 'B0CLY9XT5M', 'B0CGTMZ8GP', 'B0CF881PG4', 'B0CF4K1G4R', 'B0CB5T79XG', 'B0C73PQ9KM', 'B0C28TBN79', 'B09T31ZRJF', 'B01LBOYWNO', 'B0DDTB25YZ', 'B0D1Q8GL1Y', 'B0CW9JC84B', 'B0CLGJG36H', 'B0CF9JWKHJ', 'B0C2J3V92Q', 'B0BVVYZ6TN', 'B0B8J8VF7C', 'B0B4BFVZD1', 'B09R1CPKVX', 'B0924BNVH2', 'B0F54M2147', 'B0F1BZNBY6', 'B0F1BXV9WG', 'B0DC957VHC', 'B0D8778SZB', 'B0CP9WVSRY', 'B0CKNQP1PT', 'B0CJ4PW758', 'B0BYFD87C6', 'B0B492K9KT', 'B09968XMC7', 'B0FGJ8RCW1', 'B0FGJ4WK8B', 'B0FFMYGWHP', 'B0FD3DLFKL', 'B0FBWRWXG2', 'B0F99RW38N', 'B0F62LZ4G3', 'B0F629FLVV', 'B0F2N88DS9', 'B0F23Y11GD', 'B0F1YV8T2S', 'B0DXK9PR5X', 'B0DRV7QZ85', 'B0DRV7LN4V', 'B0DR4QYC2M', 'B0D5QL869M', 'B0D41VK3YL', 'B0D3XBW4KT', 'B0D2KW1ZRL', 'B0CPPC6PDW', 'B0CHVPSJ61', 'B0C5C1V5JD', 'B0C232Z6C6', 'B0C22KBMJC', 'B0BZVL6Y9X', 'B0BX58MW7N', 'B0B9M86GMC', 'B08DQVJ2VB', 'B07WKYLJ3T', 'B092D7P9BP', 'B0F9W2MZJY', 'B09KXV2C3T', 'B092D7TM65', 'B0CR3P2F5L', 'B0BWCSCYFL', 'B08JLW8S3Y', 'B0D4H73BJH', 'B08D77WTYG', 'B08D73XW2J', 'B09H4RB4ZL', 'B09WMQ29GH', 'B0DJ2M2RYC', 'B0BGF2QCKT', 'B0DS6FC8M7', 'B098QFLVDZ', 'B0DMD16GTD', 'B0CNVLLQ3J', 'B0CP3JZ1P5', 'B0C1GR4K6W', 'B0CBM792D2', 'B09NJK6K5T', 'B0D8Q5MK31', 'B0CYSCV19T', 'B0C5D5XPLJ', 'B09R3NFGH2', 'B09BG1QD9M', 'B092QY1823', 'B0C55PGK51', 'B0DRN8MXRG', 'B08152JKFB', 'B09Y1P7GK7', 'B0CG62X5GT', 'B0DHJKKZDD', 'B0DYHZ59YL', 'B0D2D5WS2D', 'B0G6LHVRKG', 'B0DQ53F84K', 'ASDASD', 'B0CJTCGDB7232', 'B0CJTCGDB7213', 'B0CJTCGDB79', 'B0CJTCGDB78', 'B0CJTCGDB77', 'B0CJTCGDB76', 'B0CJTCGDB75', 'B0CJTCGDB74', 'B0CJTCGDB73', 'B0CJTCGDB72', 'B0CJTCGDB71', 'B0CJTCGDB7', '1'], 'UK': ['B0FGJD2P9Q', 'B0DKBSWJ93', 'B0DJT78FNR', 'B0DFCR79L5', 'B0DCBJ3WY5', 'B0D9LPW1YB', 'B0D7VDQG19', 'B0CP5K31TL', 'B0CG3RG7RQ', 'B0C8DSPMNC', 'B0B6H8RCHC', 'B08TVWB36K', 'B08FZMZQ11', 'B0G6LHVRKG', 'B0FF36SY6Q', 'B0DYTNBMB2', 'B0DQNLGSMQ', 'B0DNSNN7NH', 'B0DJWHHFBS', 'B0DJHSJ9LS', 'B0D2D5WS2D', 'B0CG8RNWL6', 'B0C5D5XPLJ', 'B09WHYGBSR', 'B09WHV9JCJ', 'B09P4VCL2V', 'B09MYVN5YK', 'B09MBNG7P4', 'B0DM63394K', 'B0D1Q4RR99', 'B0CZD1HLY7', 'B0CWD9R2FS', 'B0CS6WYRFW', 'B0CRVTSQGQ', 'B0CR8FTHYP', 'B0CCB5Q5ZY', 'B0CC94W5G7', 'B0CC948Q8Q', 'B0CC93R18S', 'B0CC92F33P', 'B0BZ4DNWPY', 'B0BZ4CMQDL', 'B07Q4RBT32', 'B0DHX32XJH', 'B0DHX1HR7X', 'B0DHTT1X71', 'B0DHTRLQ2L', 'B0DHL7FQJM', 'B0D3V1BJX9', 'B0D3TZ45C3', 'B0D2SDNYS5', 'B0CQY5SM94', 'B0CQLN2FC1', 'B0CQF6W2JT', 'B0BY85GDF5', 'B0BY82BRTZ', 'B0B73K6QZQ', 'B0B4MDDD7D', 'B0F1X5Q9Q6', 'B0DYHPWTVJ', 'B0DQNJPS5M', 'B0DQ4B5SF7', 'B0DNZ5WX1L', 'B0DKX28SNX', 'B0DHVTGXPD', 'B0DHDBN98L', 'B0DFXRP8X1', 'B0DFGPGXY6', 'B0D8JV9MB9', 'B0D2KCRLJ1', 'B0CW63X5XY', 'B0CRVRDS8C', 'B0CRHWLDY4', 'B0CLZBR5CM', 'B0CL67FJFL', 'B0CG2C89ZZ', 'B0BP7YMCXH', 'B0BM8Y94QC', 'B0BJKBRTK7', 'B0BCL2DLPC', 'B0B6PSLTPF', 'B0B6PS4RQ1', 'B0B5L4HC95', 'B08ZT1PJ2K', 'B0FGQ31VWT', 'B0FFT33WRF', 'B0FD3S6DRN', 'B0F2GBR7MP', 'B0F2G5TG4S', 'B0F2FZVS7X', 'B0F2FYW3HN', 'B0F2B6924H', 'B0DSJMMMXN', 'B0DJT68ZM3', 'B0DJT66VG3', 'B0DJT5YJXD', 'B0DCGSFTYX', 'B0D8C6KL4G', 'B0C9H8MJC3', 'B0C85JQNRC', 'B0C5QQFN1G', 'B0BZ8FXRZG', 'B0BS8SW341', 'B0FLVP9N4Y', 'B0F2MPWSCQ', 'B0F29CFJJ9', 'B0DSKC1GQB', 'B0DSCBZSGS', 'B0DSCB329P', 'B0DLNXW8W5', 'B0DJQHVBZZ', 'B0DGCW7WBY', 'B0DFTG85NG', 'B0D99N1DZY', 'B0D8B849BJ', 'B0D89YCGCR', 'B0D89WK6JF', 'B0D89PMKKP', 'B0D7X1634D', 'B0D7V3W7D1', 'B0CYT3R89Z', 'B0CY7P61VD', 'B0CQ8B9PZM', 'B0CNK1SV4P', 'B0CF4K1G4R', 'B0CF4JJXHY', 'B0CF4BJ42D', 'B0C6T346MQ', 'B0BX2KDVMY', 'B0BR4YWSF9', 'B0BR4XHP5Z', 'B09Z68FK4Q', 'B09SL3PTTM', 'B09JBQZQQL', 'B08PSSRS3C', 'B08G4P4F76', 'B0DRD7T6TQ', 'B0DQXT78DT', 'B0DQXRV47R', 'B0DQXRV18S', 'B0DQXRLK5X', 'B0DQXQNL6Z', 'B0D2KQVS2Y', 'B0CXJC74TK', 'B0CW9JC84B', 'B0CSYBNXWX', 'B0CRHSTGX6', 'B0CPPJTX5C', 'B0CJB9WFHD', 'B0CF9JWKHJ', 'B0BVVYZ6TN', 'B0BND2YKJF', 'B09TRCQNQH', 'B09H5J6YQX', 'B09CNRP5FJ', 'B08BZ9LW9Z', 'B0F54M2147', 'B0F388HQLY', 'B0F1BZNBY6', 'B0F1BXV9WG', 'B0DSD949P4', 'B0DFQ2FP96', 'B0D9YXGFQW', 'B0CP9WVSRY', 'B0CKZ35SJC', 'B0CHYNDX87', 'B0CH66SNFC', 'B0BHSWP8RD', 'B0BGRW6858', 'B0B6JKD25B', 'B0B1LJB3M5', 'B09P3S4SH5', 'B09NLSRHGV', 'B0FVDPV5NS', 'B0FQJ5CDL2', 'B0FLJLWTLV', 'B0FGQ1VP2J', 'B0FD6YL7MM', 'B0FBWP9GJL', 'B0F99RW38N', 'B0F3Y3M538', 'B0F27ZM2TG', 'B0F23Y11GD', 'B0F23WZ78P', 'B0F1MY4PF6', 'B0DXKC6H1G', 'B0DQLNTLF4', 'B0DM8DYG77', 'B0DHJNBFVH', 'B0DH1XKG95', 'B0DFYG7C4K', 'B0DFTR1CMN', 'B0DB8FM915', 'B0D5H65VW3', 'B0D3XBW4KT', 'B0CRKJWJ4X', 'B0CPPGX6FQ', 'B0CPPGSBHD', 'B0CL9PVZD1', 'B0C232Z6C6', 'B0C22KBMJC', 'B0BX58MW7N', 'B0B6T3N5MG', 'B0B4S9QYWM', 'B0B4S93T7Q', 'B09R1PMW3J', 'B08MLN92JM', 'B0BHN71FWM', 'B09NKRWXPC', 'B0C89TNVTN', 'B0D1VDMZR1', 'B0DS2QT1YZ', 'B09Y29P5YF', 'B0DBQDSHVZ', 'B0CFF62JY3', 'B0BHPGTWQR', 'B0D4F4D3W2', 'B0F13BRF4V', 'B0D1FXTY99', 'B00KY0LLFO', 'B0B7MJXDZC', 'B0B4DV8NLV', 'B0DJXRFCF7', 'B0BPHMFD7V', 'B0F281ZQWR', 'B0D7V2FNW3', 'B0D57X7KV9', 'B0CJ2SST5Z', 'B0CZ92LPP4', 'B0CG5Z2Z44', 'B0D6T9H5LX', 'B0CQJNR9MP', 'ZXCA', 'B0CJTGDB7232', 'B0CJTGDB7213', 'B0CJTGDB79', 'B0CJTGDB78', 'B0CJTGDB77', 'B0CJCGDB76', 'B0CJCGDB75', 'B0CJCGDB74', 'B0CJCGDB73', 'B0CJCGDB72', 'B0CJCGDB71', 'B0CJCGDB7', 'B0CTCGDB7', 'B0FGTNLTG3', 'WQDASDAS', 'SADAD', 'DFHFGH78979'], 'FR': ['B0FY6JY4BF', 'B0DZNRB5Q1', 'B0DNSKFLGK', 'B0DGLCQDTR', 'B0D9W93869', 'B0CG8RNWL6', 'B0BX94RYT1', 'B0BLN7D2JZ', 'B09WHV9JCJ', 'B0DKX28SNX', 'B0CZCVTMRJ', 'B0CC95H28M', 'B0CC942XSZ', 'B0CC93WW6S', 'B0CC93TDB9', 'B0816GGCFY', 'B0CQY5SM94', 'B0DQTKRNLH', 'B0DCB85F3J', 'B0D8JR3W53', 'B0D2KCRLJ1', 'B0CQK7QWJB', 'B0B9XM29C9', 'B0B5L4HC95', 'B0F9FCT8RD', 'B0BZ8J16JM', 'B0BV1TRMGN', 'B09G68LMB3', 'B0DCRJSMJP', 'B093Y447R2', 'B0D8Y5KWB8', 'B0BJK43Z6X', 'B0DXK7YZ5D', 'B0C232Z6C6', 'B0BZVL6Y9X', 'B0B4S98HLZ', 'B09VPMBMB8', 'B0D17L6YKN'], 'IT': ['B0DTK87KJ5', 'B0DNSNN7NH', 'B0D9W93869', 'B0CLXZHM2G', 'B0CG8RNWL6', 'B0BX94RYT1', 'B0BVM53J3B', 'B0BLN7D2JZ', 'B0F9KVS6Y2', 'B0F8QGHMLL', 'B0CY5KW8RY', 'B0CC94P4H1', 'B0CC94K2HN', 'B0CC922QN8', 'B0C7Q51PL1', 'B0D8JR3W53', 'B0B5L4HC95', 'B0C5C53444', 'B0F5PRVRLF', 'B0BJK43Z6X', 'B09WKFD3G8', 'B08NT2BNGH', 'B0DRV6D214', 'B08QRKHD3C', 'B09JMW3F1R', 'B0CKYHTDJ5', 'B098QFLVDZ', '32EGDFGS4', 'B087JP6PWL89'], 'ES': ['B0D2J11TKY', 'B0CLY2THTY', 'B0D8JS8NPH', 'B0CYGSBXSQ', 'B0CKXMDBN8', 'B0CJ5F9J4Z', 'B0CC95XMD4', 'B0CC93ZNH2', 'B07L281N1M', 'B0DNZ5N7HY', 'B0C7V4BFSZ', 'B0B5L4HC95', 'B0DZCC4B4T', 'B0DZBQ4ZNC', 'B0C5QQFN1G', 'B0BFXD4LYN', 'B0F3122Q81', 'B0BG73XTFC', 'B0CNM269LZ', 'B08QS37RHQ', 'B087JP6PWL89']}, 'skip_asin_details_by_country': {'DE': [{'asin': 'B0FMKM1BYH', 'minimumPrice': ''}, {'asin': 'B0DJT78FNR', 'minimumPrice': ''}, {'asin': 'B0DJLJ73RB', 'minimumPrice': ''}, {'asin': 'B0D6QL2JMY', 'minimumPrice': ''}, {'asin': 'B0CFKLPKF5', 'minimumPrice': ''}, {'asin': 'B09BMWX6Q1', 'minimumPrice': ''}, {'asin': 'B07C4MNWFL', 'minimumPrice': ''}, {'asin': 'B0FHDGLQK7', 'minimumPrice': ''}, {'asin': 'B0FHD9S7K7', 'minimumPrice': ''}, {'asin': 'B0FB2FB6G6', 'minimumPrice': ''}, {'asin': 'B0F9W8VXWQ', 'minimumPrice': ''}, {'asin': 'B0F9W81F2F', 'minimumPrice': ''}, {'asin': 'B0F9W7KLNM', 'minimumPrice': ''}, {'asin': 'B0F2ZJWJNC', 'minimumPrice': ''}, {'asin': 'B0D49F7LM4', 'minimumPrice': ''}, {'asin': 'B0CXSP3PDP', 'minimumPrice': ''}, {'asin': 'B0CLXZHM2G', 'minimumPrice': ''}, {'asin': 'B0CG8RNWL6', 'minimumPrice': ''}, {'asin': 'B0C4SZ8GZW', 'minimumPrice': ''}, {'asin': 'B0BVM53J3B', 'minimumPrice': ''}, {'asin': 'B0BNHN9N9X', 'minimumPrice': ''}, {'asin': 'B0BMXBNMP5', 'minimumPrice': ''}, {'asin': 'B0BLN7D2JZ', 'minimumPrice': ''}, {'asin': 'B09MYVN5YK', 'minimumPrice': ''}, {'asin': 'B09MBNG7P4', 'minimumPrice': ''}, {'asin': 'B09MBNG7P4', 'minimumPrice': '30.28'}, {'asin': 'B0DXTSTZC6', 'minimumPrice': ''}, {'asin': 'B0D25871WL', 'minimumPrice': ''}, {'asin': 'B0CL69XRTY', 'minimumPrice': ''}, {'asin': 'B0CKXMDBN8', 'minimumPrice': ''}, {'asin': 'B0CGDF2G8V', 'minimumPrice': ''}, {'asin': 'B0CFTGXVYF', 'minimumPrice': ''}, {'asin': 'B0CFT6XPPD', 'minimumPrice': ''}, {'asin': 'B09YXTN8JP', 'minimumPrice': ''}, {'asin': 'B0DHX32XJH', 'minimumPrice': ''}, {'asin': 'B0DHL7FQJM', 'minimumPrice': ''}, {'asin': 'B0DHL63M79', 'minimumPrice': ''}, {'asin': 'B0CWXYY74V', 'minimumPrice': ''}, {'asin': 'B0CWXYRGZW', 'minimumPrice': ''}, {'asin': 'B0CPLR6YHB', 'minimumPrice': ''}, {'asin': 'B0BXP4RC25', 'minimumPrice': ''}, {'asin': 'B0BFRRZDCH', 'minimumPrice': ''}, {'asin': 'B0B65R3N47', 'minimumPrice': ''}, {'asin': 'B0FB2FB6G6', 'minimumPrice': ''}, {'asin': 'B0F36ZSWTQ', 'minimumPrice': ''}, {'asin': 'B0DT1DYWC6', 'minimumPrice': ''}, {'asin': 'B0DNZ5WX1L', 'minimumPrice': ''}, {'asin': 'B0DNZ5N7HY', 'minimumPrice': ''}, {'asin': 'B0DCJJ3PCM', 'minimumPrice': ''}, {'asin': 'B0D8JV9MB9', 'minimumPrice': ''}, {'asin': 'B0D2KCRLJ1', 'minimumPrice': ''}, {'asin': 'B0CRVRDS8C', 'minimumPrice': ''}, {'asin': 'B0CQK7QWJB', 'minimumPrice': ''}, {'asin': 'B0CQK5SRXW', 'minimumPrice': ''}, {'asin': 'B0BCL2DLPC', 'minimumPrice': ''}, {'asin': 'B0B9JBHPFY', 'minimumPrice': ''}, {'asin': 'B09TVY4V8Z', 'minimumPrice': ''}, {'asin': 'B0FJBD6Q3D', 'minimumPrice': ''}, {'asin': 'B0DG8LQLXZ', 'minimumPrice': ''}, {'asin': 'B0CD7Q1BX3', 'minimumPrice': ''}, {'asin': 'B0BV1SR48Y', 'minimumPrice': ''}, {'asin': 'B0F29CFJJ9', 'minimumPrice': '26.70'}, {'asin': 'B0DSL6PKR7', 'minimumPrice': '8.80'}, {'asin': 'B0CPV21W4L', 'minimumPrice': '21.87'}, {'asin': 'B0CNQZ7FXY', 'minimumPrice': '74.46'}, {'asin': 'B0CLY9XT5M', 'minimumPrice': '19.72'}, {'asin': 'B0CGTMZ8GP', 'minimumPrice': '33.44'}, {'asin': 'B0CF881PG4', 'minimumPrice': '31.82'}, {'asin': 'B0CF4K1G4R', 'minimumPrice': '31.44'}, {'asin': 'B0CB5T79XG', 'minimumPrice': '16.59'}, {'asin': 'B0C73PQ9KM', 'minimumPrice': '20.92'}, {'asin': 'B0C28TBN79', 'minimumPrice': '12.70'}, {'asin': 'B09T31ZRJF', 'minimumPrice': '35.64'}, {'asin': 'B01LBOYWNO', 'minimumPrice': '11.21'}, {'asin': 'B0DDTB25YZ', 'minimumPrice': ''}, {'asin': 'B0D1Q8GL1Y', 'minimumPrice': ''}, {'asin': 'B0CW9JC84B', 'minimumPrice': ''}, {'asin': 'B0CLGJG36H', 'minimumPrice': ''}, {'asin': 'B0CF9JWKHJ', 'minimumPrice': ''}, {'asin': 'B0C2J3V92Q', 'minimumPrice': ''}, {'asin': 'B0BVVYZ6TN', 'minimumPrice': ''}, {'asin': 'B0B8J8VF7C', 'minimumPrice': ''}, {'asin': 'B0B4BFVZD1', 'minimumPrice': ''}, {'asin': 'B09R1CPKVX', 'minimumPrice': ''}, {'asin': 'B0924BNVH2', 'minimumPrice': ''}, {'asin': 'B0F54M2147', 'minimumPrice': ''}, {'asin': 'B0F1BZNBY6', 'minimumPrice': ''}, {'asin': 'B0F1BXV9WG', 'minimumPrice': ''}, {'asin': 'B0DC957VHC', 'minimumPrice': ''}, {'asin': 'B0D8778SZB', 'minimumPrice': ''}, {'asin': 'B0CP9WVSRY', 'minimumPrice': ''}, {'asin': 'B0CKNQP1PT', 'minimumPrice': ''}, {'asin': 'B0CJ4PW758', 'minimumPrice': ''}, {'asin': 'B0BYFD87C6', 'minimumPrice': ''}, {'asin': 'B0B492K9KT', 'minimumPrice': ''}, {'asin': 'B09968XMC7', 'minimumPrice': ''}, {'asin': 'B0FGJ8RCW1', 'minimumPrice': '12.09'}, {'asin': 'B0FGJ4WK8B', 'minimumPrice': '19.77'}, {'asin': 'B0FFMYGWHP', 'minimumPrice': '34.02'}, {'asin': 'B0FD3DLFKL', 'minimumPrice': '14.13'}, {'asin': 'B0FBWRWXG2', 'minimumPrice': '10.53'}, {'asin': 'B0F99RW38N', 'minimumPrice': '14.21'}, {'asin': 'B0F62LZ4G3', 'minimumPrice': '19.95'}, {'asin': 'B0F629FLVV', 'minimumPrice': '22.62'}, {'asin': 'B0F2N88DS9', 'minimumPrice': '18.43'}, {'asin': 'B0F23Y11GD', 'minimumPrice': '14.54'}, {'asin': 'B0F1YV8T2S', 'minimumPrice': '10.38'}, {'asin': 'B0DXK9PR5X', 'minimumPrice': '18.44'}, {'asin': 'B0DRV7QZ85', 'minimumPrice': '17.72'}, {'asin': 'B0DRV7LN4V', 'minimumPrice': '18.91'}, {'asin': 'B0DR4QYC2M', 'minimumPrice': '15.46'}, {'asin': 'B0D5QL869M', 'minimumPrice': '31.89'}, {'asin': 'B0D41VK3YL', 'minimumPrice': '15.94'}, {'asin': 'B0D3XBW4KT', 'minimumPrice': '26.18'}, {'asin': 'B0D2KW1ZRL', 'minimumPrice': '32.36'}, {'asin': 'B0CPPC6PDW', 'minimumPrice': '27.00'}, {'asin': 'B0CHVPSJ61', 'minimumPrice': '31.30'}, {'asin': 'B0C5C1V5JD', 'minimumPrice': '33.84'}, {'asin': 'B0C232Z6C6', 'minimumPrice': '42.55'}, {'asin': 'B0C22KBMJC', 'minimumPrice': '35.85'}, {'asin': 'B0BZVL6Y9X', 'minimumPrice': '50.46'}, {'asin': 'B0BX58MW7N', 'minimumPrice': '26.73'}, {'asin': 'B0B9M86GMC', 'minimumPrice': '15.93'}, {'asin': 'B08DQVJ2VB', 'minimumPrice': '37.30'}, {'asin': 'B07WKYLJ3T', 'minimumPrice': '64.75'}, {'asin': 'B092D7P9BP', 'minimumPrice': '28.12'}, {'asin': 'B0F9W2MZJY', 'minimumPrice': '77.28'}, {'asin': 'B09KXV2C3T', 'minimumPrice': '27.74'}, {'asin': 'B092D7TM65', 'minimumPrice': '29.44'}, {'asin': 'B0CR3P2F5L', 'minimumPrice': '47.54'}, {'asin': 'B0BWCSCYFL', 'minimumPrice': '45.18'}, {'asin': 'B08JLW8S3Y', 'minimumPrice': '28.56'}, {'asin': 'B0D4H73BJH', 'minimumPrice': '19.57'}, {'asin': 'B08D77WTYG', 'minimumPrice': '30.35'}, {'asin': 'B08D73XW2J', 'minimumPrice': '31.35'}, {'asin': 'B09H4RB4ZL', 'minimumPrice': '25.08'}, {'asin': 'B09WMQ29GH', 'minimumPrice': '29.45'}, {'asin': 'B0DJ2M2RYC', 'minimumPrice': '13.87'}, {'asin': 'B0BGF2QCKT', 'minimumPrice': '35.52'}, {'asin': 'B0DS6FC8M7', 'minimumPrice': '14.94'}, {'asin': 'B098QFLVDZ', 'minimumPrice': '66.04'}, {'asin': 'B0DMD16GTD', 'minimumPrice': '21.90'}, {'asin': 'B0CNVLLQ3J', 'minimumPrice': '42.27'}, {'asin': 'B0CP3JZ1P5', 'minimumPrice': '37.09'}, {'asin': 'B0C1GR4K6W', 'minimumPrice': '21.71'}, {'asin': 'B0CBM792D2', 'minimumPrice': '17.03'}, {'asin': 'B09NJK6K5T', 'minimumPrice': '22.51'}, {'asin': 'B0D8Q5MK31', 'minimumPrice': '21.37'}, {'asin': 'B0CYSCV19T', 'minimumPrice': '32.71'}, {'asin': 'B0C5D5XPLJ', 'minimumPrice': '50.72'}, {'asin': 'B09R3NFGH2', 'minimumPrice': '16.51'}, {'asin': 'B09BG1QD9M', 'minimumPrice': '23.84'}, {'asin': 'B092QY1823', 'minimumPrice': '16.86'}, {'asin': 'B0C55PGK51', 'minimumPrice': '13.77'}, {'asin': 'B0DRN8MXRG', 'minimumPrice': '29.13'}, {'asin': 'B08152JKFB', 'minimumPrice': '21.98'}, {'asin': 'B09Y1P7GK7', 'minimumPrice': '29.43'}, {'asin': 'B0CG62X5GT', 'minimumPrice': '16.88'}, {'asin': 'B0DHJKKZDD', 'minimumPrice': '17.96'}, {'asin': 'B0DYHZ59YL', 'minimumPrice': '20.74'}, {'asin': 'B0CG8RNWL6', 'minimumPrice': '28.08'}, {'asin': 'B0BLN7D2JZ', 'minimumPrice': '27.15'}, {'asin': 'B0D2D5WS2D', 'minimumPrice': '40.27'}, {'asin': 'B0G6LHVRKG', 'minimumPrice': '23.41'}, {'asin': 'B0DQ53F84K', 'minimumPrice': '53.79'}, {'asin': 'ASDASD', 'minimumPrice': '90.00'}, {'asin': 'B0CJTCGDB7232', 'minimumPrice': '32.00'}, {'asin': 'B0CJTCGDB7213', 'minimumPrice': '23.00'}, {'asin': 'B0CJTCGDB79', 'minimumPrice': '32.00'}, {'asin': 'B0CJTCGDB78', 'minimumPrice': '321.00'}, {'asin': 'B0CJTCGDB77', 'minimumPrice': '32.00'}, {'asin': 'B0CJTCGDB76', 'minimumPrice': '32.00'}, {'asin': 'B0CJTCGDB75', 'minimumPrice': '321.00'}, {'asin': 'B0CJTCGDB74', 'minimumPrice': '21.00'}, {'asin': 'B0CJTCGDB73', 'minimumPrice': '231.00'}, {'asin': 'B0CJTCGDB72', 'minimumPrice': '21.00'}, {'asin': 'B0CJTCGDB71', 'minimumPrice': '20.00'}, {'asin': 'B0CJTCGDB7', 'minimumPrice': '15.00'}, {'asin': '1', 'minimumPrice': '1.00'}, {'asin': 'ASDASD', 'minimumPrice': '50.00'}], 'UK': [{'asin': 'B0FGJD2P9Q', 'minimumPrice': ''}, {'asin': 'B0DKBSWJ93', 'minimumPrice': ''}, {'asin': 'B0DJT78FNR', 'minimumPrice': ''}, {'asin': 'B0DFCR79L5', 'minimumPrice': ''}, {'asin': 'B0DCBJ3WY5', 'minimumPrice': ''}, {'asin': 'B0D9LPW1YB', 'minimumPrice': ''}, {'asin': 'B0D7VDQG19', 'minimumPrice': ''}, {'asin': 'B0CP5K31TL', 'minimumPrice': ''}, {'asin': 'B0CG3RG7RQ', 'minimumPrice': ''}, {'asin': 'B0C8DSPMNC', 'minimumPrice': ''}, {'asin': 'B0B6H8RCHC', 'minimumPrice': ''}, {'asin': 'B08TVWB36K', 'minimumPrice': ''}, {'asin': 'B08FZMZQ11', 'minimumPrice': ''}, {'asin': 'B0G6LHVRKG', 'minimumPrice': ''}, {'asin': 'B0FF36SY6Q', 'minimumPrice': ''}, {'asin': 'B0DYTNBMB2', 'minimumPrice': ''}, {'asin': 'B0DQNLGSMQ', 'minimumPrice': ''}, {'asin': 'B0DNSNN7NH', 'minimumPrice': ''}, {'asin': 'B0DJWHHFBS', 'minimumPrice': ''}, {'asin': 'B0DJHSJ9LS', 'minimumPrice': ''}, {'asin': 'B0D2D5WS2D', 'minimumPrice': ''}, {'asin': 'B0CG8RNWL6', 'minimumPrice': ''}, {'asin': 'B0C5D5XPLJ', 'minimumPrice': ''}, {'asin': 'B09WHYGBSR', 'minimumPrice': ''}, {'asin': 'B09WHV9JCJ', 'minimumPrice': ''}, {'asin': 'B09P4VCL2V', 'minimumPrice': ''}, {'asin': 'B09MYVN5YK', 'minimumPrice': ''}, {'asin': 'B09MBNG7P4', 'minimumPrice': ''}, {'asin': 'B0DM63394K', 'minimumPrice': ''}, {'asin': 'B0D1Q4RR99', 'minimumPrice': ''}, {'asin': 'B0CZD1HLY7', 'minimumPrice': ''}, {'asin': 'B0CWD9R2FS', 'minimumPrice': ''}, {'asin': 'B0CS6WYRFW', 'minimumPrice': ''}, {'asin': 'B0CRVTSQGQ', 'minimumPrice': ''}, {'asin': 'B0CR8FTHYP', 'minimumPrice': ''}, {'asin': 'B0CCB5Q5ZY', 'minimumPrice': ''}, {'asin': 'B0CC94W5G7', 'minimumPrice': ''}, {'asin': 'B0CC948Q8Q', 'minimumPrice': ''}, {'asin': 'B0CC93R18S', 'minimumPrice': ''}, {'asin': 'B0CC92F33P', 'minimumPrice': ''}, {'asin': 'B0BZ4DNWPY', 'minimumPrice': ''}, {'asin': 'B0BZ4CMQDL', 'minimumPrice': ''}, {'asin': 'B07Q4RBT32', 'minimumPrice': ''}, {'asin': 'B0DHX32XJH', 'minimumPrice': ''}, {'asin': 'B0DHX1HR7X', 'minimumPrice': ''}, {'asin': 'B0DHTT1X71', 'minimumPrice': ''}, {'asin': 'B0DHTRLQ2L', 'minimumPrice': ''}, {'asin': 'B0DHL7FQJM', 'minimumPrice': ''}, {'asin': 'B0D3V1BJX9', 'minimumPrice': ''}, {'asin': 'B0D3TZ45C3', 'minimumPrice': ''}, {'asin': 'B0D2SDNYS5', 'minimumPrice': ''}, {'asin': 'B0CQY5SM94', 'minimumPrice': ''}, {'asin': 'B0CQLN2FC1', 'minimumPrice': ''}, {'asin': 'B0CQF6W2JT', 'minimumPrice': ''}, {'asin': 'B0BY85GDF5', 'minimumPrice': ''}, {'asin': 'B0BY82BRTZ', 'minimumPrice': ''}, {'asin': 'B0B73K6QZQ', 'minimumPrice': ''}, {'asin': 'B0B4MDDD7D', 'minimumPrice': ''}, {'asin': 'B0F1X5Q9Q6', 'minimumPrice': ''}, {'asin': 'B0DYHPWTVJ', 'minimumPrice': ''}, {'asin': 'B0DQNJPS5M', 'minimumPrice': ''}, {'asin': 'B0DQ4B5SF7', 'minimumPrice': ''}, {'asin': 'B0DNZ5WX1L', 'minimumPrice': ''}, {'asin': 'B0DKX28SNX', 'minimumPrice': ''}, {'asin': 'B0DHVTGXPD', 'minimumPrice': ''}, {'asin': 'B0DHDBN98L', 'minimumPrice': ''}, {'asin': 'B0DFXRP8X1', 'minimumPrice': ''}, {'asin': 'B0DFGPGXY6', 'minimumPrice': ''}, {'asin': 'B0D8JV9MB9', 'minimumPrice': ''}, {'asin': 'B0D2KCRLJ1', 'minimumPrice': ''}, {'asin': 'B0CW63X5XY', 'minimumPrice': ''}, {'asin': 'B0CRVRDS8C', 'minimumPrice': ''}, {'asin': 'B0CRHWLDY4', 'minimumPrice': ''}, {'asin': 'B0CLZBR5CM', 'minimumPrice': ''}, {'asin': 'B0CL67FJFL', 'minimumPrice': ''}, {'asin': 'B0CG2C89ZZ', 'minimumPrice': ''}, {'asin': 'B0BP7YMCXH', 'minimumPrice': ''}, {'asin': 'B0BM8Y94QC', 'minimumPrice': ''}, {'asin': 'B0BJKBRTK7', 'minimumPrice': ''}, {'asin': 'B0BCL2DLPC', 'minimumPrice': ''}, {'asin': 'B0B6PSLTPF', 'minimumPrice': ''}, {'asin': 'B0B6PS4RQ1', 'minimumPrice': ''}, {'asin': 'B0B5L4HC95', 'minimumPrice': ''}, {'asin': 'B08ZT1PJ2K', 'minimumPrice': ''}, {'asin': 'B0FGQ31VWT', 'minimumPrice': ''}, {'asin': 'B0FFT33WRF', 'minimumPrice': ''}, {'asin': 'B0FD3S6DRN', 'minimumPrice': ''}, {'asin': 'B0F2GBR7MP', 'minimumPrice': ''}, {'asin': 'B0F2G5TG4S', 'minimumPrice': ''}, {'asin': 'B0F2FZVS7X', 'minimumPrice': ''}, {'asin': 'B0F2FYW3HN', 'minimumPrice': ''}, {'asin': 'B0F2B6924H', 'minimumPrice': ''}, {'asin': 'B0DSJMMMXN', 'minimumPrice': ''}, {'asin': 'B0DJT68ZM3', 'minimumPrice': ''}, {'asin': 'B0DJT66VG3', 'minimumPrice': ''}, {'asin': 'B0DJT5YJXD', 'minimumPrice': ''}, {'asin': 'B0DCGSFTYX', 'minimumPrice': ''}, {'asin': 'B0D8C6KL4G', 'minimumPrice': ''}, {'asin': 'B0C9H8MJC3', 'minimumPrice': ''}, {'asin': 'B0C85JQNRC', 'minimumPrice': ''}, {'asin': 'B0C5QQFN1G', 'minimumPrice': ''}, {'asin': 'B0BZ8FXRZG', 'minimumPrice': ''}, {'asin': 'B0BS8SW341', 'minimumPrice': ''}, {'asin': 'B0FLVP9N4Y', 'minimumPrice': '6.16'}, {'asin': 'B0F2MPWSCQ', 'minimumPrice': '7.51'}, {'asin': 'B0F29CFJJ9', 'minimumPrice': '21.91'}, {'asin': 'B0DSKC1GQB', 'minimumPrice': '25.52'}, {'asin': 'B0DSCBZSGS', 'minimumPrice': '18.23'}, {'asin': 'B0DSCB329P', 'minimumPrice': '19.00'}, {'asin': 'B0DLNXW8W5', 'minimumPrice': '6.44'}, {'asin': 'B0DJQHVBZZ', 'minimumPrice': '17.62'}, {'asin': 'B0DGCW7WBY', 'minimumPrice': '24.73'}, {'asin': 'B0DFTG85NG', 'minimumPrice': '8.54'}, {'asin': 'B0D99N1DZY', 'minimumPrice': '37.69'}, {'asin': 'B0D8B849BJ', 'minimumPrice': '38.90'}, {'asin': 'B0D89YCGCR', 'minimumPrice': '38.97'}, {'asin': 'B0D89WK6JF', 'minimumPrice': '37.82'}, {'asin': 'B0D89PMKKP', 'minimumPrice': '36.47'}, {'asin': 'B0D7X1634D', 'minimumPrice': '15.56'}, {'asin': 'B0D7V3W7D1', 'minimumPrice': '76.14'}, {'asin': 'B0CYT3R89Z', 'minimumPrice': '68.79'}, {'asin': 'B0CY7P61VD', 'minimumPrice': '32.79'}, {'asin': 'B0CQ8B9PZM', 'minimumPrice': '31.31'}, {'asin': 'B0CNK1SV4P', 'minimumPrice': '16.61'}, {'asin': 'B0CF4K1G4R', 'minimumPrice': '48.07'}, {'asin': 'B0CF4JJXHY', 'minimumPrice': '34.72'}, {'asin': 'B0CF4BJ42D', 'minimumPrice': '24.82'}, {'asin': 'B0C6T346MQ', 'minimumPrice': '11.52'}, {'asin': 'B0BX2KDVMY', 'minimumPrice': '23.14'}, {'asin': 'B0BR4YWSF9', 'minimumPrice': '24.32'}, {'asin': 'B0BR4XHP5Z', 'minimumPrice': '24.71'}, {'asin': 'B09Z68FK4Q', 'minimumPrice': '17.34'}, {'asin': 'B09SL3PTTM', 'minimumPrice': '21.86'}, {'asin': 'B09JBQZQQL', 'minimumPrice': '13.87'}, {'asin': 'B08PSSRS3C', 'minimumPrice': '8.06'}, {'asin': 'B08G4P4F76', 'minimumPrice': '11.61'}, {'asin': 'B0DRD7T6TQ', 'minimumPrice': ''}, {'asin': 'B0DQXT78DT', 'minimumPrice': ''}, {'asin': 'B0DQXRV47R', 'minimumPrice': ''}, {'asin': 'B0DQXRV18S', 'minimumPrice': ''}, {'asin': 'B0DQXRLK5X', 'minimumPrice': ''}, {'asin': 'B0DQXQNL6Z', 'minimumPrice': ''}, {'asin': 'B0D2KQVS2Y', 'minimumPrice': ''}, {'asin': 'B0CXJC74TK', 'minimumPrice': ''}, {'asin': 'B0CW9JC84B', 'minimumPrice': ''}, {'asin': 'B0CSYBNXWX', 'minimumPrice': ''}, {'asin': 'B0CRHSTGX6', 'minimumPrice': ''}, {'asin': 'B0CPPJTX5C', 'minimumPrice': ''}, {'asin': 'B0CJB9WFHD', 'minimumPrice': ''}, {'asin': 'B0CF9JWKHJ', 'minimumPrice': ''}, {'asin': 'B0BVVYZ6TN', 'minimumPrice': ''}, {'asin': 'B0BND2YKJF', 'minimumPrice': ''}, {'asin': 'B09TRCQNQH', 'minimumPrice': ''}, {'asin': 'B09H5J6YQX', 'minimumPrice': ''}, {'asin': 'B09CNRP5FJ', 'minimumPrice': ''}, {'asin': 'B08BZ9LW9Z', 'minimumPrice': ''}, {'asin': 'B0F54M2147', 'minimumPrice': ''}, {'asin': 'B0F388HQLY', 'minimumPrice': ''}, {'asin': 'B0F1BZNBY6', 'minimumPrice': ''}, {'asin': 'B0F1BXV9WG', 'minimumPrice': ''}, {'asin': 'B0DSD949P4', 'minimumPrice': ''}, {'asin': 'B0DFQ2FP96', 'minimumPrice': ''}, {'asin': 'B0D9YXGFQW', 'minimumPrice': ''}, {'asin': 'B0CP9WVSRY', 'minimumPrice': ''}, {'asin': 'B0CKZ35SJC', 'minimumPrice': ''}, {'asin': 'B0CHYNDX87', 'minimumPrice': ''}, {'asin': 'B0CH66SNFC', 'minimumPrice': ''}, {'asin': 'B0BHSWP8RD', 'minimumPrice': ''}, {'asin': 'B0BGRW6858', 'minimumPrice': ''}, {'asin': 'B0B6JKD25B', 'minimumPrice': ''}, {'asin': 'B0B1LJB3M5', 'minimumPrice': ''}, {'asin': 'B09P3S4SH5', 'minimumPrice': ''}, {'asin': 'B09NLSRHGV', 'minimumPrice': ''}, {'asin': 'B0FVDPV5NS', 'minimumPrice': '16.54'}, {'asin': 'B0FQJ5CDL2', 'minimumPrice': '16.44'}, {'asin': 'B0FLJLWTLV', 'minimumPrice': '14.91'}, {'asin': 'B0FGQ1VP2J', 'minimumPrice': '18.19'}, {'asin': 'B0FD6YL7MM', 'minimumPrice': '10.10'}, {'asin': 'B0FBWP9GJL', 'minimumPrice': '11.34'}, {'asin': 'B0F99RW38N', 'minimumPrice': '21.47'}, {'asin': 'B0F3Y3M538', 'minimumPrice': '16.85'}, {'asin': 'B0F27ZM2TG', 'minimumPrice': '15.78'}, {'asin': 'B0F23Y11GD', 'minimumPrice': '16.44'}, {'asin': 'B0F23WZ78P', 'minimumPrice': '12.64'}, {'asin': 'B0F1MY4PF6', 'minimumPrice': '5.96'}, {'asin': 'B0DXKC6H1G', 'minimumPrice': '16.49'}, {'asin': 'B0DQLNTLF4', 'minimumPrice': '7.20'}, {'asin': 'B0DM8DYG77', 'minimumPrice': '13.80'}, {'asin': 'B0DHJNBFVH', 'minimumPrice': '11.19'}, {'asin': 'B0DH1XKG95', 'minimumPrice': '19.34'}, {'asin': 'B0DFYG7C4K', 'minimumPrice': '10.85'}, {'asin': 'B0DFTR1CMN', 'minimumPrice': '15.79'}, {'asin': 'B0DB8FM915', 'minimumPrice': '24.17'}, {'asin': 'B0D5H65VW3', 'minimumPrice': '9.83'}, {'asin': 'B0D3XBW4KT', 'minimumPrice': '19.21'}, {'asin': 'B0CRKJWJ4X', 'minimumPrice': '17.66'}, {'asin': 'B0CPPGX6FQ', 'minimumPrice': '40.53'}, {'asin': 'B0CPPGSBHD', 'minimumPrice': '32.58'}, {'asin': 'B0CL9PVZD1', 'minimumPrice': '24.79'}, {'asin': 'B0C232Z6C6', 'minimumPrice': '32.87'}, {'asin': 'B0C22KBMJC', 'minimumPrice': '25.72'}, {'asin': 'B0BX58MW7N', 'minimumPrice': '22.04'}, {'asin': 'B0B6T3N5MG', 'minimumPrice': '15.33'}, {'asin': 'B0B4S9QYWM', 'minimumPrice': '14.36'}, {'asin': 'B0B4S93T7Q', 'minimumPrice': '14.57'}, {'asin': 'B09R1PMW3J', 'minimumPrice': '14.07'}, {'asin': 'B08MLN92JM', 'minimumPrice': '9.63'}, {'asin': 'B0BHN71FWM', 'minimumPrice': '18.09'}, {'asin': 'B09NKRWXPC', 'minimumPrice': '12.53'}, {'asin': 'B0C89TNVTN', 'minimumPrice': '39.64'}, {'asin': 'B0D1VDMZR1', 'minimumPrice': '21.46'}, {'asin': 'B0DS2QT1YZ', 'minimumPrice': '23.49'}, {'asin': 'B09Y29P5YF', 'minimumPrice': '20.23'}, {'asin': 'B0DBQDSHVZ', 'minimumPrice': '31.03'}, {'asin': 'B0CFF62JY3', 'minimumPrice': '21.88'}, {'asin': 'B0BHPGTWQR', 'minimumPrice': '28.06'}, {'asin': 'B0D4F4D3W2', 'minimumPrice': '18.17'}, {'asin': 'B0F13BRF4V', 'minimumPrice': '14.89'}, {'asin': 'B0D1FXTY99', 'minimumPrice': '14.36'}, {'asin': 'B00KY0LLFO', 'minimumPrice': '16.48'}, {'asin': 'B0B7MJXDZC', 'minimumPrice': '18.28'}, {'asin': 'B0B4DV8NLV', 'minimumPrice': '18.09'}, {'asin': 'B0DJXRFCF7', 'minimumPrice': '34.13'}, {'asin': 'B0BPHMFD7V', 'minimumPrice': '17.16'}, {'asin': 'B0F281ZQWR', 'minimumPrice': '24.51'}, {'asin': 'B0D7V2FNW3', 'minimumPrice': '49.89'}, {'asin': 'B0D57X7KV9', 'minimumPrice': '15.04'}, {'asin': 'B0CJ2SST5Z', 'minimumPrice': '21.25'}, {'asin': 'B0CZ92LPP4', 'minimumPrice': '8.74'}, {'asin': 'B0CG5Z2Z44', 'minimumPrice': '13.24'}, {'asin': 'B0D6T9H5LX', 'minimumPrice': '14.42'}, {'asin': 'B0CQJNR9MP', 'minimumPrice': '10.64'}, {'asin': 'ZXCA', 'minimumPrice': '60.00'}, {'asin': 'B0CJTGDB7232', 'minimumPrice': '854.00'}, {'asin': 'B0CJTGDB7213', 'minimumPrice': '54.00'}, {'asin': 'B0CJTGDB79', 'minimumPrice': '54.00'}, {'asin': 'B0CJTGDB78', 'minimumPrice': '54.00'}, {'asin': 'B0CJTGDB77', 'minimumPrice': '4.00'}, {'asin': 'B0CJCGDB76', 'minimumPrice': '458.00'}, {'asin': 'B0CJCGDB75', 'minimumPrice': '45.00'}, {'asin': 'B0CJCGDB74', 'minimumPrice': '45.00'}, {'asin': 'B0CJCGDB73', 'minimumPrice': '485.00'}, {'asin': 'B0CJCGDB72', 'minimumPrice': '545.00'}, {'asin': 'B0CJCGDB71', 'minimumPrice': '545.00'}, {'asin': 'B0CJCGDB7', 'minimumPrice': '854.00'}, {'asin': 'B0CTCGDB7', 'minimumPrice': '84.00'}, {'asin': 'B0FGTNLTG3', 'minimumPrice': '56.00'}, {'asin': 'B0D57X7KV9', 'minimumPrice': '11.07'}, {'asin': 'B0D1VDMZR1', 'minimumPrice': '23.73'}, {'asin': 'B0DS2QT1YZ', 'minimumPrice': '19.36'}, {'asin': 'WQDASDAS', 'minimumPrice': '60.00'}, {'asin': 'B0BHPGTWQR', 'minimumPrice': '28.48'}, {'asin': 'SADAD', 'minimumPrice': '50.00'}, {'asin': 'DFHFGH78979', 'minimumPrice': '34.00'}], 'FR': [{'asin': 'B0FY6JY4BF', 'minimumPrice': ''}, {'asin': 'B0DZNRB5Q1', 'minimumPrice': ''}, {'asin': 'B0DNSKFLGK', 'minimumPrice': ''}, {'asin': 'B0DGLCQDTR', 'minimumPrice': ''}, {'asin': 'B0D9W93869', 'minimumPrice': ''}, {'asin': 'B0CG8RNWL6', 'minimumPrice': ''}, {'asin': 'B0BX94RYT1', 'minimumPrice': ''}, {'asin': 'B0BLN7D2JZ', 'minimumPrice': ''}, {'asin': 'B09WHV9JCJ', 'minimumPrice': ''}, {'asin': 'B0DKX28SNX', 'minimumPrice': ''}, {'asin': 'B0CZCVTMRJ', 'minimumPrice': ''}, {'asin': 'B0CC95H28M', 'minimumPrice': ''}, {'asin': 'B0CC942XSZ', 'minimumPrice': ''}, {'asin': 'B0CC93WW6S', 'minimumPrice': ''}, {'asin': 'B0CC93TDB9', 'minimumPrice': ''}, {'asin': 'B0816GGCFY', 'minimumPrice': ''}, {'asin': 'B0CQY5SM94', 'minimumPrice': ''}, {'asin': 'B0DQTKRNLH', 'minimumPrice': ''}, {'asin': 'B0DCB85F3J', 'minimumPrice': ''}, {'asin': 'B0D8JR3W53', 'minimumPrice': ''}, {'asin': 'B0D2KCRLJ1', 'minimumPrice': ''}, {'asin': 'B0CQK7QWJB', 'minimumPrice': ''}, {'asin': 'B0B9XM29C9', 'minimumPrice': ''}, {'asin': 'B0B5L4HC95', 'minimumPrice': ''}, {'asin': 'B0F9FCT8RD', 'minimumPrice': ''}, {'asin': 'B0BZ8J16JM', 'minimumPrice': ''}, {'asin': 'B0BV1TRMGN', 'minimumPrice': ''}, {'asin': 'B09G68LMB3', 'minimumPrice': ''}, {'asin': 'B0DCRJSMJP', 'minimumPrice': '10.72'}, {'asin': 'B093Y447R2', 'minimumPrice': '79.17'}, {'asin': 'B0D8Y5KWB8', 'minimumPrice': ''}, {'asin': 'B0BJK43Z6X', 'minimumPrice': ''}, {'asin': 'B0DXK7YZ5D', 'minimumPrice': '15.06'}, {'asin': 'B0C232Z6C6', 'minimumPrice': '48.25'}, {'asin': 'B0BZVL6Y9X', 'minimumPrice': '54.37'}, {'asin': 'B0B4S98HLZ', 'minimumPrice': '14.61'}, {'asin': 'B09VPMBMB8', 'minimumPrice': '18.01'}, {'asin': 'B0D17L6YKN', 'minimumPrice': '16.95'}], 'IT': [{'asin': 'B0DTK87KJ5', 'minimumPrice': ''}, {'asin': 'B0DNSNN7NH', 'minimumPrice': ''}, {'asin': 'B0D9W93869', 'minimumPrice': ''}, {'asin': 'B0CLXZHM2G', 'minimumPrice': ''}, {'asin': 'B0CG8RNWL6', 'minimumPrice': ''}, {'asin': 'B0BX94RYT1', 'minimumPrice': ''}, {'asin': 'B0BVM53J3B', 'minimumPrice': ''}, {'asin': 'B0BLN7D2JZ', 'minimumPrice': ''}, {'asin': 'B0F9KVS6Y2', 'minimumPrice': ''}, {'asin': 'B0F8QGHMLL', 'minimumPrice': ''}, {'asin': 'B0CY5KW8RY', 'minimumPrice': ''}, {'asin': 'B0CC94P4H1', 'minimumPrice': ''}, {'asin': 'B0CC94K2HN', 'minimumPrice': ''}, {'asin': 'B0CC922QN8', 'minimumPrice': ''}, {'asin': 'B0C7Q51PL1', 'minimumPrice': ''}, {'asin': 'B0D8JR3W53', 'minimumPrice': ''}, {'asin': 'B0B5L4HC95', 'minimumPrice': ''}, {'asin': 'B0C5C53444', 'minimumPrice': ''}, {'asin': 'B0F5PRVRLF', 'minimumPrice': ''}, {'asin': 'B0BJK43Z6X', 'minimumPrice': ''}, {'asin': 'B09WKFD3G8', 'minimumPrice': ''}, {'asin': 'B08NT2BNGH', 'minimumPrice': ''}, {'asin': 'B0DRV6D214', 'minimumPrice': '20.50'}, {'asin': 'B08QRKHD3C', 'minimumPrice': '38.36'}, {'asin': 'B09JMW3F1R', 'minimumPrice': '28.64'}, {'asin': 'B0CKYHTDJ5', 'minimumPrice': '24.05'}, {'asin': 'B098QFLVDZ', 'minimumPrice': '77.89'}, {'asin': '32EGDFGS4', 'minimumPrice': '56.00'}, {'asin': 'B087JP6PWL89', 'minimumPrice': ''}], 'ES': [{'asin': 'B0D2J11TKY', 'minimumPrice': ''}, {'asin': 'B0CLY2THTY', 'minimumPrice': ''}, {'asin': 'B0D8JS8NPH', 'minimumPrice': ''}, {'asin': 'B0CYGSBXSQ', 'minimumPrice': ''}, {'asin': 'B0CKXMDBN8', 'minimumPrice': ''}, {'asin': 'B0CJ5F9J4Z', 'minimumPrice': ''}, {'asin': 'B0CC95XMD4', 'minimumPrice': ''}, {'asin': 'B0CC93ZNH2', 'minimumPrice': ''}, {'asin': 'B07L281N1M', 'minimumPrice': ''}, {'asin': 'B0DNZ5N7HY', 'minimumPrice': ''}, {'asin': 'B0C7V4BFSZ', 'minimumPrice': ''}, {'asin': 'B0B5L4HC95', 'minimumPrice': ''}, {'asin': 'B0DZCC4B4T', 'minimumPrice': ''}, {'asin': 'B0DZBQ4ZNC', 'minimumPrice': ''}, {'asin': 'B0C5QQFN1G', 'minimumPrice': ''}, {'asin': 'B0BFXD4LYN', 'minimumPrice': '33.02'}, {'asin': 'B0F3122Q81', 'minimumPrice': ''}, {'asin': 'B0BG73XTFC', 'minimumPrice': ''}, {'asin': 'B0CNM269LZ', 'minimumPrice': '9.24'}, {'asin': 'B08QS37RHQ', 'minimumPrice': '42.40'}, {'asin': 'B087JP6PWL89', 'minimumPrice': ''}]}, 'minimum_price_by_country_and_asin': {}, 'skip_asin_delete_policy': {'enabled': True, 'mode': 'DELETE_WHEN_PRICE_BELOW_MINIMUM', 'deleteWhenPriceBelowMinimum': True, 'compareField': 'price', 'thresholdField': 'minimumPrice', 'target': 'skip_asin', 'source': 'frontend-price-track'}, 'delete_skip_asin_when_price_below_minimum': True, 'deleteSkipAsinWhenPriceBelowMinimum': True, 'asin_rows_by_country': {}, 'taskId': 5658, 'resultId': 7627, 'shopName': '郭锋滨', 'shopId': '27769139291498', 'platformName': '亚马逊', 'companyName': '尾号5578的公司115', 'shopMallName': '', 'matchStatus': 'MATCHED', 'matchMessage': None, 'outputFilename': None, 'downloadUrl': None, 'taskStatus': 'RUNNING', 'loopRunId': 235, 'roundIndex': 1}} price_cls = PriceTask(user_info) - price_cls.process_task(task_data) \ No newline at end of file + price_cls.process_task(task_data) + + + + + diff --git a/app/amazon/similar_asin.py b/app/amazon/similar_asin.py index aaedbcb..181e47b 100644 --- a/app/amazon/similar_asin.py +++ b/app/amazon/similar_asin.py @@ -10,9 +10,10 @@ from datetime import datetime from collections import defaultdict import requests from urllib.parse import quote - +import os from curl_cffi import requests as requests_frp +from config import base_dir from amazon.tool import show_notification,get_shop_info,remove_special_characters,split_currency_values @@ -76,7 +77,9 @@ class ChromeAmzone(ChromeAmzoneBase): 将图片链接(URL 或本地文件路径)转换为 Base64 编码的字符串。 """ if image_source.startswith(('http://', 'https://')): - response = requests.get(image_source, timeout=10) + response = requests.get(image_source, timeout=10,headers={ + "user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0 18444" + }) response.raise_for_status() # 非 2xx 状态码将抛出异常 image_data = response.content else: @@ -153,7 +156,7 @@ class ChromeAmzone(ChromeAmzoneBase): # 发送第一次请求 try: response = requests_frp.post(url, headers=headers, params=params, json=data, impersonate="chrome101", - cookies=cookie, proxies=proxies) + cookies=cookie, proxies=proxies,verify=False) response.encoding = "utf-8" # 检查响应状态码和数据有效性 @@ -176,6 +179,7 @@ class ChromeAmzone(ChromeAmzoneBase): need_retry = True # 如果需要重试且配置了代理URL + # need_retry = True if need_retry and CONFIG_PROXY_URL and not proxies: try: proxy_resp = requests.get(CONFIG_PROXY_URL, timeout=10) @@ -204,7 +208,7 @@ class ChromeAmzone(ChromeAmzoneBase): if proxy_ip: proxies = { "http": f"http://{proxy_ip}", - "https": f"http://{proxy_ip}", + "https": f"https://{proxy_ip}", } if proxies: @@ -251,7 +255,23 @@ class ChromeAmzone(ChromeAmzoneBase): title = title_ele.text data["title"] = title imge_ele = self.tab.ele('xpath://div[@id="imgTagWrapperId"]//img',timeout=20) - image_url = imge_ele.attr("src") + + image_url = "" + data_a_dynamic_image = imge_ele.attr("data-a-dynamic-image") + if data_a_dynamic_image: + dynamic_image_json = json.loads(data_a_dynamic_image) + self.log(f"图片信息:{dynamic_image_json}") + max_area = 0 + + for url, (width, height) in dynamic_image_json.items(): + area = width * height + if area > max_area: + max_area = area + image_url = url + + if not image_url: + image_url = imge_ele.attr("src") + data["image_url"] = image_url # sku @@ -277,12 +297,13 @@ class ChromeAmzone(ChromeAmzoneBase): Returns: dict: 包含采集到的数据 """ + return_data = {} 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") + # show_notification(error_msg, "error") return None # 获取国家配置 @@ -314,6 +335,8 @@ class ChromeAmzone(ChromeAmzoneBase): self.log("正在抓取商品数据...") data = self._scrape_data() + return_data.update(data) + # 判断是否采集标题出错 new_size = "220,220" pattern = r"\._(?:[A-Z]+)?(\d+)_\." @@ -321,22 +344,25 @@ class ChromeAmzone(ChromeAmzoneBase): # 使用正则替换 image_new_url = re.sub(pattern, lambda m: replacement, data["image_url"]) + print(image_new_url) + iamge_base64 = self.image_to_base64(image_new_url) similar_data = [] # 4、请求获取插件的数据 - for page_num in range(total_page): - resp_data = self.get_aliprice_data( - page=page_num, - title = data["title"], - domain=domain, - category=data["category"], - imageBase64 = iamge_base64 - ) - self.log(f"Aliprice 扩展数据获取:{str(resp_data)[0:200]}") - if len(resp_data.get("data",[])) > 0: - for i in resp_data.get("data"): - similar_data.append(i) + if image_new_url: + for page_num in range(total_page): + resp_data = self.get_aliprice_data( + page=page_num+1, + title = data["title"], + domain=domain, + category=data["category"], + imageBase64 = iamge_base64 + ) + self.log(f"Aliprice 扩展数据获取:{str(resp_data)[0:200]}") + if len(resp_data.get("data",[])) > 0: + for i in resp_data.get("data"): + similar_data.append(i) data["similar_data"] = similar_data @@ -346,14 +372,17 @@ class ChromeAmzone(ChromeAmzoneBase): data['url'] = product_url data['timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - print(f"数据抓取完成: {json.dumps(data)}") - return data + print(f"数据抓取完成: {json.dumps(return_data)}") + return_data.update(data) + + return return_data except Exception as e: error_msg = f"运行出错: {traceback.format_exc()}" print(error_msg) - show_notification(f"采集失败: {str(e)}", "error") - return {} + # show_notification(f"采集失败: {str(e)}", "error") + raise RuntimeError(f"{traceback.format_exc()}") + return return_data class SimilarAsinTask(TaskBase): @@ -478,10 +507,9 @@ class SimilarAsinTask(TaskBase): result = [] for gp_index,gp in enumerate(groups): items = gp.get("items", []) - return_data = {} - + group_item = [] for index,value in enumerate(items): - + print(value) return_data = { 'image_url': "", 'title': "", @@ -494,37 +522,36 @@ class SimilarAsinTask(TaskBase): country = value.get("country") for _ in range(max_retry): try: - return_data = chrome.run(country, asin,total_page=2) or {} + return_data = chrome.run(country, asin,total_page=1) or {} self.log(f"抓取结果->{return_data}") break except Exception as e: # if "与页面的连接已断开" in str(e): chrome = ChromeAmzone() self.log(f"{asin}抓取数据报错,{e}") - if not isinstance(return_data, dict): - return_data = {} - if return_data.get("image_url"): - break + # if not isinstance(return_data, dict): + # return_data = {} + # if return_data.get("image_url"): + # break + + group_item.append({ + "sourceFileKey": value.get("sourceFileKey",""), + "sourceFilename": value.get("sourceFilename",""), + "rowToken": value.get("rowToken",""), + "groupKey": value.get("groupKey",""), + "id": value.get("values",{}).get("id",""), + "asin": value.get("asin",""), + "sku" : return_data.get("sku",""), + "country": value.get("country",""), + "url": return_data.get("image_url",""), + "title": return_data.get("title",""), + "done": False, + "urls" : [i.get("ori_picture") for i in return_data.get("similar_data",[])[:16]] + }) + # task_id: int, chunkIndex:int,chunkTotal: int, country_code: str, asin: str, status: dict,error:str="", + # item_data:dict={}, + - group_item = [ - { - "sourceFileKey": i.get("sourceFileKey"), - "sourceFilename": i.get("sourceFilename"), - "rowToken": i.get("rowToken"), - "groupKey": i.get("groupKey"), - "id": i.get("id"), - "asin": i.get("asin"), - "sku" : return_data.get("sku"), - "country": i.get("country"), - "url": return_data.get("image_url"), - "title": return_data.get("title"), - "done": False, - "urls" : [i.get("ori_picture") for i in return_data.get("similar_data")] - } - for i in items - ] - # task_id: int, chunkIndex:int,chunkTotal: int, country_code: str, asin: str, status: dict,error:str="", - # item_data:dict={}, res = { "sourceFileKey": gp.get("sourceFileKey"), "sourceFilename": gp.get("sourceFilename"), @@ -629,7 +656,7 @@ class SimilarAsinTask(TaskBase): time.sleep(2) self.log(f"已达到最大重试次数,结果回传最终失败", "ERROR") - raise RuntimeError("已达到最大重试次数,结果回传最终失败") + # raise RuntimeError("已达到最大重试次数,结果回传最终失败") if __name__ == '__main__': spide = ChromeAmzone() diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java index 09a9bbb..6bc66a2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java @@ -71,6 +71,17 @@ public class SimilarAsinProperties { */ private boolean cozeUseLegacyItemFieldOrder = false; + /** + * 是否启用 P0-3 merge 增量缓冲:每个 batch DONE 时仅缓冲 cozeRows, + * 不立即合并到 chunk;finalize 前一次性按 chunkScopeHash 分组合并, + * 把 OSS chunk 读写从 1000+ 次降到 chunk 数量级。 + * 仅作用于"正常 poll DONE"路径;失败 batch / 单 batch 任务 / 其他 + * 11 个 mergeCozeRowsIntoChunk 调用点保留原立即 merge 行为。 + * 出现问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED=false + * 一键回滚到老路径。 + */ + private boolean cozeResultBufferEnabled = true; + @Data public static class CozeCredential { private String name; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index eb29820..2b1ddf4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -34,6 +34,7 @@ import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskBatchVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskDetailVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskItemVo; +import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; @@ -56,7 +57,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.DataFormatter; -import org.apache.poi.ss.usermodel.Drawing; import org.apache.poi.ss.usermodel.Font; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -892,7 +892,7 @@ public class SimilarAsinTaskService { touchJavaSideTaskActivity(task.getId()); } else if (isResultSubmissionComplete(task.getId())) { maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext( - job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0, null)); + job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0, null, null)); } } @@ -1436,6 +1436,9 @@ public class SimilarAsinTaskService { boolean shouldAssembleResult = assembleWorkbook && (finalError == null || finalError.isBlank() || hasPersistedResultRows); if (shouldAssembleResult && shouldAssembleSynchronously()) { try { + // P0-3:stale-recovery 同步 finalize 路径走这里,确保 + // assemble 之前缓冲的 cozeRows 已合并到 chunk。 + flushBufferedCozeResults(task.getId()); assembleResultWorkbook(task, result); } catch (Exception ex) { log.warn("[similar-asin] assemble result workbook failed taskId={} err={}", task.getId(), ex.getMessage()); @@ -1554,6 +1557,16 @@ public class SimilarAsinTaskService { saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze submitted, waiting for result"); return false; } + // P0-3:stale-recovery / 异常路径兜底 —— maybeFinalizeCozeJob 可能未能成功 flush + // (Redis 锁竞争 / 异常退出),在 assemble 阶段读 chunk 之前再 flush 一次,幂等。 + try { + flushBufferedCozeResults(task.getId()); + } catch (Exception flushEx) { + log.warn("[similar-asin] result file job flush buffered coze results failed taskId={} jobId={} err={}", + task.getId(), job.getId(), flushEx.getMessage(), flushEx); + throw new BusinessException("flush buffered coze results failed: " + + firstNonBlank(flushEx.getMessage(), "unknown error")); + } saveFileBuildProgress(task, job, totalProgressUnits, 0, "Submitting Coze"); boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); if (pendingCoze) { @@ -2020,7 +2033,8 @@ public class SimilarAsinTaskService { batchTotal, currentInstanceId(), 0, - credentialName + credentialName, + null ); String batchPayload = writeJson(batchRows, "serialize pending coze batch payload failed"); String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( @@ -2074,7 +2088,8 @@ public class SimilarAsinTaskService { batchTotal, currentInstanceId(), 0, - credentialName + credentialName, + null ); String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( @@ -2192,10 +2207,27 @@ public class SimilarAsinTaskService { if (!failureMessage.isBlank()) { cozeRows = cozeClient.markRowsFailed(batchRows, failureMessage); } - FileTaskEntity task = taskForPoll(state.getTaskId()); - if (task != null) { - Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + // P0-3:仅"DONE 且 batchTotal>1 且 feature toggle 开启"时缓冲 cozeRows 到 transient storage, + // 由 finalize 阶段一次性合并到 chunk。失败 batch / 单 batch 任务保留原立即 merge 路径。 + boolean buffered = false; + if (failureMessage.isBlank() + && isCozeResultBufferEnabled() + && context.batchTotal() != null && context.batchTotal() > 1 + && cozeRows != null && !cozeRows.isEmpty()) { + CozeBatchContext bufferedContext = bufferCozeResultForFlush(state, context, cozeRows); + if (bufferedContext != null + && bufferedContext.resultPayloadPointer() != null + && !bufferedContext.resultPayloadPointer().isBlank()) { + context = bufferedContext; + buffered = true; + } + } + if (!buffered) { + FileTaskEntity task = taskForPoll(state.getTaskId()); + if (task != null) { + Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); + mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + } } markCozeStateTerminal(state, failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED, @@ -2473,7 +2505,8 @@ public class SimilarAsinTaskService { partTotal, parentContext.ownerInstanceId(), retryCount, - firstNonBlank(credentialName, parentContext.credentialName()) + firstNonBlank(credentialName, parentContext.credentialName()), + null ); LocalDateTime now = LocalDateTime.now(); String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed"); @@ -2911,6 +2944,17 @@ public class SimilarAsinTaskService { touchJavaSideTaskActivity(taskId); return; } + // P0-3:requeue assemble 之前一次性把缓冲的 cozeRows 合并到 chunk。 + // 失败时 markFailed job 并阻止 requeue,避免 assemble 阶段读到不完整 chunk。 + try { + flushBufferedCozeResults(taskId); + } catch (Exception flushEx) { + String message = firstNonBlank(flushEx.getMessage(), "flush buffered coze results failed"); + log.warn("[similar-asin] coze finalize flush failed, marking job failed taskId={} jobId={} err={}", + taskId, job.getId(), message, flushEx); + taskFileJobService.markFailed(job, message); + return; + } boolean requeued = taskFileJobService.requeue(job.getId(), "Coze results ready, assembling xlsx"); if (requeued) { log.info("[similar-asin] coze async results ready, result file job requeued taskId={} jobId={} resultId={}", @@ -3076,6 +3120,204 @@ public class SimilarAsinTaskService { } } + /** + * P0-3:读取 state 缓冲的 cozeRows。 + * 与 readCozeBatchRows(读输入 batchRows)不同,这里读的是 + * 通过 bufferCozeResultForFlush 写入 transient storage 的 DONE 结果。 + */ + private List readBufferedCozeRows(String resultPayloadPointer) { + if (resultPayloadPointer == null || resultPayloadPointer.isBlank()) { + return List.of(); + } + try { + String payloadJson = transientPayloadStorageService.resolvePayload( + resultPayloadPointer, "read similar ASIN coze result buffer failed"); + if (payloadJson == null || payloadJson.isBlank()) { + return List.of(); + } + JsonNode array = objectMapper.readTree(payloadJson); + if (!array.isArray()) { + return List.of(); + } + List rows = new ArrayList<>(); + for (JsonNode node : array) { + rows.add(objectMapper.treeToValue(node, SimilarAsinResultRowDto.class)); + } + return rows; + } catch (Exception ex) { + log.warn("[similar-asin] read coze result buffer failed pointer={} err={}", + resultPayloadPointer, ex.getMessage()); + return List.of(); + } + } + + /** + * P0-3:把单个 DONE batch 的 cozeRows 缓冲到 transient storage(不立即写 chunk)。 + * 返回更新后的 CozeBatchContext(含 resultPayloadPointer),调用方需写回 stateJson。 + * 缓冲失败时返回原 context,调用方应回退到立即 merge 路径。 + */ + private CozeBatchContext bufferCozeResultForFlush(TaskScopeStateEntity state, + CozeBatchContext context, + List cozeRows) { + if (state == null || context == null || cozeRows == null || cozeRows.isEmpty()) { + return context; + } + try { + String payloadJson = writeJson(cozeRows, "serialize coze result buffer failed"); + String pointer = transientPayloadStorageService.storeParsedPayloadEntry( + MODULE_TYPE, state.getTaskId(), state.getScopeHash(), + "coze-result-" + state.getId(), payloadJson, true); + if (pointer == null || pointer.isBlank()) { + return context; + } + CozeBatchContext bufferedContext = withResultPayloadPointer(context, pointer); + String updatedStateJson = writeJson(bufferedContext, "serialize coze result buffer context failed"); + int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getStateJson, updatedStateJson) + .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); + if (updated <= 0) { + transientPayloadStorageService.deletePayloadIfPresent(pointer); + return context; + } + state.setStateJson(updatedStateJson); + return bufferedContext; + } catch (Exception ex) { + log.warn("[similar-asin] buffer coze result failed taskId={} stateId={} err={}", + state.getTaskId(), state.getId(), ex.getMessage()); + return context; + } + } + + /** + * P0-3:清掉 state 上缓冲的 pointer + 删除 transient payload。 + * 在 flush 成功合并到 chunk 后调用。 + */ + private void clearBufferedCozeResult(TaskScopeStateEntity state, CozeBatchContext context) { + if (state == null || context == null) { + return; + } + String pointer = context.resultPayloadPointer(); + if (pointer == null || pointer.isBlank()) { + return; + } + try { + CozeBatchContext clearedContext = withResultPayloadPointer(context, null); + String updatedStateJson = writeJson(clearedContext, "serialize coze result buffer cleared context failed"); + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, state.getId()) + .set(TaskScopeStateEntity::getStateJson, updatedStateJson) + .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); + state.setStateJson(updatedStateJson); + transientPayloadStorageService.deletePayloadIfPresent(pointer); + } catch (Exception ex) { + log.warn("[similar-asin] clear coze result buffer failed taskId={} stateId={} err={}", + state.getTaskId(), state.getId(), ex.getMessage()); + } + } + + private boolean isCozeResultBufferEnabled() { + return properties.isCozeResultBufferEnabled(); + } + + /** + * P0-3:在 finalize 之前一次性把所有 DONE/FAILED state 上缓冲的 cozeRows + * 按 chunkScopeHash 分组合并到 chunk。把每个 batch 的"loadSubmittedChunks + + * readChunkRows × N + writeChunkPayload × M"压缩成"读 chunk 一次 + + * 写 chunk 一次"。 + */ + private void flushBufferedCozeResults(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED))); + if (states == null || states.isEmpty()) { + return; + } + List entries = new ArrayList<>(); + for (TaskScopeStateEntity state : states) { + CozeBatchContext context = readCozeBatchContext(state); + if (context == null) { + continue; + } + String pointer = context.resultPayloadPointer(); + if (pointer == null || pointer.isBlank()) { + continue; + } + List rows = readBufferedCozeRows(pointer); + if (rows == null || rows.isEmpty()) { + // pointer 存在但 payload 已被清理(被 GC 或上一次 flush 部分成功),直接清 pointer。 + clearBufferedCozeResult(state, context); + continue; + } + entries.add(new BufferedFlushEntry(state, context, rows)); + } + if (entries.isEmpty()) { + return; + } + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + return; + } + Map> allRowsByBaseId = loadAllRowsByBaseId(task); + // 按 chunkScopeHash 分组(空字符串占位 null,让 mergeCozeRowsIntoChunk 走全 chunk 扫描匹配) + Map> entriesByChunk = new LinkedHashMap<>(); + for (BufferedFlushEntry entry : entries) { + String key = entry.context().chunkScopeHash() == null ? "" : entry.context().chunkScopeHash(); + entriesByChunk.computeIfAbsent(key, ignored -> new ArrayList<>()).add(entry); + } + int totalFlushed = 0; + int failedGroups = 0; + for (Map.Entry> group : entriesByChunk.entrySet()) { + List groupEntries = group.getValue(); + List aggregated = new ArrayList<>(); + for (BufferedFlushEntry entry : groupEntries) { + aggregated.addAll(entry.rows()); + } + if (aggregated.isEmpty()) { + continue; + } + String chunkScopeHash = group.getKey().isEmpty() ? null : group.getKey(); + Integer chunkIndex = null; + if (chunkScopeHash != null) { + for (BufferedFlushEntry entry : groupEntries) { + if (entry.context().chunkIndex() != null) { + chunkIndex = entry.context().chunkIndex(); + break; + } + } + } + try { + mergeCozeRowsIntoChunk(task, chunkScopeHash, chunkIndex, aggregated, allRowsByBaseId); + for (BufferedFlushEntry entry : groupEntries) { + clearBufferedCozeResult(entry.state(), entry.context()); + } + totalFlushed += aggregated.size(); + } catch (Exception ex) { + failedGroups++; + log.warn("[similar-asin] flush buffered coze results group failed taskId={} chunkScopeHash={} err={}", + taskId, chunkScopeHash, ex.getMessage(), ex); + // 不 clear pointer,留待下次 finalize 或 stale-recovery 重试 + } + } + if (totalFlushed > 0 || failedGroups > 0) { + log.info("[similar-asin] flushed buffered coze results taskId={} entries={} totalRows={} chunkGroups={} failedGroups={}", + taskId, entries.size(), totalFlushed, entriesByChunk.size(), failedGroups); + } + if (failedGroups > 0) { + // 让 finalize 能感知到失败,调用方应避免继续 requeue assemble。 + throw new IllegalStateException("flush buffered coze results failed groups=" + failedGroups); + } + } + + private record BufferedFlushEntry(TaskScopeStateEntity state, + CozeBatchContext context, + List rows) { + } + private CozeBatchContext readCozeBatchContext(TaskScopeStateEntity state) { if (state == null || state.getStateJson() == null || state.getStateJson().isBlank()) { return null; @@ -3103,7 +3345,23 @@ public class SimilarAsinTaskService { context.batchTotal(), context.ownerInstanceId(), submitRetryCount, - context.credentialName() + context.credentialName(), + context.resultPayloadPointer() + ); + } + + private CozeBatchContext withResultPayloadPointer(CozeBatchContext context, String resultPayloadPointer) { + return new CozeBatchContext( + context.jobId(), + context.resultId(), + context.chunkScopeHash(), + context.chunkIndex(), + context.batchIndex(), + context.batchTotal(), + context.ownerInstanceId(), + context.submitRetryCount(), + context.credentialName(), + resultPayloadPointer ); } @@ -3361,11 +3619,13 @@ public class SimilarAsinTaskService { private void assembleResultWorkbook(FileTaskEntity task, FileResultEntity result) { SimilarAsinParsedPayloadDto parsed = readParsedPayload(task); Map resultMap = loadPersistedResultRowsWithRetry(task.getId(), parsed.getAllItems().size()); + int persistedResultRows = resultMap.size(); + resultMap.entrySet().removeIf(entry -> !isExportableResultRow(entry.getValue())); long resolvedRows = parsed.getAllItems().stream() .filter(row -> findResultRow(row, resultMap) != null) .count(); - log.info("[similar-asin] assemble workbook taskId={} parsedRows={} resultRows={} resolvedRows={}", - task.getId(), parsed.getAllItems().size(), resultMap.size(), resolvedRows); + log.info("[similar-asin] assemble workbook taskId={} parsedRows={} persistedRows={} resultRows={} resolvedRows={}", + task.getId(), parsed.getAllItems().size(), persistedResultRows, resultMap.size(), resolvedRows); if (!parsed.getAllItems().isEmpty() && resultMap.isEmpty()) { throw new BusinessException("相似ASIN检测结果为空,请稍后重试生成结果文件"); } @@ -3645,6 +3905,9 @@ public class SimilarAsinTaskService { private void writeResultWorkbook(File xlsx, List rowsToWrite, Map resultMap) { + // Excel 365 "Place in Cell" 图片:写完 workbook 后 patch richData 单元格图片结构。 + // 这不是浮动 Drawing,因此点击图片区域会选中单元格,图片不能被拖到任意位置,也不需要工作表保护。 + ExcelCellImageWriter.Session excelCellImageSession = ExcelCellImageWriter.createSession(); try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); FileOutputStream fos = new FileOutputStream(xlsx)) { Sheet sheet = workbook.createSheet("相似asin检测"); CellStyle headerStyle = workbook.createCellStyle(); @@ -3652,7 +3915,6 @@ public class SimilarAsinTaskService { font.setBold(true); headerStyle.setFont(font); - Drawing patriarch = sheet.createDrawingPatriarch(); Map taskImageCache = new ConcurrentHashMap<>(); sheet.setColumnWidth(IMG_COL_MAIN, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256); @@ -3668,7 +3930,7 @@ public class SimilarAsinTaskService { } // P2-8:第一遍预扫所有图片 URL,并行下载到 taskImageCache, - // POI 写入仍单线程串行 embed()(cache 命中直接 resize+addPicture),下载/写入解耦。 + // POI 写入仍单线程串行注册 cell image(cache 命中直接 resize + register),下载/写入解耦。 List prefetchUrls = collectImageUrlsForPrefetch(rowsToWrite, resultMap); if (!prefetchUrls.isEmpty()) { long prefetchStart = System.currentTimeMillis(); @@ -3692,13 +3954,14 @@ public class SimilarAsinTaskService { row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory())); row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getStatus(), "")); if (resultRow != null) { - // 行高在 embed 前先固定到 IMAGE_ROW_HEIGHT_POINTS(Excel 上限 409pt): - // embed() 用 IMAGE_COL_WIDTH_CHARS / IMAGE_ROW_HEIGHT_POINTS 常量推导 EMU anchor,与运行时单元格尺寸解耦, - // 顺序前置只是为了让 sheet 实际行高与 anchor 假设一致,避免后续维护误读。 + // 行高固定到 IMAGE_ROW_HEIGHT_POINTS(Excel 上限 409pt): row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS); - imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_MAIN, resultRow.getMainUrl(), row, taskImageCache, false); - imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE1, resultRow.getPuzzleImg1(), row, taskImageCache, true); - imageEmbedder.embed(sheet, patriarch, workbook, rowIndex, IMG_COL_PUZZLE2, resultRow.getPuzzleImg2(), row, taskImageCache, true); + imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_MAIN, + resultRow.getMainUrl(), row, taskImageCache, excelCellImageSession); + imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE1, + resultRow.getPuzzleImg1(), row, taskImageCache, excelCellImageSession); + imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE2, + resultRow.getPuzzleImg2(), row, taskImageCache, excelCellImageSession); } rowIndex++; } @@ -3707,6 +3970,16 @@ public class SimilarAsinTaskService { } catch (Exception ex) { throw new BusinessException("生成相似ASIN检测结果失败", ex); } + if (!excelCellImageSession.isEmpty()) { + try { + ExcelCellImageWriter.patchXlsxFile(xlsx, excelCellImageSession); + } catch (IOException ex) { + String cause = ex.toString(); + log.error("[similar-asin] excel cell image patch failed xlsx={} images={} err={}", + xlsx.getAbsolutePath(), excelCellImageSession.size(), cause, ex); + throw new BusinessException("写入相似ASIN结果 Excel 单元格图片失败: " + cause, ex); + } + } } /** @@ -3854,7 +4127,9 @@ public class SimilarAsinTaskService { } private boolean shouldInspectRow(SimilarAsinResultRowDto row) { - return row != null && (!normalize(row.getTitle()).isBlank() || row.hasImageUrl()); + return row != null && (!rowKey(row).isBlank() + || !normalize(row.getTitle()).isBlank() + || row.hasImageUrl()); } private boolean hasResolvedCozeFields(SimilarAsinResultRowDto row) { @@ -3873,6 +4148,22 @@ public class SimilarAsinTaskService { || hasUsableCozeField(row.getStatus()); } + private boolean isExportableResultRow(SimilarAsinResultRowDto row) { + if (row == null) { + return false; + } + if (hasResolvedCozeFields(row)) { + return true; + } + String error = normalize(row.getError()); + if (error.isBlank()) { + return false; + } + return !normalize(row.getTitle()).isBlank() + || !normalize(row.getUrl()).isBlank() + || row.hasImageUrl(); + } + private boolean hasUsableCozeField(String value) { String normalized = normalize(value); return !normalized.isBlank() && !isTechnicalCozeFailure(normalized); @@ -4590,7 +4881,8 @@ public class SimilarAsinTaskService { Integer batchTotal, String ownerInstanceId, Integer submitRetryCount, - String credentialName) { + String credentialName, + String resultPayloadPointer) { } private static class SourceRowsBuilder { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/ExcelCellImageWriter.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/ExcelCellImageWriter.java new file mode 100644 index 0000000..7c454a9 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/ExcelCellImageWriter.java @@ -0,0 +1,430 @@ +package com.nanri.aiimage.modules.similarasin.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +/** + * Excel 365 "Place in Cell" 图片写入器。 + *

+ * 该格式不是传统 drawing 浮动图片,而是 richData 单元格值:点击图片区域选中的是单元格, + * 图片无法像浮动对象一样拖到任意位置,也不需要开启工作表保护。 + */ +public final class ExcelCellImageWriter { + + private static final Logger log = LoggerFactory.getLogger(ExcelCellImageWriter.class); + + private static final String CONTENT_TYPES_PART = "[Content_Types].xml"; + private static final String WORKBOOK_RELS_PART = "xl/_rels/workbook.xml.rels"; + private static final String SHEET1_PART = "xl/worksheets/sheet1.xml"; + private static final String RICH_VALUE_RELS_PART = "xl/richData/_rels/richValueRel.xml.rels"; + private static final String RD_RICH_VALUE_PART = "xl/richData/rdrichvalue.xml"; + private static final String RD_RICH_VALUE_STRUCTURE_PART = "xl/richData/rdrichvaluestructure.xml"; + private static final String RD_RICH_VALUE_TYPES_PART = "xl/richData/rdRichValueTypes.xml"; + private static final String RICH_VALUE_REL_PART = "xl/richData/richValueRel.xml"; + private static final String METADATA_PART = "xl/metadata.xml"; + + private static final String REL_TYPE_IMAGE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + private static final String REL_TYPE_RICH_VALUE_STRUCTURE = + "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueStructure"; + private static final String REL_TYPE_RICH_VALUE = + "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue"; + private static final String REL_TYPE_RICH_VALUE_REL = + "http://schemas.microsoft.com/office/2022/10/relationships/richValueRel"; + private static final String REL_TYPE_SHEET_METADATA = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata"; + private static final String REL_TYPE_RICH_VALUE_TYPES = + "http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueTypes"; + + private ExcelCellImageWriter() { + } + + public static Session createSession() { + return new Session(); + } + + public static void patchXlsxFile(File xlsx, Session session) throws IOException { + if (xlsx == null || !xlsx.exists() || session == null || session.isEmpty()) { + return; + } + Path xlsxPath = xlsx.toPath().toAbsolutePath(); + Path parent = xlsxPath.getParent(); + if (parent == null) { + throw new IOException("[excel-cell-image] xlsx has no parent directory: " + xlsxPath); + } + Path tmp = Files.createTempFile(parent, "excel-cell-image-", ".xlsx"); + boolean success = false; + Set replacementNames = replacementEntryNames(session); + int patchedCells = -1; + try { + try (ZipFile zfIn = new ZipFile(xlsxPath.toFile()); + ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(tmp))) { + Enumeration entries = zfIn.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (replacementNames.contains(name)) { + continue; + } + if (entry.isDirectory()) { + writeDirectoryEntry(zout, name); + continue; + } + byte[] bytes; + try (InputStream in = zfIn.getInputStream(entry)) { + bytes = readAll(in); + } + if (CONTENT_TYPES_PART.equals(name)) { + bytes = patchContentTypes(bytes); + } else if (WORKBOOK_RELS_PART.equals(name)) { + bytes = patchWorkbookRels(bytes); + } else if (SHEET1_PART.equals(name)) { + PatchSheetResult sheetResult = patchSheet(bytes, session); + bytes = sheetResult.bytes(); + patchedCells = sheetResult.patchedCount(); + if (sheetResult.patchedCount() != session.size()) { + log.warn("[excel-cell-image] patched cells mismatch file={} images={} patchedCells={}", + xlsx.getName(), session.size(), sheetResult.patchedCount()); + } + } + writeEntry(zout, name, bytes); + } + writeEntry(zout, RICH_VALUE_RELS_PART, buildRichValueRels(session).getBytes(StandardCharsets.UTF_8)); + writeEntry(zout, RD_RICH_VALUE_PART, buildRdRichValue(session).getBytes(StandardCharsets.UTF_8)); + writeEntry(zout, RD_RICH_VALUE_STRUCTURE_PART, buildRdRichValueStructure().getBytes(StandardCharsets.UTF_8)); + writeEntry(zout, RD_RICH_VALUE_TYPES_PART, buildRdRichValueTypes().getBytes(StandardCharsets.UTF_8)); + writeEntry(zout, RICH_VALUE_REL_PART, buildRichValueRel(session).getBytes(StandardCharsets.UTF_8)); + writeEntry(zout, METADATA_PART, buildMetadata(session).getBytes(StandardCharsets.UTF_8)); + int idx = 1; + for (RegisteredCellImage image : session.images) { + writeEntry(zout, mediaPart(idx), image.bytes); + idx++; + } + } + try { + Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException moveEx) { + try { + Thread.sleep(200L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING); + log.warn("[excel-cell-image] patch xlsx move retried after error file={} cause={}", + xlsx.getName(), moveEx.toString()); + } + success = true; + log.info("[excel-cell-image] patch xlsx finished file={} images={} patchedCells={}", + xlsx.getName(), session.size(), patchedCells); + } catch (IOException ioe) { + log.error("[excel-cell-image] patch xlsx failed file={} images={} err={}", + xlsx.getName(), session.size(), ioe.toString(), ioe); + throw ioe; + } finally { + if (!success) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) { + // 临时文件清理失败不影响主流程 + } + } + } + } + + private static Set replacementEntryNames(Session session) { + Set names = new HashSet<>(); + names.add(RICH_VALUE_RELS_PART); + names.add(RD_RICH_VALUE_PART); + names.add(RD_RICH_VALUE_STRUCTURE_PART); + names.add(RD_RICH_VALUE_TYPES_PART); + names.add(RICH_VALUE_REL_PART); + names.add(METADATA_PART); + for (int i = 1; i <= session.images.size(); i++) { + names.add(mediaPart(i)); + } + return names; + } + + private static PatchSheetResult patchSheet(byte[] original, Session session) { + String content = new String(original, StandardCharsets.UTF_8); + int idx = 1; + int patchedCount = 0; + for (RegisteredCellImage image : session.images) { + String replacement = "#VALUE!"; + Pattern pattern = Pattern.compile("]*\\br=\"" + Pattern.quote(image.cellRef) + + "\")[^>]*(?:/>|>.*?)", Pattern.DOTALL); + Matcher matcher = pattern.matcher(content); + if (matcher.find()) { + content = matcher.replaceFirst(Matcher.quoteReplacement(replacement)); + patchedCount++; + } else { + log.warn("[excel-cell-image] cell {} not found in sheet xml, skip image", image.cellRef); + } + idx++; + } + return new PatchSheetResult(content.getBytes(StandardCharsets.UTF_8), patchedCount); + } + + private static byte[] patchContentTypes(byte[] original) { + String content = new String(original, StandardCharsets.UTF_8); + StringBuilder injection = new StringBuilder(); + if (!content.contains("Extension=\"jpeg\"")) { + injection.append(""); + } + appendOverrideIfMissing(content, injection, "/xl/metadata.xml", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml"); + appendOverrideIfMissing(content, injection, "/xl/richData/richValueRel.xml", + "application/vnd.ms-excel.richvaluerel+xml"); + appendOverrideIfMissing(content, injection, "/xl/richData/rdrichvalue.xml", + "application/vnd.ms-excel.rdrichvalue+xml"); + appendOverrideIfMissing(content, injection, "/xl/richData/rdrichvaluestructure.xml", + "application/vnd.ms-excel.rdrichvaluestructure+xml"); + appendOverrideIfMissing(content, injection, "/xl/richData/rdRichValueTypes.xml", + "application/vnd.ms-excel.rdrichvaluetypes+xml"); + if (injection.isEmpty()) { + return original; + } + int closeIdx = content.lastIndexOf(""); + if (closeIdx < 0) { + log.warn("[excel-cell-image] [Content_Types].xml missing , skip patch"); + return original; + } + return (content.substring(0, closeIdx) + injection + content.substring(closeIdx)) + .getBytes(StandardCharsets.UTF_8); + } + + private static void appendOverrideIfMissing(String content, StringBuilder injection, String partName, String contentType) { + if (!content.contains("PartName=\"" + partName + "\"")) { + injection.append(""); + } + } + + private static byte[] patchWorkbookRels(byte[] original) { + String content = new String(original, StandardCharsets.UTF_8); + int maxId = findMaxRId(content); + StringBuilder injection = new StringBuilder(); + maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE_STRUCTURE, + "richData/rdrichvaluestructure.xml"); + maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE, + "richData/rdrichvalue.xml"); + maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE_REL, + "richData/richValueRel.xml"); + maxId = appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_SHEET_METADATA, + "metadata.xml"); + appendRelationshipIfMissing(content, injection, maxId, REL_TYPE_RICH_VALUE_TYPES, + "richData/rdRichValueTypes.xml"); + if (injection.isEmpty()) { + return original; + } + int closeIdx = content.lastIndexOf(""); + if (closeIdx < 0) { + log.warn("[excel-cell-image] xl/_rels/workbook.xml.rels missing , skip patch"); + return original; + } + return (content.substring(0, closeIdx) + injection + content.substring(closeIdx)) + .getBytes(StandardCharsets.UTF_8); + } + + private static int appendRelationshipIfMissing(String content, + StringBuilder injection, + int maxId, + String type, + String target) { + if (content.contains("Target=\"" + target + "\"")) { + return maxId; + } + int next = maxId + 1; + injection.append(""); + return next; + } + + private static String buildRichValueRels(Session session) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + for (int i = 1; i <= session.images.size(); i++) { + sb.append(""); + } + sb.append(""); + return sb.toString(); + } + + private static String buildRdRichValue(Session session) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + for (int i = 0; i < session.images.size(); i++) { + sb.append("").append(i).append("5"); + } + sb.append(""); + return sb.toString(); + } + + private static String buildRdRichValueStructure() { + return "" + + "" + + "" + + ""; + } + + private static String buildRdRichValueTypes() { + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + } + + private static String buildRichValueRel(Session session) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + for (int i = 1; i <= session.images.size(); i++) { + sb.append(""); + } + sb.append(""); + return sb.toString(); + } + + private static String buildMetadata(Session session) { + StringBuilder sb = new StringBuilder(); + int count = session.images.size(); + sb.append(""); + sb.append("") + .append("") + .append("") + .append("") + .append(""); + for (int i = 0; i < count; i++) { + sb.append("") + .append("") + .append(""); + } + sb.append(""); + for (int i = 0; i < count; i++) { + sb.append(""); + } + sb.append(""); + return sb.toString(); + } + + private static int findMaxRId(String content) { + int maxId = 0; + Matcher matcher = Pattern.compile("Id=\"rId(\\d+)\"").matcher(content); + while (matcher.find()) { + try { + maxId = Math.max(maxId, Integer.parseInt(matcher.group(1))); + } catch (NumberFormatException ignored) { + // 非数字 rId 不影响新增关系 + } + } + return maxId; + } + + private static String mediaPart(int idx) { + return "xl/media/excelcellimage" + idx + ".jpeg"; + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + baos.write(buf, 0, n); + } + return baos.toByteArray(); + } + + private static void writeEntry(ZipOutputStream zout, String name, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(name); + zout.putNextEntry(entry); + zout.write(bytes); + zout.closeEntry(); + } + + private static void writeDirectoryEntry(ZipOutputStream zout, String name) throws IOException { + ZipEntry entry = new ZipEntry(name); + zout.putNextEntry(entry); + zout.closeEntry(); + } + + public static final class Session { + private final List images = new ArrayList<>(); + + public synchronized void registerImage(int rowIdx, int colIdx, byte[] jpegBytes) { + if (jpegBytes == null || jpegBytes.length == 0) { + return; + } + images.add(new RegisteredCellImage(cellRef(rowIdx, colIdx), jpegBytes)); + } + + public synchronized boolean isEmpty() { + return images.isEmpty(); + } + + public synchronized int size() { + return images.size(); + } + + private static String cellRef(int rowIdx, int colIdx) { + return columnName(colIdx) + (rowIdx + 1); + } + + private static String columnName(int colIdx) { + int value = colIdx + 1; + StringBuilder sb = new StringBuilder(); + while (value > 0) { + int rem = (value - 1) % 26; + sb.append((char) ('A' + rem)); + value = (value - 1) / 26; + } + return sb.reverse().toString(); + } + } + + private record RegisteredCellImage(String cellRef, byte[] bytes) { + } + + private record PatchSheetResult(byte[] bytes, int patchedCount) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index d73b091..4a4e730 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -8,13 +8,8 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; -import org.apache.poi.ss.usermodel.ClientAnchor; -import org.apache.poi.ss.usermodel.Drawing; +import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; -import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.ss.usermodel.Workbook; -import org.apache.poi.util.Units; -import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Component; import javax.imageio.IIOImage; @@ -173,25 +168,15 @@ public class SimilarAsinImageEmbedder { } /** - * 嵌入单元格:下载 → resize → POI addPicture → createPicture,全链路任一失败即 URL 文本兜底。 - * 返回值:成功时为缩略图实际像素(width/height),失败兜底为 null;调用方据此自适应行高/列宽。 + * 抽取共享:下载 + resize + 写 cache。任一失败即写文本兜底(cell 走 row.createCell(colIdx).setCellValue), + * 失败时返回 null;成功返回缩略图。给 Excel richData 单元格图片写入路径使用。 */ - public ImageDim embed(Sheet sheet, - Drawing patriarch, - SXSSFWorkbook wb, - int rowIdx, - int colIdx, - String url, - Row row, - Map taskImageCache, - boolean fillCell) { - if (url == null || url.isBlank()) { - return null; - } - String trimmedUrl = url.trim(); - ResizedImage thumb; + private ResizedImage downloadAndResize(String trimmedUrl, + int colIdx, + Row row, + Map taskImageCache) { try { - thumb = taskImageCache.get(trimmedUrl); + ResizedImage thumb = taskImageCache.get(trimmedUrl); boolean cacheHit = thumb != null; if (!cacheHit) { byte[] raw = downloadWithRetry(trimmedUrl); @@ -203,73 +188,46 @@ public class SimilarAsinImageEmbedder { log.debug("[similar-asin][image] cache-hit url={} bytes={} dims={}x{}", trimmedUrl, thumb.bytes().length, thumb.width(), thumb.height()); } + return thumb; } catch (UnsupportedUrlException ex) { log.warn("[similar-asin][image] unsupported-url url={} reason={}", trimmedUrl, ex.getMessage()); - row.createCell(colIdx).setCellValue(trimmedUrl); - return null; } catch (DownloadOversizeException ex) { log.warn("[similar-asin][image] download-oversize url={} bytes={}", trimmedUrl, ex.size()); - row.createCell(colIdx).setCellValue(trimmedUrl); - return null; } catch (ResizeOversizeException ex) { log.warn("[similar-asin][image] resize-oversize url={} bytes={}", ex.url(), ex.size()); - row.createCell(colIdx).setCellValue(trimmedUrl); - return null; } catch (ResizeException ex) { log.warn("[similar-asin][image] resize-fail url={} err={}", trimmedUrl, ex.getMessage()); - row.createCell(colIdx).setCellValue(trimmedUrl); - return null; } catch (IOException | TimeoutException ex) { log.warn("[similar-asin][image] download-fail url={} err={}", trimmedUrl, ex.getMessage()); - row.createCell(colIdx).setCellValue(trimmedUrl); - return null; } catch (RuntimeException ex) { log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage()); - row.createCell(colIdx).setCellValue(trimmedUrl); + } + row.createCell(colIdx).setCellValue(trimmedUrl); + return null; + } + + public ImageDim embedAsExcelCellImage(int rowIdx, + int colIdx, + String url, + Row row, + Map taskImageCache, + ExcelCellImageWriter.Session excelCellImageSession) { + if (url == null || url.isBlank() || excelCellImageSession == null) { + return null; + } + String trimmedUrl = url.trim(); + ResizedImage thumb = downloadAndResize(trimmedUrl, colIdx, row, taskImageCache); + if (thumb == null) { return null; } try { - int picIdx = wb.addPicture(thumb.bytes(), Workbook.PICTURE_TYPE_JPEG); - ClientAnchor anchor = wb.getCreationHelper().createClientAnchor(); - anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_DONT_RESIZE); - anchor.setCol1(colIdx); - anchor.setRow1(rowIdx); - anchor.setCol2(colIdx); - anchor.setRow2(rowIdx); - // 显式 EMU anchor:col1==col2 / row1==row2,仅靠 dx/dy 居中等比缩放。 - // picture.resize() 会按"调用时刻"的实际行高/列宽推算 col2/row2, - // 但 service 端可能在 embed 之后才 setHeightInPoints(409),导致 anchor 跨多行被纵向拉伸。 - // 这里彻底脱离运行时单元格尺寸依赖,单元格大小通过 IMAGE_COL_WIDTH_CHARS / IMAGE_ROW_HEIGHT_POINTS 常量推导。 - double cellWidthEmu = IMAGE_COL_WIDTH_CHARS * 7.0 * Units.EMU_PER_PIXEL; - double cellHeightEmu = IMAGE_ROW_HEIGHT_POINTS * (double) Units.EMU_PER_POINT; - double picWidthEmu = thumb.width() * (double) Units.EMU_PER_PIXEL; - double picHeightEmu = thumb.height() * (double) Units.EMU_PER_PIXEL; - if (fillCell) { - anchor.setDx1(0); - anchor.setDy1(0); - anchor.setDx2((int) Math.round(cellWidthEmu)); - anchor.setDy2((int) Math.round(cellHeightEmu)); - } else { - double scale = Math.min(cellWidthEmu / picWidthEmu, cellHeightEmu / picHeightEmu) * 0.98; - if (scale > 1.0) { - scale = 1.0; - } - double displayedW = picWidthEmu * scale; - double displayedH = picHeightEmu * scale; - int dx1 = (int) Math.round((cellWidthEmu - displayedW) / 2.0); - int dy1 = (int) Math.round((cellHeightEmu - displayedH) / 2.0); - anchor.setDx1(dx1); - anchor.setDy1(dy1); - anchor.setDx2((int) Math.round(dx1 + displayedW)); - anchor.setDy2((int) Math.round(dy1 + displayedH)); - } - patriarch.createPicture(anchor, picIdx); - ImageDim dim = new ImageDim(thumb.width(), thumb.height()); - // POI 已经把缩略图字节复制进 picture pool(B 副本),此时移除 taskImageCache 里的 A 副本可立刻释放。 + Cell cell = row.createCell(colIdx); + cell.setCellValue("#VALUE!"); + excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes()); taskImageCache.remove(trimmedUrl); - return dim; + return new ImageDim(thumb.width(), thumb.height()); } catch (RuntimeException ex) { - log.warn("[similar-asin][image] embed-fail url={} err={}", trimmedUrl, ex.getMessage()); + log.warn("[similar-asin][image] excel-cell-image-fail url={} err={}", trimmedUrl, ex.getMessage()); row.createCell(colIdx).setCellValue(trimmedUrl); return null; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java new file mode 100644 index 0000000..67b4bf3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java @@ -0,0 +1,356 @@ +package com.nanri.aiimage.modules.similarasin.util; + +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +/** + * WPS DISPIMG "图片嵌入单元格" 协议写入器。 + *

+ * Excel 1997-2007 OOXML 没有 "图片嵌入单元格" 概念,POI 5.2.5 也不支持写入 Excel 365 的 IMAGE() 函数。 + * 本类基于 WPS 在 OOXML 上的扩展协议(cellimages.xml),让生成的 xlsx 在 WPS、钉钉表格、 + * 飞书表格、Excel 365 中表现为 "图片真嵌入到单元格内":解除工作表保护后图片仍跟着单元格走, + * 拷贝粘贴行/排序时图片随单元格移动,无法被鼠标拖出。 + *

+ * 用法: + *

+ *     WpsCellImageWriter.Session session = WpsCellImageWriter.createSession();
+ *     // 写每个 cell:
+ *     String id = session.registerImage(jpegBytes);
+ *     cell.setCellFormula(WpsCellImageWriter.dispImgFormula(id));
+ *     // workbook.write(file) 之后调用:
+ *     WpsCellImageWriter.patchXlsxFile(xlsx, session);
+ * 
+ *

+ * 协议参考:WPS Office 表格扩展 cellimages.xml。 + */ +@Slf4j +public final class WpsCellImageWriter { + + public static final String CELLIMAGES_PART = "xl/cellimages.xml"; + public static final String CELLIMAGES_RELS_PART = "xl/_rels/cellimages.xml.rels"; + public static final String CONTENT_TYPES_PART = "[Content_Types].xml"; + public static final String WORKBOOK_RELS_PART = "xl/_rels/workbook.xml.rels"; + public static final String CELLIMAGES_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.cellimages+xml"; + public static final String CELLIMAGES_REL_TYPE = + "http://www.wps.cn/officeDocument/2018/relationships/cellimage"; + public static final String IMAGE_REL_TYPE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; + + private WpsCellImageWriter() { + } + + public static Session createSession() { + return new Session(); + } + + /** + * 构造 DISPIMG 公式。 + *

+ * 第二个参数固定 1:图片自动缩放到单元格大小(与 IMAGE_ROW_HEIGHT_POINTS / IMAGE_COL_WIDTH_CHARS 配合, + * 既不会拉伸变形也能填充单元格)。 + */ + public static String dispImgFormula(String id) { + if (id == null || id.isBlank()) { + return null; + } + return "_xlfn.DISPIMG(\"" + id + "\",1)"; + } + + /** + * 在已写出的 xlsx 文件上 patch DISPIMG 协议所需的 4 个 part: + * - 新建 xl/cellimages.xml + * - 新建 xl/_rels/cellimages.xml.rels + * - 新建 xl/media/cellimage{N}.jpeg + * - 修改 [Content_Types].xml(加 Override) + * - 修改 xl/_rels/workbook.xml.rels(加 Relationship) + *

+ * 通过临时文件 + REPLACE_EXISTING move 实现:失败时 xlsx 文件保持原样不被破坏。 + * 历史使用 ATOMIC_MOVE+REPLACE_EXISTING,但 Java NIO 规范说 ATOMIC_MOVE 下其它选项被忽略, + * Windows 实现遇到目标已存在会抛 AccessDeniedException / FileAlreadyExistsException, + * 故改为常规 REPLACE_EXISTING(Windows 内部走 MoveFileEx,行为已非常接近原子)。 + */ + public static void patchXlsxFile(File xlsx, Session session) throws IOException { + if (xlsx == null || !xlsx.exists() || session == null || session.isEmpty()) { + return; + } + Path xlsxPath = xlsx.toPath().toAbsolutePath(); + Path parent = xlsxPath.getParent(); + if (parent == null) { + throw new IOException("[wps-dispimg] xlsx has no parent directory: " + xlsxPath); + } + Path tmp = Files.createTempFile(parent, "wps-dispimg-", ".xlsx"); + boolean success = false; + try { + // 使用 ZipFile(基于中央目录读)替代 ZipInputStream(顺序流读)。 + // 原因:POI SXSSFWorkbook 写出的 xlsx,部分 entry 的 LFH(Local File Header)会用 data descriptor + // 把 size 字段置 0,把真实 CRC/size 放到 entry 末尾。ZipInputStream 在 STORED 模式下按 LFH 的 size=0 + // 校验,会抛 "ZipException: invalid entry size (expected 0 but got N bytes)"。ZipFile 直接读中央目录 + // 拿到真实 size,绕开该问题。 + try (ZipFile zfIn = new ZipFile(xlsxPath.toFile()); + ZipOutputStream zout = new ZipOutputStream(Files.newOutputStream(tmp))) { + Enumeration entries = zfIn.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + if (entry.isDirectory()) { + writeDirectoryEntry(zout, name); + continue; + } + byte[] bytes; + try (InputStream in = zfIn.getInputStream(entry)) { + bytes = readAll(in); + } + if (CONTENT_TYPES_PART.equals(name)) { + bytes = patchContentTypes(bytes); + } else if (WORKBOOK_RELS_PART.equals(name)) { + bytes = patchWorkbookRels(bytes); + } + writeEntry(zout, name, bytes); + } + writeEntry(zout, CELLIMAGES_PART, + buildCellImagesXml(session).getBytes(StandardCharsets.UTF_8)); + writeEntry(zout, CELLIMAGES_RELS_PART, + buildCellImagesRelsXml(session).getBytes(StandardCharsets.UTF_8)); + int idx = 1; + for (RegisteredImage img : session.images) { + writeEntry(zout, "xl/media/cellimage" + idx + ".jpeg", img.bytes); + idx++; + } + } + try { + Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException moveEx) { + // Windows 上偶发 AccessDenied(如反病毒/索引服务短暂占用源文件),retry 一次。 + try { + Thread.sleep(200L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + Files.move(tmp, xlsxPath, StandardCopyOption.REPLACE_EXISTING); + log.warn("[wps-dispimg] patch xlsx move retried after error file={} cause={}", + xlsx.getName(), moveEx.toString()); + } + success = true; + log.info("[wps-dispimg] patch xlsx finished file={} images={}", xlsx.getName(), session.size()); + } catch (IOException ioe) { + log.error("[wps-dispimg] patch xlsx failed file={} images={} err={}", + xlsx.getName(), session.size(), ioe.toString(), ioe); + throw ioe; + } finally { + if (!success) { + try { + Files.deleteIfExists(tmp); + } catch (IOException ignored) { + // 临时文件清理失败不影响主流程 + } + } + } + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + baos.write(buf, 0, n); + } + return baos.toByteArray(); + } + + private static void writeEntry(ZipOutputStream zout, String name, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(name); + zout.putNextEntry(entry); + zout.write(bytes); + zout.closeEntry(); + } + + private static void writeDirectoryEntry(ZipOutputStream zout, String name) throws IOException { + ZipEntry entry = new ZipEntry(name); + zout.putNextEntry(entry); + zout.closeEntry(); + } + + static byte[] patchContentTypes(byte[] original) { + String content = new String(original, StandardCharsets.UTF_8); + // 已经存在则跳过(幂等:避免重复 patch) + if (content.contains("PartName=\"/" + CELLIMAGES_PART + "\"")) { + return original; + } + String injection = ""; + int closeIdx = content.lastIndexOf(""); + if (closeIdx < 0) { + log.warn("[wps-dispimg] [Content_Types].xml missing , skip patch"); + return original; + } + String patched = content.substring(0, closeIdx) + injection + content.substring(closeIdx); + return patched.getBytes(StandardCharsets.UTF_8); + } + + static byte[] patchWorkbookRels(byte[] original) { + String content = new String(original, StandardCharsets.UTF_8); + if (content.contains("Target=\"cellimages.xml\"")) { + return original; + } + // 扫描已有最大 rId + int maxId = 0; + Matcher m = Pattern.compile("Id=\"rId(\\d+)\"").matcher(content); + while (m.find()) { + try { + maxId = Math.max(maxId, Integer.parseInt(m.group(1))); + } catch (NumberFormatException ignored) { + // 非数字 rId 无影响 + } + } + String newId = "rId" + (maxId + 1); + String injection = ""; + int closeIdx = content.lastIndexOf(""); + if (closeIdx < 0) { + log.warn("[wps-dispimg] xl/_rels/workbook.xml.rels missing , skip patch"); + return original; + } + String patched = content.substring(0, closeIdx) + injection + content.substring(closeIdx); + return patched.getBytes(StandardCharsets.UTF_8); + } + + static String buildCellImagesXml(Session session) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + int idx = 1; + for (RegisteredImage img : session.images) { + // cNvPr id 从 2 起步、descr 为空字符串、spPr 末尾追加 a:ln/a:noFill —— 与 WPS 实测样本完全对齐, + // 减少 Excel 在"修复"路径上把整个 part 丢弃的概率。 + int cNvPrId = idx + 1; + sb.append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append("") + .append(""); + idx++; + } + sb.append(""); + return sb.toString(); + } + + static String buildCellImagesRelsXml(Session session) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + int idx = 1; + for (RegisteredImage ignored : session.images) { + sb.append(""); + idx++; + } + sb.append(""); + return sb.toString(); + } + + /** + * 同一 task 内的图片注册会话。线程安全(synchronized)。 + */ + public static final class Session { + final List images = new ArrayList<>(); + // 字节级去重:相同图片只写一次 cellimage{N}.jpeg,节省 xlsx 体积。 + private final Map bytesHashToId = new LinkedHashMap<>(); + + public synchronized String registerImage(byte[] jpegBytes) { + if (jpegBytes == null || jpegBytes.length == 0) { + return null; + } + String hash = md5Hex(jpegBytes); + String existingId = bytesHashToId.get(hash); + if (existingId != null) { + return existingId; + } + String id = newImageId(); + images.add(new RegisteredImage(id, jpegBytes)); + bytesHashToId.put(hash, id); + return id; + } + + public synchronized boolean isEmpty() { + return images.isEmpty(); + } + + public synchronized int size() { + return images.size(); + } + + private static String newImageId() { + // ID_xxx 格式(24 hex chars)模拟 WPS 默认风格,避免特殊字符让公式解析出错。 + return "ID_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + + private static String md5Hex(byte[] bytes) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(bytes); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException ex) { + // MD5 缺失只在极端 JVM 下出现,退化用 hashCode 即可保证 session 内"内存唯一性" + return "len" + bytes.length + ":h" + Arrays.hashCode(bytes); + } + } + } + + static final class RegisteredImage { + final String id; + final byte[] bytes; + + RegisteredImage(String id, byte[] bytes) { + this.id = id; + this.bytes = bytes; + } + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 8627d88..0d02cce 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -181,6 +181,7 @@ aiimage: stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *} coze-include-legacy-api-key: ${AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY:true} coze-use-legacy-item-field-order: ${AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER:false} + coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true} security: shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} internal-token: ${AIIMAGE_INTERNAL_TOKEN:}