PUGE пре 1 месец
родитељ
комит
d5bb21c709
1 измењених фајлова са 485 додато и 2 уклоњено
  1. 485 2
      自动匹配上架图片.py

+ 485 - 2
自动匹配上架图片.py

@@ -11,8 +11,10 @@ import json
 import datetime
 import hashlib
 import hmac
-from urllib.parse import quote
+from urllib.parse import quote, urlparse, parse_qsl
 import time
+import base64
+from io import BytesIO
 
 
 class AmazonImageUploader:
@@ -138,13 +140,75 @@ class AmazonImageUploader:
             ]
         }
         return self.spapi_request("PATCH", path, query, body=body, access_token=access_token)
+    
+    # ==================== A+ Content API 方法 ====================
+    def create_upload_destination(self, access_token, content_md5, content_type="image/png"):
+        """创建上传目标"""
+        resource = "aplus/2020-11-01/contentDocuments"
+        path = f"/uploads/2020-11-01/uploadDestinations/{quote(resource, safe='/')}"
+        query = f"marketplaceIds={self.marketplace_id}&contentMD5={quote(content_md5, safe='')}&contentType={quote(content_type, safe='')}"
+        
+        resp = self.spapi_request("POST", path, query, access_token=access_token)
+        if resp.status_code not in [200, 201]:
+            raise Exception(f"创建上传目标失败: {resp.text}")
+        return resp.json().get("payload", {})
+    
+    def upload_to_s3(self, upload_url, image_binary, content_type="image/png"):
+        """上传图片到S3"""
+        parsed = urlparse(upload_url)
+        base_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
+        query_params = dict(parse_qsl(parsed.query))
+        files = {"File": ("image.png", image_binary, content_type)}
+        resp = requests.post(base_url, data=query_params, files=files, timeout=60)
+        if resp.status_code not in [200, 201, 204]:
+            raise Exception(f"S3上传失败: {resp.status_code}")
+        return True
+    
+    def create_content_document(self, access_token, content_doc):
+        """创建内容文档"""
+        resp = self.spapi_request(
+            "POST", 
+            "/aplus/2020-11-01/contentDocuments", 
+            f"marketplaceId={self.marketplace_id}", 
+            body={"contentDocument": content_doc}, 
+            access_token=access_token
+        )
+        if resp.status_code not in [200, 201]:
+            raise Exception(f"创建内容文档失败: {resp.text}")
+        return resp.json().get("contentReferenceKey")
+    
+    def bind_asin_to_content(self, access_token, content_key, asin):
+        """绑定ASIN到内容"""
+        body = {"asinSet": [asin]}
+        resp = self.spapi_request(
+            "POST", 
+            f"/aplus/2020-11-01/contentDocuments/{content_key}/asins", 
+            f"marketplaceId={self.marketplace_id}", 
+            body=body, 
+            access_token=access_token
+        )
+        if resp.status_code not in [200, 201]:
+            raise Exception(f"绑定ASIN失败: {resp.text}")
+        return True
+    
+    def submit_for_approval(self, access_token, content_key):
+        """提交审核"""
+        resp = self.spapi_request(
+            "POST", 
+            f"/aplus/2020-11-01/contentDocuments/{content_key}/approvalSubmissions", 
+            f"marketplaceId={self.marketplace_id}", 
+            access_token=access_token
+        )
+        if resp.status_code not in [200, 201]:
+            raise Exception(f"提交审核失败: {resp.text}")
+        return True
 
 
 class ImageMatcherApp:
     def __init__(self, root):
         self.root = root
         self.root.title("图片匹配与上传工具 - 亚马逊版")
-        self.root.geometry("950x750")
+        self.root.geometry("950x800")
         
         # 存储文件路径
         self.template_path = tk.StringVar()
@@ -189,6 +253,11 @@ class ImageMatcherApp:
         self.notebook.add(self.tab_amazon, text="亚马逊主图更换")
         self.create_amazon_tab()
         
+        # 选项卡3: 亚马逊详情图更换 (A+ Content)
+        self.tab_detail = ttk.Frame(self.notebook)
+        self.notebook.add(self.tab_detail, text="亚马逊详情图更换")
+        self.create_detail_tab()
+        
         # 状态栏
         self.status_var = tk.StringVar()
         self.status_var.set("就绪")
@@ -287,6 +356,73 @@ class ImageMatcherApp:
         self.amazon_progress_var = tk.StringVar(value="就绪")
         progress_label = ttk.Label(tab, textvariable=self.amazon_progress_var, relief=tk.SUNKEN, anchor=tk.W)
         progress_label.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
+
+    def create_detail_tab(self):
+        """创建亚马逊详情图更换选项卡 (A+ Content)"""
+        tab = self.tab_detail
+        tab.columnconfigure(1, weight=1)
+        tab.rowconfigure(8, weight=1)
+        
+        # 标题
+        title_label = ttk.Label(tab, text="亚马逊详情图更换 (A+ Content)", font=("Arial", 14, "bold"))
+        title_label.grid(row=0, column=0, columnspan=3, pady=10)
+        
+        # 模板文件选择
+        ttk.Label(tab, text="模板文件:").grid(row=1, column=0, sticky=tk.W, pady=5)
+        self.detail_template_path = tk.StringVar()
+        ttk.Entry(tab, textvariable=self.detail_template_path, width=60).grid(row=1, column=1, sticky=(tk.W, tk.E), pady=5, padx=5)
+        ttk.Button(tab, text="选择模板", command=self.select_detail_template).grid(row=1, column=2, pady=5, padx=5)
+        
+        # ASIN输入
+        ttk.Label(tab, text="ASIN (手动输入):").grid(row=2, column=0, sticky=tk.W, pady=5)
+        self.asin_input = tk.StringVar(value="B0FR1WRK4M")
+        ttk.Entry(tab, textvariable=self.asin_input, width=40).grid(row=2, column=1, sticky=tk.W, pady=5, padx=5)
+        
+        # 图片URL列选择(详情图)
+        ttk.Label(tab, text="详情图列名(逗号分隔):").grid(row=3, column=0, sticky=tk.W, pady=5)
+        self.detail_image_columns = tk.StringVar(value="详情图URL1,详情图URL2,详情图URL3")
+        ttk.Entry(tab, textvariable=self.detail_image_columns, width=60).grid(row=3, column=1, sticky=(tk.W, tk.E), pady=5, padx=5)
+        
+        # 图片尺寸设置
+        ttk.Label(tab, text="图片宽度(像素):").grid(row=4, column=0, sticky=tk.W, pady=5)
+        self.image_width = tk.StringVar(value="970")
+        ttk.Entry(tab, textvariable=self.image_width, width=15).grid(row=4, column=1, sticky=tk.W, pady=5, padx=5)
+        
+        ttk.Label(tab, text="图片高度(像素):").grid(row=5, column=0, sticky=tk.W, pady=5)
+        self.image_height = tk.StringVar(value="600")
+        ttk.Entry(tab, textvariable=self.image_height, width=15).grid(row=5, column=1, sticky=tk.W, pady=5, padx=5)
+        
+        # 标题和描述设置
+        ttk.Label(tab, text="模块标题:").grid(row=6, column=0, sticky=tk.W, pady=5)
+        self.module_headline = tk.StringVar(value="Premium Quality Product")
+        ttk.Entry(tab, textvariable=self.module_headline, width=50).grid(row=6, column=1, sticky=tk.W, pady=5, padx=5)
+        
+        ttk.Label(tab, text="模块描述:").grid(row=7, column=0, sticky=tk.W, pady=5)
+        self.module_body = tk.StringVar(value="High-quality product designed for your needs.")
+        ttk.Entry(tab, textvariable=self.module_body, width=50).grid(row=7, column=1, sticky=tk.W, pady=5, padx=5)
+        
+        # 按钮框架
+        button_frame = ttk.Frame(tab)
+        button_frame.grid(row=8, column=0, columnspan=3, pady=10)
+        
+        ttk.Button(button_frame, text="保存配置", command=self.save_detail_config, width=12).pack(side=tk.LEFT, padx=5)
+        ttk.Button(button_frame, text="加载配置", command=self.load_detail_config, width=12).pack(side=tk.LEFT, padx=5)
+        ttk.Button(button_frame, text="开始上传", command=self.start_detail_upload, width=15).pack(side=tk.LEFT, padx=5)
+        ttk.Button(button_frame, text="清空日志", command=self.clear_detail_log, width=15).pack(side=tk.LEFT, padx=5)
+        
+        # 日志框
+        log_frame = ttk.LabelFrame(tab, text="上传日志", padding="5")
+        log_frame.grid(row=9, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=10)
+        log_frame.columnconfigure(0, weight=1)
+        log_frame.rowconfigure(0, weight=1)
+        
+        self.detail_log_text = scrolledtext.ScrolledText(log_frame, height=15, width=80, wrap=tk.WORD)
+        self.detail_log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
+        
+        # 进度标签
+        self.detail_progress_var = tk.StringVar(value="就绪")
+        progress_label = ttk.Label(tab, textvariable=self.detail_progress_var, relief=tk.SUNKEN, anchor=tk.W)
+        progress_label.grid(row=10, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
         
     def select_template(self):
         filename = filedialog.askopenfilename(
@@ -311,6 +447,15 @@ class ImageMatcherApp:
         if filename:
             self.amazon_template_path.set(filename)
             self.amazon_log(f"已选择模板文件: {filename}")
+    
+    def select_detail_template(self):
+        filename = filedialog.askopenfilename(
+            title="选择模板文件",
+            filetypes=[("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")]
+        )
+        if filename:
+            self.detail_template_path.set(filename)
+            self.detail_log(f"已选择模板文件: {filename}")
             
     def log(self, message):
         """在匹配选项卡日志框中添加消息"""
@@ -331,10 +476,24 @@ class ImageMatcherApp:
     def clear_amazon_log(self):
         """清空亚马逊选项卡日志"""
         self.amazon_log_text.delete(1.0, tk.END)
+    
+    def detail_log(self, message):
+        """在详情图选项卡日志框中添加消息"""
+        self.detail_log_text.insert(tk.END, f"{message}\n")
+        self.detail_log_text.see(tk.END)
+        self.root.update_idletasks()
+        
+    def clear_detail_log(self):
+        """清空详情图选项卡日志"""
+        self.detail_log_text.delete(1.0, tk.END)
         
     def get_config_path(self):
         """获取配置文件路径"""
         return os.path.join(os.path.dirname(os.path.abspath(__file__)), "amazon_config.json")
+    
+    def get_detail_config_path(self):
+        """获取详情图配置文件路径"""
+        return os.path.join(os.path.dirname(os.path.abspath(__file__)), "amazon_detail_config.json")
         
     def save_amazon_config(self):
         """保存亚马逊配置"""
@@ -368,6 +527,45 @@ class ImageMatcherApp:
                 self.amazon_log("⚠️ 未找到配置文件,使用默认配置")
         except Exception as e:
             self.amazon_log(f"❌ 加载配置失败: {str(e)}")
+    
+    def save_detail_config(self):
+        """保存详情图配置"""
+        config = {
+            "asin": self.asin_input.get(),
+            "detail_image_columns": self.detail_image_columns.get(),
+            "template_path": self.detail_template_path.get(),
+            "image_width": self.image_width.get(),
+            "image_height": self.image_height.get(),
+            "module_headline": self.module_headline.get(),
+            "module_body": self.module_body.get()
+        }
+        try:
+            with open(self.get_detail_config_path(), 'w', encoding='utf-8') as f:
+                json.dump(config, f, ensure_ascii=False, indent=2)
+            self.detail_log("✅ 配置已保存")
+        except Exception as e:
+            self.detail_log(f"❌ 保存配置失败: {str(e)}")
+            
+    def load_detail_config(self):
+        """加载详情图配置"""
+        try:
+            config_path = self.get_detail_config_path()
+            if os.path.exists(config_path):
+                with open(config_path, 'r', encoding='utf-8') as f:
+                    config = json.load(f)
+                self.asin_input.set(config.get("asin", "B0FR1WRK4M"))
+                self.detail_image_columns.set(config.get("detail_image_columns", "详情图URL1,详情图URL2,详情图URL3"))
+                self.image_width.set(config.get("image_width", "970"))
+                self.image_height.set(config.get("image_height", "600"))
+                self.module_headline.set(config.get("module_headline", "Premium Quality Product"))
+                self.module_body.set(config.get("module_body", "High-quality product designed for your needs."))
+                if config.get("template_path"):
+                    self.detail_template_path.set(config.get("template_path"))
+                self.detail_log("✅ 配置已加载")
+            else:
+                self.detail_log("⚠️ 未找到配置文件,使用默认配置")
+        except Exception as e:
+            self.detail_log(f"❌ 加载配置失败: {str(e)}")
             
     def load_config(self):
         """启动时自动加载亚马逊配置"""
@@ -383,6 +581,22 @@ class ImageMatcherApp:
                     self.amazon_template_path.set(config.get("template_path"))
         except Exception:
             pass
+        # 加载详情图配置
+        try:
+            config_path = self.get_detail_config_path()
+            if os.path.exists(config_path):
+                with open(config_path, 'r', encoding='utf-8') as f:
+                    config = json.load(f)
+                self.asin_input.set(config.get("asin", "B0FR1WRK4M"))
+                self.detail_image_columns.set(config.get("detail_image_columns", "详情图URL1,详情图URL2,详情图URL3"))
+                self.image_width.set(config.get("image_width", "970"))
+                self.image_height.set(config.get("image_height", "600"))
+                self.module_headline.set(config.get("module_headline", "Premium Quality Product"))
+                self.module_body.set(config.get("module_body", "High-quality product designed for your needs."))
+                if config.get("template_path"):
+                    self.detail_template_path.set(config.get("template_path"))
+        except Exception:
+            pass
 
     def start_matching(self):
         """开始匹配处理"""
@@ -668,6 +882,275 @@ class ImageMatcherApp:
             import traceback
             self.amazon_log(traceback.format_exc())
 
+    def start_detail_upload(self):
+        """开始亚马逊详情图上传 (A+ Content)"""
+        if not self.detail_template_path.get():
+            messagebox.showerror("错误", "请先选择模板文件!")
+            return
+        
+        if not self.asin_input.get().strip():
+            messagebox.showerror("错误", "请先输入ASIN!")
+            return
+            
+        thread = threading.Thread(target=self.process_detail_upload, daemon=True)
+        thread.start()
+    
+    def process_detail_upload(self):
+        """执行亚马逊详情图上传 (A+ Content)"""
+        try:
+            self.detail_progress_var.set("处理中...")
+            self.detail_log("=" * 50)
+            self.detail_log("开始亚马逊详情图上传 (A+ Content)...")
+            
+            template_path = self.detail_template_path.get()
+            asin = self.asin_input.get().strip()
+            detail_cols_str = self.detail_image_columns.get()
+            detail_col_names = [col.strip() for col in detail_cols_str.split(',') if col.strip()]
+            
+            # 获取图片尺寸
+            try:
+                target_width = int(self.image_width.get())
+                target_height = int(self.image_height.get())
+            except ValueError:
+                self.detail_log("❌ 图片尺寸必须为数字")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            module_headline = self.module_headline.get().strip()
+            module_body = self.module_body.get().strip()
+            
+            self.detail_log(f"读取模板文件: {template_path}")
+            self.detail_log(f"使用的ASIN: {asin}")
+            self.detail_log(f"图片尺寸: {target_width}x{target_height}")
+            
+            # 读取Excel
+            df = pd.read_excel(template_path)
+            self.detail_log(f"找到 {len(df)} 行数据")
+            
+            # 检查详情图列
+            existing_detail_cols = [col for col in detail_col_names if col in df.columns]
+            if not existing_detail_cols:
+                self.detail_log("❌ 未找到任何详情图列")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            self.detail_log(f"详情图列: {existing_detail_cols}")
+            
+            # 尝试导入PIL
+            try:
+                from PIL import Image
+                from io import BytesIO
+                HAS_PIL = True
+            except ImportError:
+                HAS_PIL = False
+                self.detail_log("⚠️ 未安装Pillow,将使用原始图片尺寸")
+            
+            # 获取Access Token
+            self.detail_log("🔑 获取Access Token...")
+            try:
+                token = self.amazon_uploader.get_access_token()
+                self.detail_log("✅ Token获取成功")
+            except Exception as e:
+                self.detail_log(f"❌ 获取Token失败: {str(e)}")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            # 处理每一行
+            success_count = 0
+            fail_count = 0
+            total_rows = min(len(df), 1)  # 每次只处理一行(A+ Content每次只能绑定一个ASIN)
+            
+            # 只处理第一行
+            row = df.iloc[0]
+            
+            # 收集所有图片URL
+            image_urls = []
+            for col_name in existing_detail_cols:
+                img_url = str(row[col_name]).strip()
+                if img_url and img_url != 'nan' and img_url != 'None' and img_url.startswith('http'):
+                    image_urls.append(img_url)
+            
+            if not image_urls:
+                self.detail_log("❌ 未找到有效的图片URL")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            self.detail_log(f"找到 {len(image_urls)} 张详情图")
+            
+            # 处理每张图片
+            upload_ids = []
+            for i, img_url in enumerate(image_urls, start=1):
+                self.detail_log(f"\n处理第 {i} 张图片: {img_url[:60]}...")
+                
+                try:
+                    # 下载图片
+                    img_resp = requests.get(img_url, timeout=30)
+                    img_resp.raise_for_status()
+                    
+                    image_binary = img_resp.content
+                    
+                    # 调整尺寸
+                    if HAS_PIL:
+                        try:
+                            img = Image.open(BytesIO(image_binary))
+                            original_size = img.size
+                            # 调整尺寸
+                            img_resized = img.resize((target_width, target_height), Image.LANCZOS)
+                            buffer = BytesIO()
+                            # 判断原图格式
+                            if img_url.lower().endswith('.png'):
+                                img_resized.save(buffer, format="PNG")
+                            else:
+                                img_resized.save(buffer, format="JPEG")
+                            image_binary = buffer.getvalue()
+                            self.detail_log(f"  图片尺寸调整: {original_size} -> {target_width}x{target_height}")
+                        except Exception as e:
+                            self.detail_log(f"  图片调整失败: {str(e)},使用原图")
+                    
+                    # 计算MD5
+                    md5_hash = hashlib.md5(image_binary).digest()
+                    content_md5 = base64.b64encode(md5_hash).decode('utf-8')
+                    
+                    # 确定Content-Type
+                    content_type = "image/jpeg"
+                    if img_url.lower().endswith('.png'):
+                        content_type = "image/png"
+                    
+                    self.detail_log(f"  图片大小: {len(image_binary)} bytes")
+                    
+                    # 创建上传目标
+                    upload_data = self.amazon_uploader.create_upload_destination(token, content_md5, content_type)
+                    upload_id = upload_data.get("uploadDestinationId")
+                    upload_url = upload_data.get("url")
+                    
+                    if not upload_id or not upload_url:
+                        self.detail_log(f"  ❌ 创建上传目标失败")
+                        fail_count += 1
+                        continue
+                    
+                    self.detail_log(f"  ✅ 上传目标创建成功")
+                    
+                    # 上传到S3
+                    self.amazon_uploader.upload_to_s3(upload_url, image_binary, content_type)
+                    self.detail_log(f"  ✅ S3上传成功")
+                    
+                    upload_ids.append({
+                        "upload_id": upload_id,
+                        "width": target_width,
+                        "height": target_height,
+                        "alt_text": f"Product detail image {i}"
+                    })
+                    
+                    success_count += 1
+                    time.sleep(0.5)
+                    
+                except Exception as e:
+                    self.detail_log(f"  ❌ 处理失败: {str(e)}")
+                    fail_count += 1
+            
+            if not upload_ids:
+                self.detail_log("❌ 没有成功上传的图片")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            # 构建A+ Content内容文档
+            self.detail_log("\n📝 构建A+ Content文档...")
+            
+            # 构建模块列表 - 每张图片一个模块
+            content_module_list = []
+            for i, upload_info in enumerate(upload_ids):
+                module = {
+                    "contentModuleType": "STANDARD_HEADER_IMAGE_TEXT",
+                    "standardHeaderImageText": {
+                        "headline": {
+                            "value": module_headline if i == 0 else f"{module_headline} - {i+1}",
+                            "decoratorSet": []
+                        },
+                        "block": {
+                            "image": {
+                                "uploadDestinationId": upload_info["upload_id"],
+                                "imageCropSpecification": {
+                                    "size": {
+                                        "width": {"value": upload_info["width"], "units": "pixels"},
+                                        "height": {"value": upload_info["height"], "units": "pixels"}
+                                    },
+                                    "offset": {
+                                        "x": {"value": 0, "units": "pixels"},
+                                        "y": {"value": 0, "units": "pixels"}
+                                    }
+                                },
+                                "altText": upload_info["alt_text"]
+                            },
+                            "headline": {
+                                "value": f"Image {i+1}",
+                                "decoratorSet": []
+                            },
+                            "body": {
+                                "textList": [
+                                    {
+                                        "value": module_body if i == 0 else f"Product detail view {i+1}",
+                                        "decoratorSet": []
+                                    }
+                                ]
+                            }
+                        }
+                    }
+                }
+                content_module_list.append(module)
+            
+            # 构建content文档
+            content_doc = {
+                "name": f"Product Images for {asin}",
+                "contentType": "EBC",
+                "locale": "en-US",
+                "contentModuleList": content_module_list
+            }
+            
+            # 创建内容文档
+            self.detail_log("📤 创建A+ Content文档...")
+            try:
+                content_key = self.amazon_uploader.create_content_document(token, content_doc)
+                self.detail_log(f"✅ Content文档创建成功,Content Key: {content_key}")
+            except Exception as e:
+                self.detail_log(f"❌ 创建Content文档失败: {str(e)}")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            # 绑定ASIN
+            self.detail_log(f"🔗 绑定ASIN: {asin}...")
+            try:
+                self.amazon_uploader.bind_asin_to_content(token, content_key, asin)
+                self.detail_log(f"✅ ASIN绑定成功")
+            except Exception as e:
+                self.detail_log(f"❌ 绑定ASIN失败: {str(e)}")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            # 提交审核
+            self.detail_log("📋 提交审核...")
+            try:
+                self.amazon_uploader.submit_for_approval(token, content_key)
+                self.detail_log(f"✅ 审核提交成功")
+                self.detail_log(f"⚠️ 审核通常需要 1-7 个工作日")
+            except Exception as e:
+                self.detail_log(f"❌ 提交审核失败: {str(e)}")
+                self.detail_progress_var.set("上传失败")
+                return
+            
+            self.detail_log(f"\n✅ 处理完成!成功: {success_count}, 失败: {fail_count}")
+            self.detail_log(f"   Content Key: {content_key}")
+            self.detail_log(f"   ASIN: {asin}")
+            self.detail_progress_var.set(f"上传完成!成功: {success_count}, 失败: {fail_count}")
+            messagebox.showinfo("完成", f"亚马逊详情图上传完成!\n成功: {success_count}\n失败: {fail_count}\n\nContent Key: {content_key}\nASIN: {asin}\n\n审核通常需要 1-7 个工作日")
+            
+        except Exception as e:
+            error_msg = f"处理出错: {str(e)}"
+            self.detail_log(f"❌ {error_msg}")
+            self.detail_progress_var.set("处理出错")
+            messagebox.showerror("错误", error_msg)
+            import traceback
+            self.detail_log(traceback.format_exc())
+
 
 def main():
     root = tk.Tk()