# -*- coding: utf-8 -*- """ 从 inkreach PostgreSQL 同步指定国家货盘商品数据到 SQLite (spu_sku.db) 支持多国累积同步(保留已存在数据): - sync(plate_ids, country, clear_first) clear_first=True -> 先 DELETE 整库再插入(重置该次目标) clear_first=False -> 仅追加,跳过已存在的 SPU code 与 SKU 组合 国家货盘 plate_id 映射: - 美国 : (2, 3, 4, 5) country="US" - 墨西哥: (6,) country="MX" - 日本 : (7,) country="JP" - 韩国 : (8,) country="KR" - 沙特 : (9,) country="SA" - 巴西 : (10,) country="BR" - 英国 : (11,) country="GB" - 加拿大: (12,) country="CA" - 波兰 : (13,) country="PL" - 西班牙: (14,) country="ES" - 德国 : (15,) country="DE" - 澳洲 : (16,) country="AU" - 意大利: (17,) country="IT" SPU = categories(code 去重) SKU = 颜色 × 尺码 粒度: - code = colors.code 颜色映射编码(脏数据兜底为 款号-颜色的英文名,英文名取自 color_detail_images.color_name_web) - color = colors.name 颜色名 - size = product_extra.product_size JSON 的尺码列(缺 JSON 回退 size_chart) - 肩宽/胸围/衣长/袖长 = product_size JSON 对应列(动态识别表头) - 最长边/次长边/最短边 = packaging_spec JSON "包装尺寸(cm)" 列 长*宽*高 拆分排序 - package_weight = packaging_spec JSON "含包装重量(g)" 列(单位: 克) - price = 该品类 prices 首条价格(颜色尺码粒度无独立价格) - img_url_2~5 = 该品类首颜色 web_sku 的图(seq 2~5) """ import sqlite3 import psycopg2 import re import json PG = dict(host="localhost", port=5432, user="postgres", password="inkreach", dbname="inkreach") SQLITE = r"C:\Users\Admin\Desktop\test模版\design_agent\pod_trend_agent\db\spu_sku.db" # 尺码体系按国家区分:亚洲市场(JP/KR)用亚洲尺码,其余默认 欧美尺码/欧美尺码常规 # (库内约定:除 JP/KR 外所有国家 size_group 一律"欧美尺码",与下方默认一致) SIZE_SYSTEM = { "JP": ("亚洲尺码", "亚洲尺码亚洲常规"), "KR": ("亚洲尺码", "亚洲尺码亚洲常规"), } def clean(v): if v is None: return None s = str(v).strip() return s if s else None def format_pct(v): """成分比例转百分比形式:'100'/'100.0' -> '100%',空值保持 None""" s = clean(v) if s is None: return None try: num = float(s) if num == int(num): return f"{int(num)}%" return f"{num}%" except ValueError: return s # 中文颜色名 -> 英文(color_name_web 缺失时的兜底) ZH_TO_EN = { "黑": "Black", "黑色": "Black", "白": "White", "白色": "White", "灰": "Gray", "灰色": "Gray", "杏": "Apricot", "杏色": "Apricot", "咖色": "Coffee", "咖": "Coffee", "藏青": "Navy", "藏青色": "Navy", "蓝": "Blue", "蓝色": "Blue", "海蓝": "Blue", "海蓝色": "Ocean Blue", "翠绿": "Green", "翠绿色": "Green", "绿": "Green", "绿色": "Green", "紫": "Purple", "紫色": "Purple", "玫红": "Rose", "玫红色": "Rose", "粉": "Pink", "粉色": "Pink", "粉红": "Pink", "红": "Red", "红色": "Red", "黄": "Yellow", "黄色": "Yellow", "金": "Gold", "金色": "Gold", "银": "Silver", "银色": "Silver", "棕": "Brown", "棕色": "Brown", "米": "Beige", "米色": "Beige", "卡其": "Khaki", "卡其色": "Khaki", "橙": "Orange", "橙色": "Orange", } def clean_color_name(name): """清洗颜色名: 'S-3XL黑色' -> '黑色'(去开头尺码前缀) '灰色(S~XXL)' -> '灰色'(去结尾尺码区间括号) '(M~3XL)' -> None(纯尺码无颜色) 保留中文颜色核心部分。""" s = clean(name) if not s: return None # 开头若全是非中文(尺码前缀如 S-3XL / S(4-5)y),取其后中文部分 m = re.match(r"^[^一-鿿]+([一-鿿].*)$", s) if m: s = m.group(1).strip() # 去掉结尾的尺码区间括号,如 (S~XXL)/(M~5XL)/(XS~3XL) s = re.sub(r"[((][^一-鿿]*[))]$", "", s).strip() # 去掉残留的纯尺码/空白字符 s = s.strip(" ()()~  ") return s if s else None def is_clean_code(ccode, catcode): """判断颜色 code 是否规范:必须以 品类code- 开头, 且后缀为纯字母数字(如 BL01 / ESPRESSO),不含中文、~ 或括号。 纯尺码区间(如 JPHM009-(S~XXXL))与多色拼接脏数据会被判为非规范。""" if not ccode or not catcode: return False prefix = catcode + "-" if not ccode.startswith(prefix): return False suffix = ccode[len(prefix):] if not suffix or re.search(r"[一-鿿~(())]", suffix): return False return True def first_color_en(name): """从可能含多色的脏名称中取首个可识别颜色的英文名,否则原样返回。""" if not name: return None for zh, en in ZH_TO_EN.items(): if zh in name: return en return name def extract_weight(name): if not name: return None, None m = re.search(r"(\d+(?:\.\d+)?)\s*(?:G|g|克)", name) if m: return m.group(1), "G" return None, None def parse_size_json(raw): """product_size JSON -> [{size, shoulder, bust, length, sleeve}...]""" if not raw: return [] try: data = json.loads(raw) except Exception: return [] if not isinstance(data, list) or len(data) < 2: return [] header = [c.get("content", "") for c in data[0]] size_i = next((i for i, h in enumerate(header) if "尺码" in h), None) sh_i = next((i for i, h in enumerate(header) if "肩宽" in h), None) bu_i = next((i for i, h in enumerate(header) if "胸围" in h), None) le_i = next((i for i, h in enumerate(header) if "衣长" in h), None) sl_i = next((i for i, h in enumerate(header) if "袖长" in h), None) if size_i is None: return [] rows = [] for r in data[1:]: if not isinstance(r, list): continue def cell(i): if i is None or i >= len(r): return None return clean(r[i].get("content", "") if isinstance(r[i], dict) else r[i]) size = cell(size_i) if not size or size == "尺码": continue rows.append(dict(size=size, shoulder=cell(sh_i), bust=cell(bu_i), length=cell(le_i), sleeve=cell(sl_i))) return rows def parse_pkg_json(raw): """packaging_spec JSON -> {size: (longest, second, shortest)} 按 cm 列拆分排序""" if not raw: return {} try: data = json.loads(raw) except Exception: return {} if not isinstance(data, list) or len(data) < 2: return {} header = data[0] size_i = next((i for i, c in enumerate(header) if "尺码" in c.get("content", "")), None) cm_i = next((i for i, c in enumerate(header) if "包装尺寸" in c.get("content", "") and "cm" in c.get("content", "")), None) if size_i is None or cm_i is None: return {} out = {} for r in data[1:]: if not isinstance(r, list) or cm_i >= len(r): continue size = clean(r[size_i].get("content", "")) if isinstance(r[size_i], dict) else clean(r[size_i]) val = clean(r[cm_i].get("content", "")) if isinstance(r[cm_i], dict) else clean(r[cm_i]) if not size or not val: continue parts = re.findall(r"\d+(?:\.\d+)?", val) if len(parts) >= 3: nums = sorted((float(p) for p in parts[:3]), reverse=True) out[size] = (nums[0], nums[1], nums[2]) return out def parse_weight_json(raw): """packaging_spec JSON -> {size: 含包装重量(g)},取"含包装重量(g)"列""" if not raw: return {} try: data = json.loads(raw) except Exception: return {} if not isinstance(data, list) or len(data) < 2: return {} header = data[0] size_i = next((i for i, c in enumerate(header) if "尺码" in c.get("content", "")), None) w_i = next((i for i, c in enumerate(header) if "重量" in c.get("content", "") and "(g)" in c.get("content", "")), None) if size_i is None or w_i is None: return {} out = {} for r in data[1:]: if not isinstance(r, list) or w_i >= len(r): continue size = clean(r[size_i].get("content", "")) if isinstance(r[size_i], dict) else clean(r[size_i]) val = clean(r[w_i].get("content", "")) if isinstance(r[w_i], dict) else clean(r[w_i]) if not size or not val: continue out[size] = val return out def sync(plate_ids, country, clear_first=True): print(f"\n==== 同步 {country} (plate_ids={plate_ids}, clear_first={clear_first}) ====") pg = psycopg2.connect(**PG) pg.autocommit = True pc = pg.cursor() # ---------- SPU 数据 ---------- pc.execute(""" SELECT c.id, c.plate_id, c.code, c.name, c.sub_category, c.fabric, c.composition, p.display_name FROM categories c LEFT JOIN plates p ON c.plate_id = p.id WHERE c.plate_id IN %s AND c.code IS NOT NULL AND c.code <> '' ORDER BY c.plate_id, c.id """, (plate_ids,)) cat_rows = pc.fetchall() spu_map = {} for cid, plate_id, code, name, sub, fabric, comp, display in cat_rows: if code in spu_map: prev = spu_map[code] prev_score = (0 if prev["sub"] == "组合款" else 1, -prev["plate_id"], -prev["cid"]) new_score = (0 if sub == "组合款" else 1, -plate_id, -cid) if new_score > prev_score: spu_map[code] = dict(cid=cid, plate_id=plate_id, name=name, sub=sub, fabric=fabric, comp_raw=comp, display=display) else: spu_map[code] = dict(cid=cid, plate_id=plate_id, name=name, sub=sub, fabric=fabric, comp_raw=comp, display=display) pc.execute(""" SELECT category_id, fabric, comp1, comp1_pct, comp2, comp2_pct, comp3, comp3_pct FROM composition """) comp_by_cat = {} for cid, fabric, c1, p1, c2, p2, c3, p3 in pc.fetchall(): comp_by_cat[cid] = dict(c1=c1, p1=p1, c2=c2, p2=p2, c3=c3, p3=p3) pc.execute(""" SELECT code, english_name, washing_instructions, design_explanation, texture, product_size, packaging_spec FROM product_extra """) extra_by_code = {} for code, en, wash, design, texture, psize, pkg in pc.fetchall(): extra_by_code[code] = dict(en=en, wash=wash, design=design, texture=texture, psize=psize, pkg=pkg) # ---------- SKU 数据 ---------- # 颜色 pc.execute(""" SELECT id, category_id, name, code, size_range FROM colors WHERE category_id IN (SELECT id FROM categories WHERE plate_id IN %s) ORDER BY category_id, seq """, (plate_ids,)) colors_by_cat = {} for col_id, cid, name, code, sr in pc.fetchall(): colors_by_cat.setdefault(cid, []).append(dict(id=col_id, name=name, code=code, sr=sr)) # 颜色英文名(color_detail_images.color_name_web,按 color_id) pc.execute(""" SELECT DISTINCT color_id, color_name_web FROM color_detail_images WHERE color_id IN ( SELECT id FROM colors WHERE category_id IN (SELECT id FROM categories WHERE plate_id IN %s) ) """, (plate_ids,)) color_en_by_id = {} for col_id, en in pc.fetchall(): en = clean(en) if en: color_en_by_id[col_id] = en # size_chart 回退 pc.execute(f""" SELECT category_id, size, shoulder, bust, length, sleeve FROM size_chart WHERE category_id IN (SELECT id FROM categories WHERE plate_id IN %s) ORDER BY category_id, seq """, (plate_ids,)) sizechart_by_cat = {} for cid, size, sh, bu, le, sl in pc.fetchall(): sizechart_by_cat.setdefault(cid, []).append(dict(size=size, shoulder=sh, bust=bu, length=le, sleeve=sl)) # prices(首条价格) pc.execute(""" SELECT code, price FROM prices WHERE code IN (SELECT DISTINCT code FROM categories WHERE plate_id IN %s) ORDER BY code, web_product_id """, (plate_ids,)) price_by_code = {} for code, price in pc.fetchall(): if code not in price_by_code: price_by_code[code] = price # 图片(按 code,取首个 web_sku 的图) pc.execute(""" SELECT cd.code, cd.web_sku, cd.seq, cd.image_url FROM color_detail_images cd WHERE cd.code IN (SELECT DISTINCT code FROM categories WHERE plate_id IN %s) ORDER BY cd.code, cd.web_sku, cd.seq """, (plate_ids,)) imgs_by_code = {} for code, wsku, seq, url in pc.fetchall(): imgs_by_code.setdefault(code, []).append((wsku, seq, url)) pg.close() # ---------- 写入 SQLite ---------- db = sqlite3.connect(SQLITE) cur = db.cursor() if clear_first: cur.execute("DELETE FROM SKU") cur.execute("DELETE FROM SPU") db.commit() print("[clear] 已清空 SPU/SKU 旧数据") else: print("[append] 保留现有数据,仅追加新国家") # append 模式:收集已存在的 SPU code 与 SKU 组合,避免重复插入 existing_spu_codes = set() existing_sku_keys = set() if not clear_first: existing_spu_codes = {r[0] for r in cur.execute("SELECT code FROM SPU")} existing_sku_keys = {(r[0], r[1], r[2]) for r in cur.execute("SELECT spu_id, code, size FROM SKU")} print(f"[append] 已有 SPU {len(existing_spu_codes)} 条, SKU {len(existing_sku_keys)} 条") # SPU spu_id_by_code = {} n_spu = 0 n_spu_skip = 0 for code, s in sorted(spu_map.items()): # append 模式:若该 SPU code 已存在则跳过(保留首次写入的数据) if code in existing_spu_codes: spu_id = cur.execute("SELECT id FROM SPU WHERE code=?", (code,)).fetchone()[0] spu_id_by_code[code] = spu_id n_spu_skip += 1 continue comp = comp_by_cat.get(s["cid"], {}) or {} weight, wunit = extract_weight(s["name"]) # 业务模板固定值(按用户要求,不随 PG 数据变化) fixed = dict( pattern="印花", details="无", collar_style="圆领", style="休闲", care_Instructions="数码印花类可机洗且不可干洗", fabric="微弹", target_audience="成人", season="四季", is_transparent="否", layout="常规", weaving_method="针织(含钩织、毛织面料)", printing_type="定位印花", fabric_texture_1="光面", fabric_weight_unit_1="g/㎡", lining_texture="无里料/无内衬", mark="1", ) cur.execute(""" INSERT INTO SPU ( code, material, component_1, component_2, component_3, component_proportion_1, component_proportion_2, component_proportion_3, pattern, details, collar_style, style, care_Instructions, fabric, target_audience, season, is_transparent, layout, weaving_method, printing_type, fabric_texture_1, fabric_weight_1, fabric_weight_unit_1, lining_texture, country, mark ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( code, clean(s["fabric"]), clean(comp.get("c1")), clean(comp.get("c2")), clean(comp.get("c3")), format_pct(comp.get("p1")), format_pct(comp.get("p2")), format_pct(comp.get("p3")), fixed["pattern"], fixed["details"], fixed["collar_style"], fixed["style"], fixed["care_Instructions"], fixed["fabric"], fixed["target_audience"], fixed["season"], fixed["is_transparent"], fixed["layout"], fixed["weaving_method"], fixed["printing_type"], fixed["fabric_texture_1"], weight, fixed["fabric_weight_unit_1"], fixed["lining_texture"], country, fixed["mark"], )) spu_id_by_code[code] = cur.lastrowid n_spu += 1 # SKU(颜色 × 尺码) n_sku = 0 n_sku_skip = 0 detail = dict(no_color=0, no_size=0, no_pkg=0, no_img=0, color_sku=0) for code, s in sorted(spu_map.items()): spu_id = spu_id_by_code[code] cat_id = s["cid"] extra = extra_by_code.get(code, {}) # 颜色列表 color_list = colors_by_cat.get(cat_id, []) if not color_list: color_list = [dict(name=None, code=code, sr=None)] # 兜底 detail["no_color"] += 1 # 品类尺码范围(取首个非空) sr = next((c["sr"] for c in color_list if c["sr"]), None) if sr is None: sr = None # 尺码表:product_size JSON 优先,回退 size_chart size_rows = parse_size_json(extra.get("psize")) if not size_rows: size_rows = sizechart_by_cat.get(cat_id, []) if not size_rows: detail["no_size"] += 1 pkg_map = parse_pkg_json(extra.get("pkg")) weight_map = parse_weight_json(extra.get("pkg")) # 归一化兜底:尺码列可能带英寸后缀(如 '30*40/76.2*101.6' vs '30*40') pkg_map_norm = {k.split("/")[0]: v for k, v in pkg_map.items()} weight_map_norm = {k.split("/")[0]: v for k, v in weight_map.items()} def lookup_pkg(size): v = pkg_map.get(size) or pkg_map_norm.get(size.split("/")[0]) if v is None and ("one" in size.lower() or "均码" in size): v = pkg_map.get("均码") or pkg_map.get("Onesize") or pkg_map.get("OneSize") return v def lookup_weight(size): v = weight_map.get(size) or weight_map_norm.get(size.split("/")[0]) if v is None and ("one" in size.lower() or "均码" in size): v = weight_map.get("均码") or weight_map.get("Onesize") or weight_map.get("OneSize") return v # 图片 imgs = imgs_by_code.get(code, []) if imgs: first_sku = imgs[0][0] urls = [u for ws, _, u in imgs if ws == first_sku] else: urls = [] if not urls: detail["no_img"] += 1 img2 = urls[1] if len(urls) > 1 else None img3 = urls[2] if len(urls) > 2 else None img4 = urls[3] if len(urls) > 3 else None img5 = urls[4] if len(urls) > 4 else None price = price_by_code.get(code) # 尺码体系按国家区分:亚洲市场(JP/KR)=亚洲尺码,其余默认 欧美尺码/欧美尺码常规 sgroup, stype = SIZE_SYSTEM.get(country, ("欧美尺码", "欧美尺码常规")) for col in color_list: raw_code = col["code"] raw_name = col["name"] col_id = col["id"] cname = clean_color_name(raw_name) # 清洗颜色名(去尺码前缀/区间) # 颜色英文名:优先 PG color_name_web,否则用中文名查兜底字典/取首个颜色 en = None if cname: en = color_en_by_id.get(col_id) or first_color_en(cname) # 命名规则: # 规范 code(品类code-英文/缩写,无中文,如 JPHM009-BL01 / JPTM007-ESPRESSO) # -> 直接使用 # 缺失/脏 code(含中文或尺码区间)-> 款号(品类code)-颜色的英文名 if not is_clean_code(raw_code, code): ccode = f"{code}-{en}" if en else code else: ccode = raw_code for srow in size_rows: size = clean(srow["size"]) if not size: continue # append 模式:跳过已存在的 SKU 组合 if (spu_id, ccode, size) in existing_sku_keys: n_sku_skip += 1 continue pkg = lookup_pkg(size) pkg_weight = lookup_weight(size) cur.execute(""" INSERT INTO SKU ( spu_id, code, price, color, size, size_group, size_type, shoulder_width, bust, clothing_length, sleeve_length, longest_side, secondary_long_side, shortest_side, package_weight, img_url_2, img_url_3, img_url_4, img_url_5 ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, ( spu_id, ccode, price, cname, size, sgroup, stype, clean(srow.get("shoulder")), clean(srow.get("bust")), clean(srow.get("length")), clean(srow.get("sleeve")), str(pkg[0]) if pkg else None, str(pkg[1]) if pkg else None, str(pkg[2]) if pkg else None, pkg_weight, img2, img3, img4, img5, )) n_sku += 1 db.commit() # ---------- 验证 ---------- print(f" 本次新增 SPU: {n_spu} (跳过已存在 {n_spu_skip})") print(f" 本次新增 SKU: {n_sku} (跳过已存在 {n_sku_skip}) (颜色×尺码展开)") print(f" 无颜色品类: {detail['no_color']}, 无尺码品类: {detail['no_size']}, 无图品类: {detail['no_img']}") print(f" 库内 SPU 总数: {cur.execute('SELECT COUNT(*) FROM SPU').fetchone()[0]}") print(f" 库内 SKU 总数: {cur.execute('SELECT COUNT(*) FROM SKU').fetchone()[0]}") print(f" country 分布: {cur.execute('SELECT country, COUNT(*) FROM SPU GROUP BY country').fetchall()}") db.close() if __name__ == "__main__": # 追加模式(clear_first=False):保留现有 US/MX/JP/GB 及手动修改,仅补齐其余 9 国 # 任何国家若已存在会被跳过(SPU code / SKU 组合去重),不会覆盖手动编辑 sync((2, 3, 4, 5), "US", clear_first=False) sync((6,), "MX", clear_first=False) sync((7,), "JP", clear_first=False) sync((11,), "GB", clear_first=False) # 新增国家 sync((8,), "KR", clear_first=False) sync((9,), "SA", clear_first=False) sync((10,), "BR", clear_first=False) sync((12,), "CA", clear_first=False) sync((13,), "PL", clear_first=False) sync((14,), "ES", clear_first=False) sync((15,), "DE", clear_first=False) sync((16,), "AU", clear_first=False) sync((17,), "IT", clear_first=False) print("\n✅ 同步完成:13 国数据已包含(US/MX/JP/GB 保留,KR/SA/BR/CA/PL/ES/DE/AU/IT 已追加)")