commit f493bde8a908878ee824970078aa74e3ae439174 Author: 任汉熙 <3218485270@qq.com> Date: Sat Aug 22 14:14:01 2026 +0800 POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底 - 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fdb604 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# 构建产物 +dist_*/ +build*/ +build_clean*/ +*.spec.bak + +# Python +.venv/ +__pycache__/ +*.pyc + +# 运行产物(生成物不入库,可重新生成) +output/ +logs/ +*.log + +# 缓存(可重新采集/生成) +.cache/ + +# 系统 +.DS_Store +Thumbs.db diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/PODTrendAgent.spec b/PODTrendAgent.spec new file mode 100644 index 0000000..3e5fa08 --- /dev/null +++ b/PODTrendAgent.spec @@ -0,0 +1,41 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['ui_app.py'], + pathex=[], + binaries=[], + datas=[('config.yaml', '.'), ('configs', 'configs'), ('prompts', 'prompts'), + ('templates', 'templates'), ('basemap', 'basemap'), + ('material_library', 'material_library')], + # 注意:db/ 不打进 exe —— 运行时常读 exe 旁 db/spu_sku.db(用户可随时替换取最新) + hiddenimports=['pytrends', 'pytrends.request', 'PIL', 'openpyxl'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='PODTrendAgent', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..039bf32 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# POD 热点抓取 Agent(LangGraph 工程化版) + +自动抓取海外各国(US / GB / JP / AU)实时热点 + 风格趋势,过滤侵权风险,产出可直接用于 +AI 生图的印花设计提示词。本版本用 **LangGraph** 重写,强调**工程化、节点兜底、可插拔**。 + +## 架构:LangGraph 状态图 + +``` +START → seed → fetch → filter → score → screen → prompt_build → compose → END +``` + +| 节点 | 职责 | 可插拔点 / 兜底 | +|---|---|---| +| `seed` | 动态生成种子词(trending 派生 + 历史热点 + 月份/节日 → LLM) | 策略可插拔(`graph/seeds/`);LLM 失败回退静态种子 | +| `fetch` | 抓取热点(多数据源汇总) | 数据源可插拔(`graph/sources/`);单源失败不影响其它源 | +| `filter` | 黑名单 + 真实人物 + 设计相关性三级过滤 | 各级独立 try,单级失败不影响其它级 | +| `score` | 归一化 + 跨源融合 + 综合分 | 纯逻辑,空数据返回空 | +| `screen` | LLM 合规筛查 + 结构化四要素 | LLM 后端可插拔(`graph/llms/`);调用失败降级 Mock | +| `prompt_build` | 用固定模板装配 image/wearable/composite 提示词 | 四要素缺失时动态推导兜底 | +| `compose` | 写 `output//` 产物;可选印图 | 图像后端可插拔(`graph/backends/`);无底图只导出 | + +**每个节点都用 `graph/validate.with_fallback` 包裹**:任何未预料异常都被捕获、记入 +`state['errors']`、返回最小更新,整图继续往下走,单点故障不中断流水线。 + +## 目录结构 + +``` +pod_trend_agent/ +├── cli.py # 命令行入口(替代旧 run/screen/compose) +├── config.yaml # 全局配置(数据源、权重、黑名单、LLM、模板、compose) +├── configs/countries/ # 各国专属数据源种子(US|GB|JP|AU.yaml) +├── prompts// # ★ 每个国家不同的提示词单独文件夹 +│ ├── system_prompt.md # 该国 LLM 系统提示(补充段,叠加到默认规则) +│ └── aesthetics.yaml # 审美 hint + 风格-配色 extra 规则 + 额外黑名单 +├── graph/ +│ ├── state.py # AgentState(共享状态) +│ ├── validate.py # with_fallback 兜底 + 数据校验 +│ ├── loader.py # 配置/提示词加载与合并 +│ ├── scoring.py # 归一化/融合/过滤(纯逻辑) +│ ├── style_rules.py # 动态风格/配色推导 +│ ├── templates.py # 固定提示词模板(按国家区分,30×38cm 上限,文字敏感规则) +│ ├── classify.py # 热点分类 +│ ├── sources/ # 数据源可插拔(google_trends / pinterest + 注册表) +│ ├── llms/ # LLM 后端可插拔(mock / openai_compat + 注册表) +│ ├── backends/ # 图像后端可插拔(openai) +│ ├── seeds/ # 种子词策略可插拔(static / dynamic + holidays 月份节日) +│ ├── nodes/ # 7 个流水线节点(含 seed) +│ └── agent.py # 构建并编译 StateGraph,run_country() +└── output// # ★ 每个国家的产物独立文件夹 + ├── design_briefs.json # 含 国家/热点词/类别/风险/设计稿提示词/完整印图提示词 + ├── design_briefs.md + ├── composite_prompts.json/md # 封装提示词包(印到底图用) + └── report.md # 各阶段统计 + 兜底错误记录 +``` + +## 可插拔指南 + +- **加数据源**:在 `graph/sources/` 新建类继承 `DataSource` 实现 `fetch()`,在 + `graph/sources/__init__.py` 的 `SOURCES` 登记;在 `config.yaml` 的 `sources:` 加入。 +- **加 LLM 后端**:在 `graph/llms/` 新建类继承 `LLMBackend` 实现 `screen()`,在 + `LLM_BACKENDS` 登记;`llm_screen.provider` 选择(openai/deepseek/qwen/moonshot 走 openai_compat)。 +- **改种子词策略**:`seed_provider` 选 `static`(仅 yaml)/ `mock`(规则生成,零成本)/ `openai_compat`(真 LLM)。 + 动态策略收集 trending 派生 + 上轮 safe 历史热点 + 月份/临近节日上下文,由 LLM 后端生成种子词, + 与 yaml 静态种子合并后注入 `style.seeds` / `related.seed_keywords`(`config.yaml` 的 `seed_provider_cfg` 限量)。 +- **加国家**:建 `configs/countries/.yaml`(种子词)与 `prompts//`(提示词), + 在 `config.yaml` 的 `countries:` 加入即可,无需改代码。 +- **加图像后端**:在 `graph/backends/` 实现 `ImageBackend`,`compose.backend` 配置后 + 用 `cli.py -c <国家> --base 底图.png` 印图。 + +## 用法 + +```bash +# 跑全部国家(默认 US GB JP AU) +python cli.py + +# 只跑英国,Mock 兜底 LLM +python cli.py -c GB --provider mock + +# 跑美国 + 真实 LLM(provider 已在 config.llm_screen 配好时可不传) +python cli.py -c US + +# 提供平铺衣服底图,把设计印上去(需 config.compose.backend + api_key) +python cli.py -c US --base path/to/flatlay.png +``` + +## 桌面 UI(Tkinter 轻量版) + +```bash +# 开发模式直接跑 +python ui_app.py + +# 无 GUI 自检(验证环境/核心链路,结果写入运行根 self_test_result.txt) +python ui_app.py --self-test + +# 打包 exe(单文件、无控制台;数据文件打进 _MEIPASS,output/.cache 写在 exe 旁) +.venv/Scripts/pyinstaller -F -w --name PODTrendAgent \ + --add-data "config.yaml;." --add-data "configs;configs" --add-data "prompts;prompts" \ + --hidden-import pytrends --hidden-import pytrends.request ui_app.py +# 产物:dist/PODTrendAgent.exe +``` + +UI 功能:国家多选、LLM 后端选择(mock/static/openai_compat...)、种子上限、**SPU/颜色选品**、运行(后台线程 + 实时日志)、 +结果表格(国家/热点/类别/风险/分数,双击看完整提示词)、打开产物目录。 +打包后首次运行自动在 exe 旁创建 `output/` 与 `.cache/`,并生成一份**可编辑的默认配置** +(`config.yaml` / `configs/` / `prompts/`);exe 运行时**优先读 exe 旁这份配置**(改了就生效), +exe 内置的作为兜底默认。想恢复默认:删掉 exe 旁对应文件即可。 + +## 产品图生成(product) + +热点提示词 → SPU/颜色选品 → basemap 底图 → 印花图 → 模特试穿合成图,产物在 `output//product/`。 + +```bash +# 查看 db 可选款号 / 某款颜色 +python cli.py --list-spus +python cli.py --list-colors DG004 + +# 指定款号+颜色跑流水线(product.backend=mock 占位 / openai 真生图需 compose.api_key) +python cli.py -c GB --spu DG004 --sku DG004-BL01 +``` + +- 数据关系:`SPU.code`=款号,`SKU.code`=`款号-颜色编码`(如 DG004-BL01);底图在 `basemap/<款号>//`,模特图在 `material_library/<品类>/`。 +- 缺底图/模特图时对应步骤自动跳过并提示;`product.backend` 留空则仅存底图。 +- **商品上传模板自动导出**:生成产品图后,从 db 读该 SPU/SKU 信息(材质/成分/图案/领型/面料/克重/印花类型/尺码表/边长/包装重量…),经 `templates/template_router.py` 的路由函数填入商品上传模板, + 输出 `_已填写.xlsx` 到 `output/<国家>/product/`。模板已自包含在项目 `templates/`(`config.product.template_path`,可改为自由上传)。 + - 数据从模板第 6 行(数据区空行)开始填,SPU 与各尺码 SKU 紧邻; + - SKC货号:SPU 行=SPU.code、SKU 行=SKU.code;SKU货号暂不填; + - 规格类型2 = `{size}*{color}`;币种=CNY;发货仓1~N 取模板顶头按「、」分隔,库存均 200; + - 商品轮播图列名中/英/日变体均可模糊匹配;SPU 行轮播图1=生成首图,SKU 行轮播图2~5=db `img_url_2~5`(无则回退生成图); + - 模板文件被占用(打开中)时自动换名 `_已填写_N.xlsx`,不中断导出; + - **多颜色 + 模式**:`sku_code` 支持逗号分隔多颜色(如 `DG015-VT01,DG015-DARK HEATHER`);`spu_per_color`(true=每颜色一个 SPU 块 / false=单 SPU 挂多颜色变体);CLI 用 `--single-spu` 切单 SPU 模式,UI 用「颜色多选 + 模板模式」。 +- UI 打开时**默认加载勾选国家的热点缓存**(`output/<国家>/design_briefs.json`)直接展示,点「运行」刷新最新。 + +## 数据流要点 + +0. `seed`(动态种子词):收集 trending 派生(去噪 + 人名 + 风险护栏过滤)、上轮 safe 历史热点、 + 当前月份/临近节日,由 LLM 后端(mock 规则 / 真 LLM)生成动态种子词,与 yaml 静态种子合并注入抓取配置。 +1. `fetch` 抓 `gt_trending`(国家 RSS 实时榜)+ `gt_style`/`gt_related`(风格/行业种子关联词,种子来自 seed 节点)。 +2. `filter` 三级过滤:合规黑名单(全局 + 各国 `extra_blacklist`)→ 真实人物 → 泛新闻/科技/赛事词。 +3. `score` 按 (source,kind) 分组 min-max 归一化、跨源融合加权重。 +4. `screen` LLM 合规筛查,产出 risk_level / 四要素;`keep_review=false` 时丢弃待复核。 +5. `prompt_build` 用四要素 + 固定模板装配 image/wearable/composite 提示词(结构一致、可复用 img2img)。 +6. `product`(可选)SPU/颜色选品 → basemap 底图 → 印花图 → 模特试穿合成(`output//product/`)。 +7. `compose` 写各国 `output//`;给底图则调图像后端印图。 + +> 合规红线(最重要):版权/商标/真实人物/敏感内容一律拦截或降级;二创/重绘仍有风险, +> 拿不准进"待复核"。各国侵权盲区不同(如英国 WWE/游戏/乐队、日本动漫 IP),放在 +> `prompts//aesthetics.yaml` 的 `extra_blacklist` 按国家隔离。 diff --git a/basemap/DG004/DG004-BL01/a28d0341425b716f4e88c5cabad1daa5.jpg b/basemap/DG004/DG004-BL01/a28d0341425b716f4e88c5cabad1daa5.jpg new file mode 100644 index 0000000..75f5553 Binary files /dev/null and b/basemap/DG004/DG004-BL01/a28d0341425b716f4e88c5cabad1daa5.jpg differ diff --git a/basemap/GBTM013/GBTM013-BL01/C00A6682.jpg b/basemap/GBTM013/GBTM013-BL01/C00A6682.jpg new file mode 100644 index 0000000..a82449a Binary files /dev/null and b/basemap/GBTM013/GBTM013-BL01/C00A6682.jpg differ diff --git a/basemap/GBTM013/GBTM013-CF01/C00A3948.jpg b/basemap/GBTM013/GBTM013-CF01/C00A3948.jpg new file mode 100644 index 0000000..6df5830 Binary files /dev/null and b/basemap/GBTM013/GBTM013-CF01/C00A3948.jpg differ diff --git a/basemap/GBTM013/GBTM013-GR02/C00A6668.jpg b/basemap/GBTM013/GBTM013-GR02/C00A6668.jpg new file mode 100644 index 0000000..9797ee3 Binary files /dev/null and b/basemap/GBTM013/GBTM013-GR02/C00A6668.jpg differ diff --git a/basemap/JPTM001/JPTM001-AP01/Zhao 0704-1230-F new.jpg b/basemap/JPTM001/JPTM001-AP01/Zhao 0704-1230-F new.jpg new file mode 100644 index 0000000..f928899 Binary files /dev/null and b/basemap/JPTM001/JPTM001-AP01/Zhao 0704-1230-F new.jpg differ diff --git a/basemap/JPTM001/JPTM001-BE01/平铺正面.jpg b/basemap/JPTM001/JPTM001-BE01/平铺正面.jpg new file mode 100644 index 0000000..51205c8 Binary files /dev/null and b/basemap/JPTM001/JPTM001-BE01/平铺正面.jpg differ diff --git a/basemap/JPTM001/JPTM001-BL01/C00A2632.jpg b/basemap/JPTM001/JPTM001-BL01/C00A2632.jpg new file mode 100644 index 0000000..eda210b Binary files /dev/null and b/basemap/JPTM001/JPTM001-BL01/C00A2632.jpg differ diff --git a/basemap/JPTM001/JPTM001-GR02/平铺正面.jpg b/basemap/JPTM001/JPTM001-GR02/平铺正面.jpg new file mode 100644 index 0000000..b848a53 Binary files /dev/null and b/basemap/JPTM001/JPTM001-GR02/平铺正面.jpg differ diff --git a/basemap/JPTM001/JPTM001-PK01/平铺正面.jpg b/basemap/JPTM001/JPTM001-PK01/平铺正面.jpg new file mode 100644 index 0000000..4c8ba36 Binary files /dev/null and b/basemap/JPTM001/JPTM001-PK01/平铺正面.jpg differ diff --git a/basemap/JPTM001/JPTM001-WH01/C00A2614.jpg b/basemap/JPTM001/JPTM001-WH01/C00A2614.jpg new file mode 100644 index 0000000..ff3b8e7 Binary files /dev/null and b/basemap/JPTM001/JPTM001-WH01/C00A2614.jpg differ diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..1c1bbf2 --- /dev/null +++ b/cli.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +"""POD 热点抓取 Agent —— LangGraph 工程化版命令行入口。 + +用法示例: + # 跑全部国家(默认 US GB JP AU) + python cli.py + + # 只跑英国,使用 Mock 兜底 LLM + python cli.py -c GB --provider mock + + # 跑美国并用真实 LLM(provider 在 config.llm_screen 已配好时也可不传) + python cli.py -c US + + # 提供平铺衣服底图,把设计印上去(需 config.compose.backend + api_key) + python cli.py -c US --base path/to/flatlay.png + +产物按国家落在 output//(design_briefs.json/md、composite_prompts.json/md、report.md)。 +每个国家的提示词规则在 prompts//(system_prompt.md + aesthetics.yaml)。 +""" +import argparse +import sys +from pathlib import Path + +# 把项目根加入 sys.path,确保 `import graph` 可用 +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +import yaml # noqa: E402 + +from graph.agent import run_country # noqa: E402 + + +def main(): + parser = argparse.ArgumentParser(description="POD 热点抓取 Agent (LangGraph)") + parser.add_argument("-c", "--countries", nargs="*", default=None, + help="国家代码(US GB JP AU),可多个;默认取 config.countries") + parser.add_argument("--provider", default=None, + help="覆盖 LLM 后端:mock / openai_compat / openai / deepseek / qwen / moonshot") + parser.add_argument("--base", default=None, + help="平铺衣服底图路径;提供则尝试印图(需 config.compose 配置图像后端)") + parser.add_argument("--config", default="config.yaml", help="全局配置文件路径") + # —— 产品图生成(product 节点)—— + parser.add_argument("--spu", default=None, help="选品款号(如 DG004;留空自动选第一个有底图的)") + parser.add_argument("--sku", default=None, help="颜色编码(如 DG004-BL01;多个用逗号分隔,如 DG015-VT01,DG015-DARK HEATHER;留空自动选)") + parser.add_argument("--single-spu", action="store_true", help="模板导出:单 SPU 下挂多颜色变体(默认每颜色一个 SPU 块)") + parser.add_argument("--product-backend", default=None, help="产品图像后端:openai / mock") + parser.add_argument("--list-spus", action="store_true", help="列出 db 可选 SPU 并退出") + parser.add_argument("--list-colors", default=None, metavar="SPU", help="列出某款号颜色并退出") + args = parser.parse_args() + + cfg_path = ROOT / args.config + if not cfg_path.exists(): + print(f"配置文件不存在: {cfg_path}") + sys.exit(1) + cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + + # 产品选品查询(--list-spus / --list-colors) + pcfg0 = cfg.get("product") or {} + dbp = Path(pcfg0.get("db_path", "db/spu_sku.db")) + if not dbp.is_absolute(): + dbp = ROOT / dbp + if args.list_spus: + from graph.product import list_spus + print("=== db 可选 SPU(款号 / 品类 / 印花类型 / 国家)===") + for s in list_spus(dbp): + print(f" {s['code']:10s} {s.get('style') or '':8s} {s.get('printing_type') or '':12s} {s.get('country') or ''}") + sys.exit(0) + if args.list_colors: + from graph.product import list_colors + print(f"=== {args.list_colors} 可选颜色(SKU.code / 色名)===") + for c in list_colors(dbp, args.list_colors): + print(f" {c['sku_code']:16s} {c['color']}") + sys.exit(0) + + if args.provider: + cfg.setdefault("llm_screen", {})["provider"] = args.provider + if args.spu or args.sku or args.product_backend or args.single_spu: + p = cfg.setdefault("product", {}) + if args.spu: + p["spu_code"] = args.spu + if args.sku: + p["sku_code"] = args.sku + if args.product_backend: + p["backend"] = args.product_backend + if args.single_spu: + p["spu_per_color"] = False + + countries = args.countries or cfg.get("countries", ["US", "GB", "JP", "AU"]) + base_image = args.base + + overall_errors = 0 + for c in countries: + print(f"\n===== 开始处理 {c} =====") + try: + res = run_country(c, cfg, ROOT, base_image=base_image) + except Exception as e: # noqa: BLE001 + print(f"[{c}] 运行失败: {e}") + overall_errors += 1 + continue + + stats = res.get("stats", {}) + f = stats.get("fetch", {}) + fl = stats.get("filter", {}) + sc = stats.get("screen", {}) + pr = stats.get("prompt", {}) + print(f"[{c}] 抓取原始 {f.get('raw_rows')} 行 | 过滤后保留 {fl.get('kept')} | " + f"筛选保留 {sc.get('kept')} | 设计简报 {pr.get('briefs')} 条") + errs = res.get("errors") or [] + if errs: + overall_errors += len(errs) + print(f"[{c}] 节点兜底捕获 {len(errs)} 条错误:") + for e in errs: + print(f" - [{e.get('node')}] {e.get('type')}: {e.get('message')}") + print(f"[{c}] 产物目录: output/{c}/") + + print(f"\n全部完成。累计兜底错误 {overall_errors} 条(不影响产出)。") + + +if __name__ == "__main__": + main() diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..b207019 --- /dev/null +++ b/config.yaml @@ -0,0 +1,180 @@ +# ===== POD 热点抓取 Agent 配置(LangGraph 工程化版)===== + +# 目标国家(Google Trends 地区代码:US / GB / JP / AU / MX ...) +countries: + - US + - GB + - JP + - AU + - MX + +# —— 可插拔数据源 —— +# 在 graph/sources/ 注册表里登记;这里列出要启用的。 +# 每个国家的具体种子词/limit 写在 configs/countries/.yaml。 +sources: + - google_trends + # - pinterest # 需要 Pinterest 商业 token + API Review 通过后才启用 + +# —— 动态种子词(seed 节点,在 fetch 之前运行)—— +# 收集「trending 派生 + 上一轮 safe 历史热点 + 月份/节日」上下文, +# 由 LLM 后端生成动态种子词,与 yaml 静态种子合并后注入 style.seeds / related.seed_keywords。 +# 只适配 OpenAI 兼容协议:openai(真 LLM,自定义 model/base_url/api_key 见 llm_screen)/ mock(规则生成,零 API 成本)。 +# static: 仅用 yaml 写死种子,零动态(保留兼容,UI 未列出)。 +seed_provider: mock +seed_provider_cfg: + max_style_seeds: 12 # style 种子总数上限(动态分配:固定种子优先 → 节日/月份主题 → 动态补位) + max_related_seeds: 12 # related 种子总数上限(同上) + trending_context_limit: 15 + history_limit: 20 + +# —— Pinterest(官方 API v5,可选;默认关闭)—— +pinterest: + enabled: false + access_token: "" # 填 Bearer Token,或用环境变量 PINTEREST_ACCESS_TOKEN + search_keywords: + - "trending fashion" + - "streetwear" + - "cottagecore" + page_size: 25 + +# 跨源融合权重(按 source 标签,无需和为 1) +# 已下调 gt_trending(泛国家热点只作微弱信号),主力偏向 style+related(可印花型词)。 +weights: + gt_trending: 0.15 + gt_style: 0.55 + gt_related: 0.30 + pinterest: 0.0 + +# 合规黑名单(全局,生效于所有国家;各国专属侵权盲区写在 prompts//aesthetics.yaml 的 extra_blacklist) +blacklist: + - disney + - marvel + - nfl + - nba + - mlb + - nhl + - fifa + - olympics + - bernie sanders + +# —— 真实人物过滤(肖像权 right of publicity)—— +# 显式名单(子串匹配)+ Firstname Lastname 模式(仅对 gt_trending 源,避免误删风格词)。 +name_filter: + enabled: true + exemptions: [] # 留空用内置默认豁免(new album / red cross 等) + +# —— 设计相关性过滤(让关键词更聚焦可印主体,剔除泛新闻/科技/赛事词)—— +relevance: + enabled: true + drop_patterns: + - "iphone|ipad|android|ps5|xbox|macbook|windows|samsung|pixel|tesla" + - "pro max|firmware|software version|os update|app update" + - "election|debate|summit|treaty|sanction|parliament|congress|prime minister" + - "stock|crypto|bitcoin|earnings|inflation|interest rate|market crash|nasdaq" + - "score|vs |quarter-final|semi-final|final match|match result|fixture|grand prix" + - "episode|trailer|release date|box office|watch online|full movie|streaming" + - "breaking news|headline|live update|press conference" + keep_patterns: [] + +# —— 查询噪声过滤(问句/命名清单/损坏碎片/模糊名词,非可印花设计概念)—— +# 这些词进入 screen 会被 Mock 误标 safe,故在过滤阶段直接丢弃。 +query_noise: + enabled: true + +# —— LLM 筛选(印花设计合规筛 + 生图提示词构造,第二阶段)—— +# 只适配 OpenAI 兼容协议:自定义 model / base_url / api_key(UI 的「OpenAI 配置」区或此处均可填)。 +llm_screen: + enabled: true + provider: openai # openai(真 LLM,需 key)/ mock(无 key 启发式兜底) + api_key: "sk-ws-H.EPXPIER.vRMT.MEUCIQDji_AHGl-EekYOftdLxEvFl2ZqCNtLDSp3Bqnaz0SRpgIgI4qPeVyIMMzutl9JakuYRv3zmFxkwc12c4aBcIL1gqE" # 留空则自动读取环境变量 LLM_API_KEY / OPENAI_API_KEY(推荐,避免密钥入库) + base_url: "https://ws-5rfjflubus647o7t.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" # 自定义网关地址,也可用环境变量 LLM_BASE_URL 覆盖 + model: "qwen3.7-max-preview" # 自定义模型名(任意 OpenAI 兼容模型) + temperature: 0.6 + max_topics_per_call: 12 + min_score: 0.0 + keep_review: false # false = 待复核(review)直接过滤、不生成设计提示词,只留 safe + +# —— 固定提示词模板(规则写死,保证每条一致)—— +# 由 motif + art_style + color_palette + composition 四要素按模板确定性拼出。 +# v3:countries. 按国家覆盖 image_prompt(每国设计风格不同),顶层为兜底。 +# 文字规则统一:英文可加可不加、适配印花即可;任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物等敏感内容。 +prompt_templates: + image_prompt: "{motif}, {art_style}, {color_palette}, {composition}, standalone pure print design, print-ready artwork, flat vector-like graphic, crisp clean edges, high resolution, ultra sharp, high contrast, US-market aesthetic: bold confident statement graphic, high contrast, clean modern vector, sporty or humorous mood, size: choose freely between a MINIMUM print area of about 15x18 cm and a MAXIMUM of 26x32 cm, any size in this range fits, pick the one that best suits the design, keep proportions, scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, do NOT force full-bleed, leave balanced margins around the artwork, no garment, no shirt, no model, no mannequin, no watermark, text: optional - add short original English words or a small slogan ONLY if they fit the print, or keep it text-free; any text must be safe, short and original; no brand names, no logos, no trademarked phrases, no real people names" + wearable_prompt: "{motif}, {art_style}, {color_palette}, {composition}, printed centered on the chest of a flat-lay plain white t-shirt, print sized freely between about 15x18 cm and a max of 26x32 cm, scaled naturally to the artwork, not stretched, not full-bleed, studio lighting, e-commerce product photo, no human model" + composite_prompt: "【图片角色,按提交顺序】图1=模特实拍图(基底);图2=纯印花设计稿;图3=平铺衣服底图(只取衣服本身的底色与面料材质,忽略平铺图背景/桌面/场景,只保留面料颜色与质感)。 +TASK: 把图2的印花设计印到图3底色的衣服上,并让图1的模特穿上“图3底色+图2印花”的衣服。 +RULES:/n1.底色锁定:从图3提取衣服底色与面料,最终合成中必须100%保持不变,严禁偏色。 +2.印花提取:从图2精准提取纯印花图案(线条/色号/比例),叠加到图3底色上形成合成面料。3.印花尺寸适配:印花整体尺寸与衣服面料面积成合理比例,居中印在胸/背/衣身主体区域,占衣身面积约30%-45%,四周留白,严禁过大撑满整件或过小(低于20%)。 +3.主体遮罩:识别图1模特服装穿着区域(忽略皮肤/头发/背景/配饰),用合成面料完整覆盖,清除原衣服颜色与图案。 +4.精准贴合:合成面料严格跟随图1衣服立体结构,褶皱/扭转处印花相应变形,杜绝“贴纸感”与“平面涂色感”。 +5.光影融合:按图1环境光方向调整亮度/对比度,印花受光影响产生明暗变化但色号不偏移。 +6.纯净输出:仅输出一张最终合成图;图1背景/人物/构图/光影100%不变,仅替换衣服印花与底色。 +DESIGN CONTENT: {motif}, {art_style}, {color_palette}, {composition}." + composite_negative: "garment changed, wrong color, distorted print, blurry, low-res, human model, body, extra objects, watermark, glow, 3d render, text unless part of design" + countries: + US: + image_prompt: "{motif}, {art_style}, {color_palette}, {composition}, standalone pure print design, print-ready artwork, flat vector-like graphic, crisp clean edges, high resolution, ultra sharp, high contrast, US-market aesthetic: bold confident statement graphic, high contrast, clean modern vector, sporty or humorous mood, size: choose freely between a MINIMUM print area of about 15x18 cm and a MAXIMUM of 26x32 cm, any size in this range fits, pick the one that best suits the design, keep proportions, scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, do NOT force full-bleed, leave balanced margins around the artwork, no garment, no shirt, no model, no mannequin, no watermark, text: optional - add short original English words or a small slogan ONLY if they fit the print, or keep it text-free; any text must be safe, short and original; no brand names, no logos, no trademarked phrases, no real people names" + GB: + image_prompt: "{motif}, {art_style}, {color_palette}, {composition}, standalone pure print design, print-ready artwork, flat vector-like graphic, crisp clean edges, high resolution, ultra sharp, high contrast, UK-market aesthetic: witty understated British charm, heritage-inspired motifs, retro sportswear or punk-zine mood, size: choose freely between a MINIMUM print area of about 15x18 cm and a MAXIMUM of 26x32 cm, any size in this range fits, pick the one that best suits the design, keep proportions, scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, do NOT force full-bleed, leave balanced margins around the artwork, no garment, no shirt, no model, no mannequin, no watermark, text: optional - add short original English words or a small slogan ONLY if they fit the print, or keep it text-free; any text must be safe, short and original; no brand names, no logos, no trademarked phrases, no real people names" + JP: + image_prompt: "{motif}, {art_style}, {color_palette}, {composition}, standalone pure print design, print-ready artwork, flat vector-like graphic, crisp clean edges, high resolution, ultra sharp, high contrast, JP-market aesthetic: kawaii cute or clean minimal, soft pastel-friendly, polished neat lines, small cute mascot mood, size: choose freely between a MINIMUM print area of about 15x18 cm and a MAXIMUM of 26x32 cm, any size in this range fits, pick the one that best suits the design, keep proportions, scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, do NOT force full-bleed, leave balanced margins around the artwork, no garment, no shirt, no model, no mannequin, no watermark, text: optional - add short original English words or a small slogan ONLY if they fit the print, or keep it text-free; any text must be safe, short and original; no brand names, no logos, no trademarked phrases, no real people names" + AU: + image_prompt: "{motif}, {art_style}, {color_palette}, {composition}, standalone pure print design, print-ready artwork, flat vector-like graphic, crisp clean edges, high resolution, ultra sharp, high contrast, AU-market aesthetic: laid-back coastal and outdoor vibe, nature-inspired, bright fresh energy, size: choose freely between a MINIMUM print area of about 15x18 cm and a MAXIMUM of 26x32 cm, any size in this range fits, pick the one that best suits the design, keep proportions, scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, do NOT force full-bleed, leave balanced margins around the artwork, no garment, no shirt, no model, no mannequin, no watermark, text: optional - add short original English words or a small slogan ONLY if they fit the print, or keep it text-free; any text must be safe, short and original; no brand names, no logos, no trademarked phrases, no real people names" + MX: + image_prompt: "{motif}, {art_style}, {color_palette}, {composition}, standalone pure print design, print-ready artwork, flat vector-like graphic, crisp clean edges, high resolution, ultra sharp, high contrast, MX-market aesthetic: vibrant mexican folk art, sugar skull / loteria / aztec motifs, fiesta colors, festive cultural pride mood, size: choose freely between a MINIMUM print area of about 15x18 cm and a MAXIMUM of 26x32 cm, any size in this range fits, pick the one that best suits the design, keep proportions, scale naturally to the content, do NOT stretch, do NOT fill the entire canvas, do NOT force full-bleed, leave balanced margins around the artwork, no garment, no shirt, no model, no mannequin, no watermark, text: optional - add short original English words or a Spanish slogan ONLY if they fit the print, or keep it text-free; any text must be safe, short and original; no brand names, no logos, no trademarked phrases, no real people names" + +# —— 第三阶段 compose:生成纯印花设计稿 + 导出简报包(在 product 之前)—— +# 用前 N 个 safe 简报的 image_prompt 调图像后端文生图,产物 output//designs/, +# product 节点取第一张设计稿作为图2 复用(多款号共用同一设计)。 +# backend 留空则不生成设计稿(product 节点会自行回退生成)。 +compose: + backend: "openai" # 生成设计稿的图像后端:openai(需 api_key)/ mock(占位);留空=不生成 + api_key: "sk-ef1f251f059f9c2f8cc397de4a84e96d65775e1f2d32bb13b69b23dafc24d111" + base_url: "https://api.tofastcode.xyz" # 自定义图像网关地址(默认 OpenAI) + model: "gpt-image-2" + size: "1536×2048" + background: "transparent" # 生成透明背景 PNG(gpt-image 系列支持);留空=默认背景 + design_count: 1 # 用前 N 个 safe 简报生成设计稿(product 取第一个复用) + +# —— 第五阶段 oss_upload:压缩(3:4 / ≥1340×1785 / <2MB)+ 上传阿里云 OSS 图床 —— +# 产物(composite/printed/design/basemap)逐个压缩上传,URL 写回 *_url 字段。 +# key_id/key_secret 也可用环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET 覆盖。 +oss: + oss_bucket: "image-taotu" + oss_endpoint: "oss-cn-beijing.aliyuncs.com" + oss_key_id: "LTAI5t6tN2k2dznmdBmDJebh" + oss_key_secret: "2Q25JohOI4GmovszvIw1NuVvUzM6PB" + enabled: true # false = 跳过上传(本地仅压缩或完全跳过) + +# —— 第六阶段 seed_shot:种草图生成(在 oss_upload 之后)—— +# 模板:configs/seed_shot_templates.yaml(提示词,占位符 [商品名称]/[材质]/[模特特征],可自定义添加) +# 模特特征:configs/model_features.yaml(随机取一条,可自定义添加) +# [商品名称] ← product 的 cn_title;[材质] ← db SPU.material;[模特特征] ← yaml 随机 +# 种草图同样压缩上传 OSS(货号计数与 oss_upload 共用续接)。 +seed_shot: + enabled: true + count: 1 # 每个产品生成几张种草图(传入数量即可) + size: "1536×2048" # 种草图尺寸(统一读取此配置,不再硬编码) + +# —— 第四阶段 product:热点 → SPU/颜色选品 → 底图 → 三图合成(图1模特+图2设计稿+图3底图)→ 模板 —— +# 数据关系:SPU.code=款号,SKU.code="款号-颜色编码"(如 DG004-BL01), +# 底图在 basemap/<款号>//,模特图在 material_library/<品类>/。 +# 设计稿优先用 compose 节点生成的(compose.backend),本节点 backend 负责三图/两图合成。 +# backend: openai(真合成,需 compose.api_key) / mock(Pillow 占位,无 key 演示) / 留空=仅导出模板。 +product: + enabled: true + db_path: "db/spu_sku.db" + basemap_dir: "basemap" + material_library_dir: "material_library" + model_category: "T-shirt" # 模特目录优先品类;为空/无图时取 material_library 第一个有图的子目录;SPU.mark==1 才启用模特试穿 + brief_index: 0 # 用第几个 safe 简报的提示词生成(0=第一个) + spu_code: "" # 款号(留空自动选第一个有本地底图的) + sku_code: "" # 颜色编码(留空自动选该款第一个有底图的);多个颜色用逗号分隔(如 "DG015-VT01,DG015-DARK HEATHER") + spu_per_color: # 留空自动判定:单色=每色一SPU;多色=单SPU多色(UI 不选择模板模式);CLI --single-spu 可显式覆盖 + markup_percent: 0 # 加价百分比(按 SKU 最低价,供后续定价使用) + code_prefix: "DG" # 货号前缀(OSS 上传 key:{国家}/{时间戳}/{前缀+3位计数}_{4位随机}.jpg,000 起最多 999) + backend: "mock" # openai / mock / 留空=跳过生成 + background: "transparent" # 设计稿透明背景(product 后端生图时也传 background=transparent) + template_dir: "templates" # template_router.py 所在目录(项目内自包含) + template_path: "templates/商品上传模版.xlsx" # 商品上传模板(可改为自由上传) + diff --git a/configs/countries/AU.yaml b/configs/countries/AU.yaml new file mode 100644 index 0000000..6f64e08 --- /dev/null +++ b/configs/countries/AU.yaml @@ -0,0 +1,32 @@ +# 澳大利亚专属数据源配置 +trending: + enabled: true + limit: 40 + +style: + enabled: true + seeds: + - summer + - beach + - surf + - vintage + - outback + - coastal + - cat + - dog + - cute animals + - paw prints + - moon and stars + - cozy vibes + +related: + enabled: true + seed_keywords: + - kawaii + - vintage poster + - funny mug + - beach art + - retro travel + - floral + +timeframe: "today 3-m" diff --git a/configs/countries/GB.yaml b/configs/countries/GB.yaml new file mode 100644 index 0000000..7cfa4d7 --- /dev/null +++ b/configs/countries/GB.yaml @@ -0,0 +1,32 @@ +# 英国专属数据源配置 +trending: + enabled: true + limit: 40 + +style: + enabled: true + seeds: + - british humor + - punk + - vintage + - cottagecore + - retro + - gothic + - cat + - dog + - cute animals + - paw prints + - moon and stars + - cozy vibes + +related: + enabled: true + seed_keywords: + - kawaii + - vintage uk + - funny names + - skull head + - england retro + - retro shirt + +timeframe: "today 3-m" diff --git a/configs/countries/JP.yaml b/configs/countries/JP.yaml new file mode 100644 index 0000000..cfaf168 --- /dev/null +++ b/configs/countries/JP.yaml @@ -0,0 +1,32 @@ +# 日本专属数据源配置 +trending: + enabled: true + limit: 40 + +style: + enabled: true + seeds: + - kawaii + - anime + - japanese aesthetic + - kanji + - vaporwave + - gyaru + - cat + - dog + - cute animals + - paw prints + - moon and stars + - cozy vibes + +related: + enabled: true + seed_keywords: + - kawaii sticker + - vintage poster + - cute cat + - anime tee + - minimalist art + - floral + +timeframe: "today 3-m" diff --git a/configs/countries/MX.yaml b/configs/countries/MX.yaml new file mode 100644 index 0000000..b0e3e26 --- /dev/null +++ b/configs/countries/MX.yaml @@ -0,0 +1,34 @@ +# 墨西哥专属数据源配置 +trending: + enabled: true + limit: 40 + +style: + enabled: true + seeds: + - day of the dead + - calavera + - sugar skull + - loteria + - aztec pattern + - lucha libre + - mariachi + - mexican folk art + - chicano + - cactus + +related: + enabled: true + seed_keywords: + - catrina + - dia de los muertos + - mexican food + - taco + - avocado + - sombrero + - aguila mexicana + - aztec calendar + - alebrijes + - lowrider + +timeframe: "today 3-m" diff --git a/configs/countries/US.yaml b/configs/countries/US.yaml new file mode 100644 index 0000000..b189544 --- /dev/null +++ b/configs/countries/US.yaml @@ -0,0 +1,35 @@ +# 美国专属数据源配置(覆盖全局默认) +# 种子词已换成短而热门、且贴近 POD 的词,related_queries 才能返回数据。 +trending: + enabled: true + limit: 40 + +style: + enabled: true + seeds: + - vintage + - retro + - funny + - aesthetic + - cottagecore + - grunge + - streetwear + - punk + - cat + - dog + - cute animals + - paw prints + - moon and stars + - cozy vibes + +related: + enabled: true + seed_keywords: + - kawaii + - vintage poster + - funny mug + - anime tee + - retro band tee + - skull art + +timeframe: "today 3-m" diff --git a/configs/model_features.yaml b/configs/model_features.yaml new file mode 100644 index 0000000..2c747f4 --- /dev/null +++ b/configs/model_features.yaml @@ -0,0 +1,56 @@ +# 模特特征库 —— 可自定义添加(一行一条,种草图提示词 [模特特征] 随机取用) +# 示例: +# - "20岁清新少女,素颜通透感" +# - "25岁都市职场女性,干练气质" +model_features: + - "20岁欧美甜心,金发碧眼,阳光加州感" + - "25岁法国左岸文青,法式刘海,慵懒红唇" + - "28岁意大利名媛,复古波浪卷,精致上扬眼线" + - "22岁纽约下城区酷女孩,挑染发色,厌世烟熏妆" + - "30岁北欧极简风女性,冷白皮,无瑕光泽肌" + - "24岁拉美热情系女生,小麦色肌肤,野生挑眉" + - "26岁法国时尚博主,法式码头上衣,碎发自然感" + - "29岁英伦中性风女模,深色短寸,骨感清冷" + - "21岁俄罗斯芭蕾少女,深邃眼窝,通透纯欲妆" + - "25岁巴西超模脸,高颧骨,健康古铜色肌肤" + - "33岁美式大女主气场,利落大波浪,深色红唇" + - "22岁Y2K千禧辣妹,银色眼影,浅色唇彩" + - "28岁美式复古Pin-up女孩, Victory卷发,饱满红唇" + - "24岁欧美高级中性模,凌乱短发,苍白质肌肤" + - "20岁加州冲浪系男孩,阳光金发,麦色健康肌" + - "25岁英伦雅痞型男,复古油头,短胡茬,熟男气质" + - "28岁意大利型男,深邃五官,意式凌乱卷发" + - "22岁纽约街头滑板男孩,脏辫,宽松街头风" + - "30岁华尔街商务精英,背头,沉稳内敛气质" + - "26岁日系盐系清冷男模,单眼皮,清瘦骨感" + - "24岁欧美健身教练型男,肌肉线条分明,小麦色肌肤" + - "29岁柏林暗黑系青年,苍白皮肤,银色配饰,冷感打底" + - "21岁英伦贵族少爷,金丝眼镜,白皙皮肤,忧郁气质" + - "27型美式机车硬汉,粗犷轮廓,络腮胡,硬挺气质" + - "23岁东欧赛博朋克风骇客,机械感配饰,冷感眼妆" + - "32岁法式儒雅大叔,微卷发,眼镜,温润如玉气质" + - "25岁欧美高冷男模,银白发色,苍白冷白皮" + - "22岁俄裔冷酷超模,高颧骨,锋利眼神" + - "26岁拉美混血风男模,深邃眼窝,健康小麦色" + - "28岁纽约高街潮流主理人,oversize穿搭,厌世脸" + - "20岁荷兰清新少女,雀斑,通透素颜" + - "28岁北欧美人鱼风,长发及腰,冷感通透妆" + - "24岁巴黎左岸文艺女青年,条纹衫气质,伪素颜" + - "29岁米兰奢华贵妇,戴墨镜,精致法式美甲" + - "21岁加州 Coachella 音乐节女孩,波西米亚编发,晒伤妆" + - "27岁东欧废土风流浪者,做旧皮革质感,沧桑眼神" + - "23岁巴西里约热内卢狂欢女孩,羽毛头饰,高饱和妆容" + - "31岁哥本哈根极简风设计师,利落短发,黑白灰穿搭" + - "25岁美式复古绅士,圆框眼镜,马甲三件套" + - "22岁迈阿密热辣女孩,大波浪,高光立体修容妆" + - "26岁巴黎先锋艺术系青年,高领毛衣,单边耳坠" + - "32岁意式慵懒波西米亚女性,大波浪,流苏配饰" + - "25岁柏林冷酷电子乐DJ,全黑穿搭,荧光眼线" + - "20岁美式青春啦啦队队长,马尾,元气通透妆" + - "28岁伦敦复古古着店主理人,复古丝绒材质,红棕唇" + - "24岁北欧极简主义建筑系学生,眼镜,素雅气质" + - "29岁好莱坞黄金时代复古女星,手推波浪卷,红唇" + - "21岁加拿大户外徒步青年,冲锋衣,健康小麦色" + - "33岁法式波尔多酒庄主理人,优雅气质,微醺红唇" + - "26岁纽约苏荷区独立摄影师,工装背带裤,随性素颜" + - "30岁北欧性冷淡风艺术家,银色短发,疏离眼神" diff --git a/configs/seed_shot_templates.yaml b/configs/seed_shot_templates.yaml new file mode 100644 index 0000000..f4c6f97 --- /dev/null +++ b/configs/seed_shot_templates.yaml @@ -0,0 +1,182 @@ +# 种草图提示词模板库 —— 跨境电商详情页专用版(正面印花完整展示版) +# 占位符:[商品名称]、[材质]、[模特特征]、[服装风格] +# 摄影参数说明:35mm/50mm镜头控制自然透视,f/1.4-f/2.8控制景深虚化,8k强化面料细节,3:4符合电商标准长图比例。 + +seed_shot_templates: + # ========================================== + # 一、 高转化白底/灰底主图区(聚焦正面印花) + # ========================================== + - name: "纯白背景正面全展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在纯白无缝背景纸前,身体正对镜头微侧15度,双手自然下垂,眼神直视镜头,整体呈现[服装风格]的调性,完整展示衣服正面的印花图案与[材质]面料的垂坠感。采用电商标准的高亮柔光,左右各一盏柔光箱消除杂乱阴影,光线均匀分布。画面比例为3:4。商业白底图摄影,50mm镜头,f/2.8光圈,8k分辨率。 + + - name: "高级浅灰背景全展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在高级浅灰背景纸前,身体正对镜头,双手插兜或自然下垂,姿态略微随性,整体呈现[服装风格]的调性,完整展示衣服正面印花与[材质]的挺括度。采用影棚标准柔光与边缘光结合,背景为干净的低饱和灰色。画面比例为3:4。商业白底摄影,50mm镜头,f/2.8光圈,8k分辨率。 + + - name: "纯白背景双手展示下摆" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中近景半身构图,模特站在纯白背景纸前,双手捏住衣服下摆两侧向下轻拉展平,确保正面印花完整无遮挡,眼神直视镜头,整体呈现[服装风格]的调性,画面聚焦展示衣服正面印花图案与下摆的缝线工艺。采用影棚高显色柔光箱,突出印花色彩与[材质]的紧密编织纹理。画面比例为3:4。电商白底图摄影,50mm镜头,f/2.0光圈,8k分辨率。 + + - name: "低饱和克莱因蓝背景棚拍" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特在低饱和克莱因蓝的无缝背景纸前摆出具有张力的站姿,身体正对镜头,双手自然下垂,整体呈现[服装风格]的调性,突出衣服正面印花图案的视觉冲击力与剪裁,展示[材质]的独特纹理。光线采用双灯硬光与柔光箱结合,形成干净分明的明暗对比。画面比例为3:4。极简主义商业摄影,85mm镜头,f/2.8光圈,8k分辨率。 + + - name: "纯白背景微动态走动展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在纯白无缝背景纸前,呈现正对镜头的动态走动状态,单手插兜,整体呈现[服装风格]的调性,展示衣服正面印花在动态下的视觉效果与[材质]面料的动态垂坠感。采用影棚高亮柔光,光比干净分明。画面比例为3:4。电商白底图摄影,50mm镜头,f/2.8光圈,8k分辨率。 + + # ========================================== + # 二、 面料与印花特写区(打消购买顾虑) + # ========================================== + - name: "纯白背景领口细节特写" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中近景构图,模特站在纯白背景纸前,双手自然下垂不遮挡印花,整体呈现[服装风格]的调性,画面聚焦展示衣服的领口、袖口等工艺细节与[材质]面料的紧密编织纹理。采用影棚标准高显色柔光箱,光线均匀且突出材质的高密度纹理与亲肤特性。画面比例为3:4。电商白底图摄影,50mm镜头,f/2.0光圈,8k分辨率。 + + - name: "逆光面料透气质感展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特在黄昏时分的空旷天台逆光站立,发丝被风吹起,双手插兜,身体正对镜头微侧,整体呈现[服装风格]的调性,展示衣服在逆光强光下展现出的[材质]面料透光性与柔软质感。光线为日落黄金时刻逆光,边缘形成强烈的轮廓光发丝发亮。画面比例为3:4。电影级情绪摄影,50mm镜头,f/1.8光圈,8k分辨率。 + + - name: "双手拉扯领口弹性展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中近景构图,模特站在纯色背景前,双手拉扯衣服领口向下或向两侧拉伸,展示衣服的弹力与防变形属性,整体呈现[服装风格]的调性,凸显[材质]面料的回弹性与结实度。采用高亮柔光,突出面料拉伸时的纹理张力。画面比例为3:4。商业电商摄影,50mm镜头,f/2.8光圈,8k分辨率。 + + - name: "低头凝视正面印花特写" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特站在纯白背景纸前,微微低头凝视衣服胸前的印花图案,双手自然下垂,整体呈现[服装风格]的调性,画面以斜俯视角度聚焦展示正面印花的色彩细节与[材质]面料的质感。采用影棚顶部柔光与正面补光,突出印花图案的清晰度。画面比例为3:4。电商白底图摄影,50mm镜头,f/2.0光圈,8k分辨率。 + + # ========================================== + # 三、 居家生活场景区(营造松弛感与舒适度) + # ========================================== + - name: "居家沙发慵懒场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中远景全身构图,模特慵懒地靠在现代极简风格的客厅布艺沙发上,身体正对镜头微侧,姿态松弛,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的舒适度。室内采用柔和的漫反射自然光,背景为高度虚化的居家环境。画面比例为3:4。柯达Portra 400胶片质感,色彩柔和,35mm镜头,f/2.0光圈,8k分辨率。 + + - name: "居家落地窗自然光展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特站在高层公寓的落地窗前,单手轻触玻璃,身体正前方对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]亲肤舒适属性。采用日落前的柔和逆光,形成强烈的轮廓光发丝发亮。背景为大虚化的城市楼宇剪影与暖色光晕。画面比例为3:4。电影级情绪摄影,50mm镜头,f/1.8光圈,8k分辨率。 + + - name: "周末清晨床头场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景构图,模特坐在柔软的双人床上伸懒腰,旁边有凌乱的白色枕头,展现周末清晨的松弛感,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的亲肤无拘束感。室内采用清晨柔和的侧向自然光,背景为高度虚化的卧室家具。画面比例为3:4。日系胶片质感,柯达Portra 400色彩预设,40mm镜头,f/2.0光圈,8k分辨率。 + + - name: "居家厨房烹饪场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中远景全身构图,模特站在现代开放式厨房的中岛台前切水果,身体正对镜头微侧,姿态放松自然,整体呈现[服装风格]的调性,展示衣服正面印花在居家生活中的百搭属性与[材质]面料的舒适耐穿度。室内采用明亮的漫反射自然光与暖色室内灯,背景为高度虚化的厨房台面与绿植。画面比例为3:4。清透氧气感色彩,索尼A7R4画质,50mm镜头,f/1.8光圈,8k分辨率。 + + - name: "周末居家阅读场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特盘腿坐在卧室的羊毛地毯上,身体正对镜头微侧,双手捧着一本书但不遮挡胸口印花,整体呈现[服装风格]的调性,展示衣服正面印花图案与[材质]面料的柔软亲肤感。室内采用柔和的窗光侧逆光,背景为高度虚化的卧室床铺与暖色氛围灯。画面比例为3:4。日系胶片质感,柯达Portra 400色彩预设,40mm镜头,f/2.0光圈,8k分辨率。 + + # ========================================== + # 四、 户外休闲/运动场景区(展现百搭与透气) + # ========================================== + - name: "阳光草坪户外实拍" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身动态抓拍构图,模特单手插兜,行走在阳光斑驳的城市林荫道上,身体正对镜头微侧,微微低头微笑,呈现不经意的随性状态,整体展现[服装风格]的氛围,凸显[材质]在自然行动中的透气与百搭。午后阳光透过树叶洒下丁达尔光斑与树影,背景为高度虚化的过往行人与都市街景。画面比例为3:4。徕卡Q2摄影质感,高对比度色彩,35mm镜头,f/1.7大光圈,8k分辨率。 + + - name: "都市街头OOTD穿搭展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在现代商业街区或美术馆外墙前,单手拿着外带咖啡杯放在胸前不遮挡印花,身体正对镜头微靠在墙上,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的抗皱性。采用午后天光与建筑反射光,画面明亮通透,背景为高度虚化的都市街景与阳光光斑。画面比例为3:4。清透氧气感色彩,索尼A7R4画质,50mm镜头,f/1.8光圈,8k分辨率。 + + - name: "天台蓝天白云清透场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在无遮挡的空旷天台上,背景是大面积的蓝天白云,模特双手自然下垂或交叉抱胸但不遮挡印花,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的透气感。正午明亮的顺光,无死角的展现服装颜色,背景为高饱和的纯蓝天空。画面比例为3:4。徕卡Q2摄影质感,35mm镜头,f/2.8光圈,8k分辨率。 + + - name: "自然公园休闲外景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在阳光充足的公园草坪前,微风吹拂发丝,微微仰头感受自然,身体正对镜头微侧,双手自然下垂,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的透气性与轻盈感。光线为明亮的顺光搭配自然反光,画面明亮通透,背景为大面积虚化的鲜绿植被与光斑。画面比例为3:4。清透氧气感色彩,索尼A7R4画质,85mm镜头,f/1.8光圈,清新高调摄影,8k分辨率。 + + - name: "街头滑板运动场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身动态抓拍构图,模特单脚踩着滑板停在滑板公园的U型池边缘,身体正对镜头,微风吹拂发丝,双手自然下垂,呈现不经意的随性状态,整体展现[服装风格]的氛围,展示衣服正面印花与[材质]的运动属性。午后强烈的阳光形成侧逆光,背景为高度虚化的水泥滑板池与涂鸦墙。画面比例为3:4。徕卡Q2摄影质感,35mm镜头,f/1.7光圈,8k分辨率。 + + - name: "阳光林荫道骑行场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身动态抓拍构图,模特骑着一辆复古自行车停在两旁长满梧桐树的林荫小道上,单脚点地,转头看向镜头微笑,身体正对镜头,双手握把不遮挡印花,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的透气性。午后阳光透过树叶洒下丁达尔光斑,背景为高度虚化的树干与光斑。画面比例为3:4。日系胶片质感,柯达Portra 400色彩预设,40mm镜头,f/2.0光圈,8k分辨率。 + + - name: "海边漫步度假场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身动态抓拍构图,模特走在细软的沙滩上,海风吹起衣摆,双手自然张开,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的轻盈与夏日属性。光线为正午强烈的顺光,画面明亮高调,背景为高度虚化的蔚蓝海水、白沙滩与远处海平线。画面比例为3:4。索尼A7R4画质,35mm镜头,f/2.0光圈,高饱和度清透色彩,8k分辨率。 + + - name: "露营帐篷森系户外场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特坐在森林空地的露营椅上,旁边有燃烧的篝火和搭好的金字塔帐篷,双手端着搪瓷杯放在腿上不遮挡印花,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的保暖性。采用黄昏暖色篝火光与天光交织,背景为高度虚化的茂密松林与烟雾。画面比例为3:4。复古胶片质感,35mm镜头,f/1.8光圈,8k分辨率。 + + - name: "海风度假场景展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特赤脚走在浅水沙滩上,海风吹起衣摆,双手自然张开,笑容明朗,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的轻盈与飘逸感。光线为正午到下午的明亮阳光,海面反光形成自然补光,背景为高度虚化的蔚蓝大海、白沙滩与天空。画面比例为3:4。夏日清透色彩,高饱和度,35mm镜头,f/2.0光圈,8k分辨率。 + + - name: "热带绿植温室场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在长满大型龟背竹与天堂鸟的植物园温室中,手指轻轻触碰绿叶,笑容明朗,身体正对镜头,双手自然下垂,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的轻盈与透气感。光线为穿透植物叶片的柔和自然光,形成斑驳的树影打在衣服上,背景为高度虚化的茂密绿植与光斑。画面比例为3:4。富士Provia 400X色彩预设,35mm镜头,f/1.4大光圈,8k分辨率。 + + - name: "阴天极简冷淡风外景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特背靠在粗犷的混凝土墙面或现代美术馆外墙前,眼神冷酷,身体正对镜头微侧,双手自然下垂,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]的街头属性。采用阴天柔和的漫反射自然冷光,背景为低饱和的莫兰迪灰调与几何阴影,画面干净清冷。画面比例为3:4。极简主义商业摄影,55mm镜头,f/2.8光圈,8k分辨率。 + + - name: "山顶自然风光征服场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在山顶的岩石上,双手叉腰但不遮挡印花,身体正对镜头微侧,整体呈现[服装风格]的调性,展示衣服正面印花图案与[材质]面料在户外的耐穿与透气属性。采用傍晚日落前的逆光与山顶漫反射光,背景为高度虚化的连绵山脉与云海。画面比例为3:4。电影级情绪摄影,35mm镜头,f/2.8光圈,8k分辨率。 + + # ========================================== + # 五、 工作/通勤场景区(展现日常实用度) + # ========================================== + - name: "办公场景商务休闲展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特坐在极简办公桌前,单手托腮或敲击键盘,身体正对镜头微侧,展现干练气质,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的抗皱与挺括感。室内采用明亮的现代白光照明,背景为高度虚化的电脑屏幕与办公桌绿植,画面通透干净。画面比例为3:4。商业电商摄影,50mm镜头,f/2.0光圈,8k分辨率。 + + - name: "咖啡馆周末休闲场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特坐在极简风格咖啡馆的窗边座位上,单手端着陶瓷咖啡杯放在嘴边不遮挡印花,身体正对镜头微侧,姿态慵懒松弛,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的柔软垂坠感。晨间自然光从大窗户斜射进来形成柔和侧逆光,背景为高度虚化的木质室内装潢、绿植与朦胧的咖啡蒸汽。画面比例为3:4。日系胶片质感,富士Provia 400X色彩预设,35mm镜头,f/1.8大光圈,8k分辨率。 + + - name: "书店翻阅书籍场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特站在高大的木质书架前翻阅一本精装书,眼神专注,身体正对镜头微侧,双手捧书放在腰间不遮挡胸口印花,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的挺括与质感。书店采用暖色调的钨丝灯点光源,背景为高度虚化的书脊与暖色光斑。画面比例为3:4。电影级情绪摄影,50mm镜头,f/1.8大光圈,8k分辨率。 + + - name: "复古家具店探店场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在摆满上世纪老物件的中古家具店内,手指轻抚一把复古单椅,眼神安静,身体正对镜头微侧,双手自然下垂,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的质感。店内采用暖色调的钨丝灯漫反射,背景为高度虚化的胡桃木柜子与老式台灯。画面比例为3:4。富士Provia 400X色彩预设,35mm镜头,f/1.8光圈,8k分辨率。 + + - name: "艺术画廊看展高级场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在灯光柔和的现代艺术画廊内,侧身凝视一幅巨大的抽象画后转过头来正对镜头,双手自然下垂,气质疏离高级,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]的高级调性。画廊专业的顶光灯打出柔和的漫反射,背景为高度虚化的洁白墙面与画框。画面比例为3:4。索尼A7R4画质,35mm镜头,f/1.8大光圈,清冷调高级感,8k分辨率。 + + - name: "超市货架色彩碰撞场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特推着购物车或站在摆满色彩鲜艳饮料的超市货架前,身体正对镜头,双手推车不遮挡印花,姿态俏皮个性,整体呈现[服装风格]的调性,展示衣服正面印花图案与背景色彩的碰撞感及[材质]的日常实穿度。货架顶部的冷光源打亮环境,背景为高度虚化的彩色商品与灯轨。画面比例为3:4。赛博潮流摄影,35mm镜头,f/1.8光圈,8k分辨率。 + + # ========================================== + # 六、 情绪氛围与夜景场景区(提升品牌调性) + # ========================================== + - name: "工业风工作室背景展示" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特站在工业风loft工作室的水泥墙前,单手插兜不遮挡印花,眼神清冷,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的硬挺与廓形。采用顶部的冷色聚光灯与微弱的暖色环境光对比,背景为高度虚化的昏暗通道与反光地面。画面比例为3:4。赛博潮流电商摄影,55mm镜头,f/2.0光圈,8k分辨率。 + + - name: "录音棚音乐人场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景构图,模特戴着监听耳机站在专业麦克风前调音,眼神专注,身体正对镜头微侧,双手调音不遮挡胸口印花,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的挺括廓形。采用录音棚特有的暖色小聚光灯与整体冷色调对比,背景为高度虚化的吸音棉墙面与调音台。画面比例为3:4。电影级情绪摄影,50mm镜头,f/1.8光圈,8k分辨率。 + + - name: "微醺暖光清吧场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特坐在氛围感清吧的吧台前,单手把玩着威士忌酒杯放在吧台上不遮挡印花,眼神微醺迷离,身体正对镜头微侧,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]在暖光下的质感。采用暖黄色的点光源与背景微弱的蓝光对比,背景为高度虚化的酒瓶阵列与杯光交错。画面比例为3:4。电影级情绪摄影,50mm镜头,f/1.4大光圈,8k分辨率。 + + - name: "夜晚霓虹街头场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特夜晚倚靠在城市天桥栏杆旁,眼神清冷,身体正对镜头微侧,双手插兜不遮挡印花,整体呈现[服装风格]的调性,展示衣服正面印花在夜色下的视觉效果与[材质]的质感与垂感。采用强烈的边缘光与城市霓虹灯的彩色反光(青蓝与橘红对比),背景为高度虚化的车流光轨与霓虹灯牌。画面比例为3:4。赛博朋克潮流摄影,35mm镜头,f/1.4大光圈,8k分辨率。 + + - name: "复古旱冰场霓虹场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特穿着旱冰鞋在复古旱冰场滑行,姿态灵动,身体正对镜头微侧,双手自然摆动不遮挡印花,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的随性与飘逸。霓虹紫粉色调与射灯打在模特身上,背景为高度虚化的霓虹灯带与溜冰场护栏。画面比例为3:4。复古胶片质感,35mm镜头,f/1.4大光圈,8k分辨率。 + + - name: "地下车库工业风场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特背靠在粗犷的地下车库水泥柱上,眼神冷酷,身体转回正对镜头,双手自然下垂,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的耐穿属性。采用顶部的冷色聚光灯与微弱的暖色环境光对比,背景为高度虚化的昏暗车库通道与反光地面。画面比例为3:4。赛博朋克潮流摄影,55mm镜头,f/2.0光圈,8k分辨率。 + + - name: "美式复古街道场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。全身构图,模特走在有复古拱门和石板路的欧式街道上,手里拿着一束鲜花放在胸前不遮挡印花,身体正对镜头,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的垂感。清晨柔和的漫反射自然光,画面充满复古氛围,背景为高度虚化的古典建筑与梧桐树。画面比例为3:4。柯达Portra 400胶片质感,色彩柔和低饱和,50mm镜头,f/2.0光圈,8k分辨率。 + + - name: "长途自驾车窗场景" + prompt: | + 【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。中景半身构图,模特坐在汽车副驾驶位上,单手将手肘搭在车窗上,看着镜头,微风吹拂发丝,身体正对镜头微侧,另一只手自然放在腿上不遮挡印花,整体呈现[服装风格]的调性,展示衣服正面印花与[材质]面料的随性质感。光线为穿透车窗的柔和侧逆光,背景为高度虚化的公路与掠过的树影。画面比例为3:4。电影级情绪摄影,柯达Portra 400色彩预设,40mm镜头,f/2.0光圈,8k分辨率。 diff --git a/configs/style_features.yaml b/configs/style_features.yaml new file mode 100644 index 0000000..75fa2aa --- /dev/null +++ b/configs/style_features.yaml @@ -0,0 +1,24 @@ +# style_features.yaml —— 适合基础款短袖/卫衣的保守百搭风格 +# 占位符说明:生成种草图时,从此列表中随机取一条替换 [服装风格] + +style_features: + - "极简基础款风格,干净纯粹,无过多繁复装饰" # 最适合纯色短袖 + - "日常休闲风,舒适随性,呈现松弛的居家与出街状态" + - "美式休闲运动风,阳光活力,适合日常通勤或轻度户外" + - "日系City Boy/Girl风,微宽松版型,注重舒适度与层次感" # 适合卫衣 + - "韩系极简通勤风,低饱和度色彩,展现内敛的高级感" + - "北欧性冷淡风,线条利落,剪裁干净,突出版型本身" + - "都市通勤百搭风,简约不挑人,适合多数日常场景" + - "Clean Fit复古休闲风,质感舒适,呈现美式复古的慵懒感" + - "轻奢极简风,面料有质感,呈现出简约但不失调性的氛围" + - "户外轻机能风,版型挺括,适合城市游走与周末郊游" + - "法式慵懒风,宽松舒适,展现不经意的随性气质" + - "复古学院风,减龄青春,适合搭配牛仔裤或休闲裤的日常穿搭" + - "中性无性别穿搭风,男女皆可驾驭,强调肩部与胸前的廓形" # 适合Oversize卫衣 + - "基础叠穿风,内搭T恤外穿卫衣,展现层次感与保暖性" # 适合开衫或连帽卫衣 + - "街头滑板风,宽松舒适,呈现年轻活力的街头氛围" + - "极简棉麻风,透气轻盈,展现自然通透的呼吸感" # 适合夏季短袖 + - "阳光海滩度假风,色彩明快,轻盈舒适,适合夏季出游" + - "秋冬慵懒居家风,柔软亲肤,呈现温暖的室内氛围" # 适合抓绒卫衣 + - "高街潮流风,Oversize廓形,展现酷飒的日常穿搭" + - "商务休闲风,剪裁得体不松垮,适合半正式的办公场景" diff --git a/db/.workbuddy/memory/2026-08-19.md b/db/.workbuddy/memory/2026-08-19.md new file mode 100644 index 0000000..f45c23d --- /dev/null +++ b/db/.workbuddy/memory/2026-08-19.md @@ -0,0 +1,48 @@ +# 2026-08-19 工作日志 + +## 创建 SQLite 商品数据库 +- 在 `db/` 目录下创建 `spu_sku.db`,含 SPU、SKU 两张表。 +- SPU 表 25 个字段:id(主键,自增)、code、material、component_1~3、component_proportion_1~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。 +- SKU 表 9 个字段:id(主键,自增)、spu_id(外键→SPU.id,含索引 idx_sku_spu_id)、code、color、size、price(REAL)、stock(INTEGER 默认0)、image_url、status(默认 'active')。 +- 建库脚本保留在 `db/create_db.py`,可修改字段后重跑重建。 +- SKU 字段为按电商惯例补充设计,用户未指定,待确认是否调整。 + +## 更新 SKU 表结构(用户指定字段) +- 用户提供完整 SKU 字段定义,重建 SKU 表,共 19 个字段: + id(主键,自增)、spu_id(外键→SPU.id)、code、price(REAL)、color、size、size_group、size_type、shoulder_width、bust、clothing_length、sleeve_length、longest_side、secondary_long_side、shortest_side、img_url_2~img_url_5。 +- 尺寸类字段(shoulder_width/bust/clothing_length 等)用 TEXT 存储(兼容区间值/单位),如需要数值计算可改 REAL。 +- 注意:删除 db 文件会被沙箱安全机制拦截,create_db.py 已改为 DROP TABLE 方式重建,不再删除文件。 + +## 从 PG (inkreach) 同步美国商品数据 +- PG 连接:localhost:5432, user=postgres, pwd=inkreach, db=inkreach(psycopg2 装在 venv `C:\Users\Admin\.workbuddy\binaries\python\envs\default`)。 +- 数据规律:**plates.id IN (2,3,4,5) = 美国男装/女装/童装/家居配饰**;SPU = categories(code 去重 59 个);SKU = prices(web_product_id 粒度 218 条);成分=composition,洗涤/英文名/质地/印花工艺=product_extra,图片=color_detail_images,尺码范围=colors.size_range。 +- 映射:SPU.code=品类code, material/fabric=品类fabric, component_1~3=composition.comp1~3, style=sub_category, details=english_name, care_Instructions=washing_instructions, target_audience=plates.display_name, printing_type=design_explanation(烫画/热转印), fabric_texture_1=texture, fabric_weight_1=从名称正则提取克重。 +- SKU: code=sku, price=price, size_group=colors.size_range, size_type=按尺码范围推断(字母码/儿童码/婴童码/均码/家居尺寸), img_url_2~5=该产品 color_detail_images 第2~5张。 +- **留空字段(PG 无对应数据)**:pattern、collar_style、season、is_transparent、layout、weaving_method、lining_texture;SKU 的 color(web_sku 与 colors 名无法可靠映射,75 品类中 21 个不一致)、size、shoulder_width、bust、clothing_length、sleeve_length、longest_side、secondary_long_side、shortest_side(尺寸为品类级多值)。 +- 结果:SPU 59 条(5000B 无价格故无 SKU)、SKU 218 条,字段未改动。 +- 同步脚本 `db/sync_from_pg.py` 可重跑(先 DELETE 清空再插入)。 + +## SKU 改为颜色×尺码粒度(v2) +- 用户要求:SKU.code 用颜色映射编码(如 DG004-AP01 = colors.code)、color 填颜色名、每个尺码独立一个 SKU、包装规格从原库解析。 +- 新数据源(product_extra 表的 JSON 字段): + - `product_size` JSON:尺码表(表头动态列:肩宽/胸围/衣长/袖长/腰围等,第一列=尺码) + - `packaging_spec` JSON:包装规格("包装尺寸(cm)"列 = 长*宽*高,拆分为最长边/次长边/最短边;另有 in/体积/重量列) +- SKU 生成:每个 SPU = colors(按主 category_id) × product_size 尺码表 → 笛卡尔积。 + - code=colors.code(颜色映射编码),color=colors.name,size=尺码,size_group=colors.size_range,size_type=推断 + - price=该品类 prices 首条价(颜色尺码粒度无独立价);img_url_2~5=品类首颜色 web_sku 图 + - 5000B 无 product_extra → 尺码回退 size_chart;无 prices → price 留空 +- **坑**:5000B(12色)/DG502(1色) 的 colors.code 是坏数据(存了颜色名/品类码)→ 脚本兜底生成 f"{品类code}-{颜色名}"。 +- 结果:SPU 59 / SKU 1400(254 个唯一颜色编码)。填充率:code/color/size/size_group/size_type 100%,price 1340/1400,包装规格 1326/1400,尺寸字段 75~88%。 + +## SKU 尺码字段固定值 + SPU 增加国家字段 +- 用户要求:SKU.size_group 全部='尺码',SKU.size_type 全部='欧美尺码常规'(不再用推断逻辑)。 +- SPU 表新增 `country` 列(TEXT),全部 59 条 SPU 设为 'US'。 +- 已同步更新 `create_db.py`(SPU 表加 country 列)和 `sync_from_pg.py`(SPU 插入含 country='US',SKU 用固定 sgroup/stype),重跑验证通过:SPU 59 / SKU 1400 / country 全 US / size_group、size_type 各 1 个唯一值。 + +## SKU 增加 package_weight(包装重量) +- SKU 表新增 `package_weight` 列,来源 = `product_extra.packaging_spec` JSON 的"含包装重量(g)"列(单位:克),新增 `parse_weight_json()` 解析函数。 +- 匹配兜底(重要): + 1. 精确匹配尺码 → 失败则取 '/' 前部分(家居类 '30*40/76.2*101.6' vs '30*40') + 2. 均码同义词兜底('Onesize'/'OneSize'/'均码' 互相匹配,YSM02 案例) +- 结果:填充 1340/1400(95.7%),仅 5000B 无数据(PG 无 packaging_spec,合理留空)。重量范围 16~847 g。 +- create_db.py 的 SKU 定义同步加了 package_weight 列。 diff --git a/db/.workbuddy/memory/2026-08-20.md b/db/.workbuddy/memory/2026-08-20.md new file mode 100644 index 0000000..5c6234b --- /dev/null +++ b/db/.workbuddy/memory/2026-08-20.md @@ -0,0 +1,64 @@ +# 2026-08-20 工作日志 + +## SPU 增加 mark 字段 + 批量替换固定值 +- SPU 表新增 `mark` 列(TEXT),全部 59 条 SPU 设为 '1'。 +- 按用户要求用 SQL 批量替换 SPU 15 个字段为统一模板值: + pattern=印花、details=无、collar_style=圆领、style=休闲、care_Instructions=数码印花类可机洗且不可干洗、fabric=微弹、target_audience=成人、season=四季、is_transparent=否、layout=H、weaving_method=针织(含钩织、毛织面料)、printing_type=定位印花、fabric_texture_1=光面、fabric_weight_unit_1=g/㎡、lining_texture=无里料/无内衬。 +- **注意**:这些字段不再随 PG 数据变化(原 details=english_name、fabric=品类面料、target_audience=美国男装等已被覆盖),sync_from_pg.py 的 SPU 插入已改为 fixed 模板值 + mark='1'。 +- 保留的 PG 映射字段:material(20 个唯一值)、component_1~3/比例、fabric_weight_1(克重,如 180/207/270)。 +- create_db.py 的 SPU 定义同步加了 mark 列。重跑验证:SPU 59 / SKU 1400,16 个固定值字段各仅 1 个唯一值。 +- 踩坑:SPU INSERT 的占位符一度写成 27 个 ? 对 26 列(报 27 values for 26 columns),已修正。 + +## component_proportion 字段转百分比 +- SPU 的 component_proportion_1/2/3 由数值(100、100.0、92.0…)改为百分比形式(100%、92%…),空值保持不变。 +- SQL:`UPDATE SPU SET component_proportion_x = CAST(CAST(component_proportion_x AS REAL) AS INTEGER) || '%' WHERE ... IS NOT NULL AND <> ''`(当前值全为整数小数,无精度损失)。 +- sync_from_pg.py 新增 `format_pct()` 函数(整数值去 .0 加 %,带小数保留,非数字原样),SPU 插入时对 p1/p2/p3 应用。重跑验证:p2 空 33、p3 空 51 保持,格式全部正确。 + +## SPU layout 字段改为"常规" +- SPU.layout 由 'H' 全部替换为 '常规'(59 条),sync_from_pg.py 的 fixed 模板值同步更新。 + +## 同步墨西哥货盘数据(替换美国数据) +- PG `plates` 表:`墨西哥` = plate_id 6(共 17 国,美国 2/3/4/5、日本 7、韩国 8、沙特 9、巴西 10、英国 11、加拿大 12、波兰 13、西班牙 14、德国 15、澳洲 16、意大利 17)。 +- 改造 sync_from_pg.py:顶部常量 `PLATE_IDS=(6,)` + `COUNTRY="MX"` 可切换国家;SPU 插入 country 用 COUNTRY;SKU 颜色增加 `clean_color_name()`(去尺码前缀如 'S-3XL黑色'→'黑色')与 `is_clean_code()`(code 须 '品类code-非中文后缀')兜底。 +- 结果:**SPU 8 / SKU 123**(颜色×尺码粒度),country 全 MX。8 个品类:HM01、MESXT001、METB001、METN001、METP001、PET001、SFB、TBB001。 +- 脏数据兜底:墨西哥 colors 有脏 code('METB001-S-3XL黑色'、'SFB-黑色'),清洗后 code 为 'METB001-黑色'/'SFB-黑色' 等(源库只有中文名、无拉丁编码,故 code 含中文属必然)。 +- 留空:仅 **SFB**(运动短裤)12 条 SKU 缺 price/package_weight/img_url,因 PG 中 SFB 无 prices 记录、无 product_extra(无 packaging JSON)、无 color_detail_images。SFB 尺码走 size_chart 回退(2 色×6 码)。 +- 注:本步将数据库内容由美国(US)整体替换为墨西哥(MX)——这是**错误的**,用户要求的是保留 US 再追加 MX。 + +## 修正:US + MX 双国累积同步(保留多国数据) +- sync_from_pg.py 重构为 `sync(plate_ids, country, clear_first)`: + - `clear_first=True` 先 DELETE 整库再插入(重置/单国全量);`clear_first=False` 仅追加,跳过已存在的 SPU code 与 SKU 组合 (spu_id, code, size)。 + - `__main__` 顺序执行:`sync((2,3,4,5),"US",clear_first=True)` 再 `sync((6,),"MX",clear_first=False)`。 +- 结果:**SPU 67(US 59 + MX 8)/ SKU 1523(US 1400 + MX 123)**,country 分布 `[('MX',8),('US',59)]`,全部保留。 +- 缺失 price/package_weight/img 共 72 条 = 5000B(US,60 条,源库无价格/包装/图)+ SFB(MX,12 条,源库无 prices/product_extra/图),属正常留空。 +- 顶部注释已列出全部 17 国 plate_id 映射,后续同步任意国家(如 JP=7/KR=8/GB=11/DE=15)追加进脚本的 `__main__` 调用即可,不会覆盖已有数据。 + +## 追加同步日本(JP)与英国(GB) +- JP = plate_id 7(15 品类,1 个缺 product_extra 走 size_chart 回退);GB = plate_id 11(15 品类,其中 4 个 code=NULL 被自动过滤,实际 11 有效品类,均有 product_extra)。 +- `__main__` 追加 `sync((7,), "JP", False)` 与 `sync((11,), "GB", False)`,整库重置顺序:US(全量) → MX → JP → GB(追加)。 +- 结果:**SPU 93(US59/MX8/JP15/GB11)/ SKU 1997(US1400/MX123/JP216/GB258)**,四国数据全部保留。 +- JP 字段填充:color/size 216/216,price 204/216,package_weight 200/216,img 204/216(缺项来自源库无价格/包装/图的品类)。GB:color/size/price/img 全填,package_weight 238/258,shoulder_width 236/258(裤子/裙无肩宽属正常)。 +- 颜色名正常(日文/英文均识别),尺码体系 JP=XS~L、GB=S~XL,均按颜色×尺码展开。 + +## SKU code 命名规则改为「款号-颜色的英文名」(用户要求) +- 问题:用户反馈「看不到日本 SKU」——实际 JP 数据齐全(216 条),根因是 JP `colors` 源数据存在脏 code(中文颜色名+尺码区间拼进 code,甚至整组颜色被拼成一条),旧兜底把它们变成 `JPHM009-灰色(S~XXL)` 这类难看 code,看起来像坏数据。 +- 新规则(`sync_from_pg.py`):SKU code 规范(品类code-纯字母数字后缀,如 `JPHM009-BL01`/`JPTM007-ESPRESSO`)直接用;**缺失/脏 code → 款号(品类code)-颜色的英文名**。 +- 英文名来源:PG `color_detail_images.color_name_web`(按 color_id 取,如 Black/White/Navy/Apricot);该表无值则用 `ZH_TO_EN` 中文→英文字典兜底;多色拼接脏名取首个可识别颜色英文名(`first_color_en()`)。 +- `is_clean_code()` 收窄:后缀须为纯字母数字,含中文/`~`/`()` 一律判为脏(修复纯尺码区间假颜色 `JPHM009-(S~XXXL)` 被误判规范的问题);纯尺码行 code 仅保留款号,color=NULL。 +- `clean_color_name()` 增强:额外去掉结尾尺码区间括号(如 `灰色(S~XXL)`→`灰色`)。 +- 全量重跑(先备份 spu_sku.db.bak):SPU/SKU 总数不变(93/1997),JP 残留中文/`~` 的 code 由多条降为 **0**。 +- **已知源数据坑**:`JPTM001` 的某条 `colors` 记录把 5 个颜色(白/杏/藏青/灰/粉)拼进同一 code 字段,脚本只能取首个颜色英文名兜底为 `JPTM001-White`,无法还原成 5 条独立颜色 SKU——需在 PG 侧修源。 +- 结论:加其他国家只需在 `__main__` 追加 `sync((plate_id,), "国家码", False)`,命名规则自动生效。 + +## 清洗 JPTM001 脏 colors + 重新同步(用户要求"先清洗再导入sku") +- PG 侧:`colors` 表 id=357 把 5 个颜色(白/杏/藏青/灰/粉)拼进同一条 code(源数据损坏);其 `color_detail_images` 本就有 5 个独立颜色(White/Apricot/Deep Navy Blue/Gray/Pink,各 9 图)但 `color_id` 全为 NULL——脏记录是个未被图片引用的孤儿。 +- 清洗(事务提交):DELETE id=357;INSERT 5 条规范 `colors`(code=JPTM001-WH01/AP01/BE01/GR02/PK01,name 中文,size_range=XS-XXXL);UPDATE `color_detail_images.color_id` 指回新 id(白/杏/藏青/灰/粉 各 9 行 + 黑 9 行全关联成功)。 +- 颜色 code 缩写约定:白=WH01、杏=AP01、藏青=BE01、灰=GR02、粉=PK01(粉色无先例新编 PK01;藏青沿用 JPTM006 的 BE01)。 +- 重跑 `sync_from_pg.py` 全量:JPTM001 SKU 由 2 色(14 条)→6 色(42 条);全局 SKU 1997→2025(+28),SPU 93 不变。脏 `JPTM001-White` 长串记录已清除。 +- 注意:四国(US/MX/JP/GB)还存在其它"多色拼接"脏 colors 记录(一条 code 含 ≥2 个颜色,如 DG102/DG120/DG205/DG701/VS002/JSA004/GBHM002/GBTF004 等)。JPTM001 已修,其余待用户决定是否同法清洗(每条需先核 color_detail_images 是否可按独立颜色拆分)。 + +## SKU 尺码字段按国家区分(仅 JP=亚洲,其余=欧美) +- 用户最初要求 US/JP 改为亚洲尺码,后纠正:**US 不改**,仅 JP 走亚洲尺码。 +- 最终状态:`size_group`/`size_type` —— JP=亚洲尺码/亚洲尺码亚洲常规(244),US/GB/MX=尺码/欧美尺码常规(1781)。全表 SKU 仍 2025。 +- 回滚备份:spu_sku.db.bak_usjp(US+JP 都改错的版本)、spu_sku.db.bak_revert_us(改回 US 后的版本)。 +- 规律:尺码体系与国家挂钩——**仅 JP=亚洲尺码亚洲常规**,US/GB/MX=欧美尺码常规。后续加国家时按此约定(亚洲市场如 JP/KR 走亚洲,欧美市场 US/GB/MX/DE 走欧美),或写进 sync_from_pg.py 按 country 映射自动填。 diff --git a/db/.workbuddy/memory/MEMORY.md b/db/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..18dac12 --- /dev/null +++ b/db/.workbuddy/memory/MEMORY.md @@ -0,0 +1,25 @@ +# 项目长期记忆:Inkreach POD 数据库同步 + +## PG 库连接 +- host=localhost, port=5432, user=postgres, password=inkreach, dbname=inkreach +- Python 驱动 psycopg2 装在隔离 venv:`C:/Users/Admin/.workbuddy/binaries/python/envs/default/Scripts/python.exe` + +## 国家货盘映射(plates 表 id → 国家) +- 美国 2/3/4/5、墨西哥 6、日本 7、韩国 8、沙特 9、巴西 10、英国 11、加拿大 12、波兰 13、西班牙 14、德国 15、澳洲 16、意大利 17 + +## SPU/SKU 表结构(SQLite spu_sku.db,字段不可改) +- SPU(25+ 业务字段 + country + mark):code/material/component_1~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 +- SPU 固定模板值(不随 PG 变):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 +- SKU(19 字段):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~5 + +## 同步脚本 sync_from_pg.py 规律 +- SPU = categories(按 code 去重);SKU = colors × product_size/ size_chart 尺码(颜色×尺码粒度) +- 业务模板固定值写入 SPU,PG 仅提供 material、component、fabric_weight(从 name 提取克重)、country +- SKU code = colors.code(脏数据兜底为 品类code-颜色名);color = 清洗后颜色名;price = 该品类 prices 首条;包装尺寸/重量 = product_extra.packaging_spec JSON;尺码表 = product_extra.product_size JSON(缺则回退 size_chart) +- 切换国家:改顶部 `PLATE_IDS` 与 `COUNTRY` 后重跑(会 DELETE 两表再插入) +- 脏颜色 code 处理:clean_color_name() 去尺码前缀;is_clean_code() 判断规范,不规范则 品类code-颜色名 兜底(源库无拉丁编码时 code 含中文属必然) + +## 已知数据坑 +- 部分品类 colors.code 为脏数据(如 'METB001-S-3XL黑色'、'5000B-黑色'、'SFB-黑色')→ 兜底补全 +- 部分品类无 product_extra(如 SFB/5000B)→ 尺码走 size_chart 回退,包装/重量留空 +- 家居类尺码格式不一致('30*40/76.2*101.6' vs '30*40')、均码叫法不同(Onesize vs 均码)→ 已加归一化兜底 diff --git a/db/create_db.py b/db/create_db.py new file mode 100644 index 0000000..4975e8c --- /dev/null +++ b/db/create_db.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import sqlite3 + +db_path = r"C:\Users\Admin\Desktop\test模版\design_agent\pod_trend_agent\db\spu_sku.db" + +conn = sqlite3.connect(db_path) +cur = conn.cursor() + +# 若表已存在则先删除(重建表结构) +cur.execute("DROP TABLE IF EXISTS SKU") +cur.execute("DROP TABLE IF EXISTS SPU") + +# ---------- SPU 表 ---------- +cur.execute(""" +CREATE TABLE SPU ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT, + material TEXT, + component_1 TEXT, + component_2 TEXT, + component_3 TEXT, + component_proportion_1 TEXT, + component_proportion_2 TEXT, + component_proportion_3 TEXT, + pattern TEXT, + details TEXT, + collar_style TEXT, + style TEXT, + care_Instructions TEXT, + fabric TEXT, + target_audience TEXT, + season TEXT, + is_transparent TEXT, + layout TEXT, + weaving_method TEXT, + printing_type TEXT, + fabric_texture_1 TEXT, + fabric_weight_1 TEXT, + fabric_weight_unit_1 TEXT, + lining_texture TEXT, + country TEXT, + mark TEXT +) +""") + +# ---------- SKU 表 ---------- +cur.execute(""" +CREATE TABLE SKU ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spu_id INTEGER NOT NULL, + code TEXT, + price REAL, + color TEXT, + size TEXT, + size_group TEXT, + size_type TEXT, + shoulder_width TEXT, + bust TEXT, + clothing_length TEXT, + sleeve_length TEXT, + longest_side TEXT, + secondary_long_side TEXT, + shortest_side TEXT, + package_weight TEXT, + img_url_2 TEXT, + img_url_3 TEXT, + img_url_4 TEXT, + img_url_5 TEXT, + FOREIGN KEY (spu_id) REFERENCES SPU(id) +) +""") + +# 索引:SKU 按 spu_id 快速查询 +cur.execute("CREATE INDEX idx_sku_spu_id ON SKU(spu_id)") + +conn.commit() + +# ---------- 验证 ---------- +cur.execute("SELECT name FROM sqlite_master WHERE type='table'") +tables = cur.fetchall() +print("数据库文件:", db_path) +print("表列表:", [t[0] for t in tables]) + +cur.execute("PRAGMA table_info(SPU)") +spu_cols = cur.fetchall() +print("\nSPU 字段 ({}个):".format(len(spu_cols))) +for row in spu_cols: + print(" ", row[1], "|", row[2], "| 主键" if row[5] else "") + +cur.execute("PRAGMA table_info(SKU)") +sku_cols = cur.fetchall() +print("\nSKU 字段 ({}个):".format(len(sku_cols))) +for row in sku_cols: + print(" ", row[1], "|", row[2], "| 主键" if row[5] else "") + +conn.close() +print("\n创建完成") diff --git a/db/spu_sku.db b/db/spu_sku.db new file mode 100644 index 0000000..bb2ddaa Binary files /dev/null and b/db/spu_sku.db differ diff --git a/db/spu_sku.db.bak b/db/spu_sku.db.bak new file mode 100644 index 0000000..86d58dc Binary files /dev/null and b/db/spu_sku.db.bak differ diff --git a/db/spu_sku.db.bak_gb b/db/spu_sku.db.bak_gb new file mode 100644 index 0000000..af5fd0e Binary files /dev/null and b/db/spu_sku.db.bak_gb differ diff --git a/db/spu_sku.db.bak_revert_us b/db/spu_sku.db.bak_revert_us new file mode 100644 index 0000000..cc374ed Binary files /dev/null and b/db/spu_sku.db.bak_revert_us differ diff --git a/db/spu_sku.db.bak_usjp b/db/spu_sku.db.bak_usjp new file mode 100644 index 0000000..d9cce4a Binary files /dev/null and b/db/spu_sku.db.bak_usjp differ diff --git a/db/sync_from_pg.py b/db/sync_from_pg.py new file mode 100644 index 0000000..be3ef23 --- /dev/null +++ b/db/sync_from_pg.py @@ -0,0 +1,537 @@ +# -*- 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" + + +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) + # 按用户要求:size_group 固定为"尺码",size_type 固定为"欧美尺码常规" + sgroup = "尺码" + stype = "欧美尺码常规" + + 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__": + # 顺序执行:先全量同步美国(重置整库),再依次追加其他国家(保留已有数据) + sync((2, 3, 4, 5), "US", clear_first=True) + sync((6,), "MX", clear_first=False) + sync((7,), "JP", clear_first=False) + sync((11,), "GB", clear_first=False) + print("\n✅ 同步完成:SPU/SKU 已包含 US + MX + JP + GB 数据") diff --git a/graph/__init__.py b/graph/__init__.py new file mode 100644 index 0000000..241d106 --- /dev/null +++ b/graph/__init__.py @@ -0,0 +1,13 @@ +"""POD 热点抓取 Agent(LangGraph 工程化版本)。 + +架构: +- graph/state.py : 共享状态 AgentState +- graph/validate.py : 节点级兜底(with_fallback)+ 数据校验 +- graph/sources/ : 数据源可插拔(GoogleTrends / Pinterest ...) +- graph/llms/ : LLM 后端可插拔(Mock / OpenAI 兼容 ...) +- graph/nodes/ : 6 个流水线节点(fetch/filter/score/screen/prompt_build/compose) +- graph/agent.py : 构建并编译 StateGraph,提供 run_country() + +每个国家独立处理:prompts// 放该国专属提示词与审美规则, +output// 放该国产物。节点全部带兜底,单点失败不影响整图。 +""" diff --git a/graph/agent.py b/graph/agent.py new file mode 100644 index 0000000..34541bd --- /dev/null +++ b/graph/agent.py @@ -0,0 +1,111 @@ +"""构建并编译 LangGraph,提供 run_country() 入口。 + +图结构(线性流水线,节点全部带兜底): + START -> seed -> fetch -> filter -> score -> screen -> prompt_build + -> compose(生成纯印花设计稿 + 导出简报)-> product(底图/模特/三图合成/模板) + -> oss_upload(压缩 3:4 / ≥1340×1785 / <2MB + 上传阿里云 OSS)-> END +""" +import time +from pathlib import Path +from typing import Any, Dict, Optional + +from langgraph.graph import END, StateGraph + +from graph.loader import build_country_config +from graph.nodes import ( + compose_node, + fetch_node, + filter_node, + oss_upload_node, + product_node, + prompt_node, + score_node, + screen_node, + seed_node, + seed_shot_node, + template_export_node, +) +from graph.state import AgentState + + +def build_graph(): + """构建 StateGraph 并编译。""" + builder = StateGraph(AgentState) + builder.add_node("seed", seed_node) + builder.add_node("fetch", fetch_node) + builder.add_node("filter", filter_node) + builder.add_node("score", score_node) + builder.add_node("screen", screen_node) + builder.add_node("prompt_build", prompt_node) + builder.add_node("product", product_node) + builder.add_node("compose", compose_node) + builder.add_node("oss_upload", oss_upload_node) + builder.add_node("seed_shot", seed_shot_node) + builder.add_node("template_export", template_export_node) + + builder.add_edge("__start__", "seed") + builder.add_edge("seed", "fetch") + builder.add_edge("fetch", "filter") + builder.add_edge("filter", "score") + builder.add_edge("score", "screen") + builder.add_edge("screen", "prompt_build") + builder.add_edge("prompt_build", "compose") # compose:生成纯印花设计稿(放前面) + builder.add_edge("compose", "product") # product:底图/模特/三图合成/模板 + builder.add_edge("product", "oss_upload") # oss_upload:压缩 + 上传图床 + builder.add_edge("oss_upload", "seed_shot") # seed_shot:种草图生成(模板+模特特征 yaml)→ 上传 + builder.add_edge("seed_shot", "template_export") # template_export:最终结果导入商品上传模板 + builder.add_edge("template_export", END) + return builder.compile() + + +def run_country( + country: str, + global_config: Dict[str, Any], + project_root: Path, + output_root: Optional[Path] = None, + base_image: Optional[str] = None, + task_timestamp: Optional[str] = None, +) -> Dict[str, Any]: + """运行单个国家的完整流水线,返回最终 state(含 errors / stats / briefs)。 + + project_root:数据文件根(configs / prompts,打包后为 _MEIPASS 只读目录)。 + output_root :产物输出根(默认=project_root;打包后传 exe 旁运行目录, + 避免把 output/ 写进临时解压目录导致重启丢失)。 + task_timestamp:任务时间戳(每次点击运行 = 一个任务);None 时自动生成。 + """ + compiled = build_graph() + cc = build_country_config(global_config, country, project_root) + prompts_dir = project_root / "prompts" / country + cache_dir = (output_root or project_root) / "output" / country # 缓存/去重(根目录) + ts = task_timestamp or time.strftime("%Y%m%d_%H%M%S") + _base = ts + _i = 1 + while (cache_dir / ts).exists(): # 时间戳文件夹唯一(同秒多任务防冲突/覆盖) + ts = f"{_base}_{_i}" + _i += 1 + output_dir = cache_dir / ts # 本次任务产物(时间戳文件夹) + + state: Dict[str, Any] = { + "country": country, + "config": global_config, + "country_config": cc, + "prompts_dir": str(prompts_dir), + "cache_dir": str(cache_dir), + "output_dir": str(output_dir), + "raw_rows": [], + "filtered_rows": [], + "scored_rows": [], + "screened": [], + "briefs": [], + "composite": [], + "designs": [], + "errors": [], + "stats": {}, + "task_timestamp": ts, # 任务开始时间戳(OSS 路径段 / 产物文件夹名) + "oss_seq": 0, # 货号计数(000 起,最多 999) + } + if base_image: + state["base_image"] = base_image + + result = compiled.invoke(state) + return result diff --git a/graph/backends/__init__.py b/graph/backends/__init__.py new file mode 100644 index 0000000..f4b2018 --- /dev/null +++ b/graph/backends/__init__.py @@ -0,0 +1,22 @@ +"""图像后端注册表(可插拔:印到底图 / 模特试穿 / 占位)。 + +compose_node / product_node 在配置 backend 时调用。默认未配置则跳过,仅导出提示词。 +新增图像后端:实现 graph/backends/base.ImageBackend,在此登记。 +""" +from typing import Dict + +from .base import ImageBackend +from .openai_image_backend import OpenAIImageBackend +from .mock_image_backend import MockImageBackend + +IMAGE_BACKENDS: Dict[str, type] = { + "openai": OpenAIImageBackend, # 真生图(images/edits img2img,需 api_key) + "mock": MockImageBackend, # 占位图(Pillow,无 key 也能端到端演示) +} + + +def get_image_backend(name: str): + cls = IMAGE_BACKENDS.get(name) + if cls is None: + return None + return cls() diff --git a/graph/backends/base.py b/graph/backends/base.py new file mode 100644 index 0000000..d942efa --- /dev/null +++ b/graph/backends/base.py @@ -0,0 +1,28 @@ +"""图像后端抽象接口(可插拔)。 + +- print(prompt, base_image, out_path, negative, extra_images): + 以 base_image 为底图(+ extra_images 多参考图,按顺序追加)按 prompt 生成成品图。 + product 流水线用法: + * 纯印花设计稿 → generate() + * 平铺服装图(底图+印花)→ print(base=底图, extra=[设计稿]) + * 三图模特合成 → print(base=模特图, extra=[设计稿, 底图])(图1=模特, 图2=印花, 图3=底图) +- generate(prompt, out_path, negative):纯文生图(无参考图),用于生成白底纯印花设计稿。 +""" +from abc import ABC, abstractmethod +from typing import Optional, Sequence + + +class ImageBackend(ABC): + name: str = "base" + + @abstractmethod + def print(self, prompt: str, base_image: str, out_path: str, negative: str = "", + extra_images: Optional[Sequence[str]] = None, size: str = "") -> str: + """返回生成的成品图路径。实现内部应处理调用失败/超时并抛异常由调用方兜底。 + size: 显式尺寸覆盖(如 "1504x2000");留空则用后端配置的 size。""" + raise NotImplementedError + + def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str: + """纯文生图(无参考图),默认退化为 print 不支持则抛异常。 + size: 显式尺寸覆盖;留空则用后端配置的 size。""" + raise NotImplementedError(f"{self.name} 后端不支持纯文生图(generate)") diff --git a/graph/backends/mock_image_backend.py b/graph/backends/mock_image_backend.py new file mode 100644 index 0000000..b7ac987 --- /dev/null +++ b/graph/backends/mock_image_backend.py @@ -0,0 +1,65 @@ +"""Mock 图像后端:Pillow 生成占位图。 + +无 OpenAI key 时也能端到端演示产品流水线(选品 → 底图 → "印花图" → "模特合成" 产物齐全)。 +占位图 = 参考图尺寸 + 文字标注(提示词摘要),明确标识 [MOCK] 避免误用。 +""" +from pathlib import Path + +from PIL import Image, ImageDraw + +from .base import ImageBackend + + +class MockImageBackend(ImageBackend): + name = "mock" + + def __init__(self): + self._cfg: dict = {} + + def bind_config(self, cfg: dict): + self._cfg = cfg or {} + + def print(self, prompt: str, base_image: str, out_path: str, negative: str = "", + extra_images=None, size: str = "") -> str: + size_px = (1024, 1024) + try: + with Image.open(base_image) as im: + size_px = im.size + except Exception: + pass + if size: + try: + w, h = (int(x) for x in str(size).lower().split("x")) + size_px = (w, h) + except Exception: + pass + img = Image.new("RGB", size_px, (240, 240, 248)) + d = ImageDraw.Draw(img) + d.rectangle([0, 0, size_px[0] - 1, size_px[1] - 1], outline=(180, 180, 200)) + d.text((24, 24), f"[MOCK] {Path(out_path).name}", fill=(50, 50, 80)) + d.text((24, 56), "(未配置 OpenAI key,占位图演示流程)", fill=(120, 120, 150)) + d.text((24, 96), (prompt or "")[:160], fill=(90, 90, 120)) + if extra_images: + d.text((24, 136), f"参考图 {len(list(extra_images))} 张: " + ", ".join(Path(p).name[:24] for p in extra_images), fill=(90, 90, 120)) + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + img.save(out_path) + return out_path + + def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str: + """纯文生图(mock):白底 + 文字标注,模拟纯印花设计稿。""" + size_px = (1024, 1024) + if size: + try: + w, h = (int(x) for x in str(size).lower().split("x")) + size_px = (w, h) + except Exception: + pass + img = Image.new("RGB", size_px, (252, 252, 252)) + d = ImageDraw.Draw(img) + d.rectangle([0, 0, size_px[0] - 1, size_px[1] - 1], outline=(200, 200, 210)) + d.text((24, 24), f"[MOCK DESIGN] {Path(out_path).name}", fill=(50, 50, 80)) + d.text((24, 56), "(纯印花设计稿占位,白底)", fill=(120, 120, 150)) + d.text((24, 96), (prompt or "")[:160], fill=(90, 90, 120)) + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + img.save(out_path) + return out_path diff --git a/graph/backends/openai_image_backend.py b/graph/backends/openai_image_backend.py new file mode 100644 index 0000000..07ce8db --- /dev/null +++ b/graph/backends/openai_image_backend.py @@ -0,0 +1,242 @@ +"""OpenAI 图像后端(生成设计稿 + 三图合成)。 + +支持 OpenAI images/generations(文生图)与 images/edits(img2img 多参考图), +兼容两类网关返回: + 1) 同步:{"data": [{"b64_json" | "url"}]} + 2) 异步任务(ai-media.vip 等重负载自动转异步): + 提交返回 202 + {"object":"image.task","task_id","poll_url","poll_after_ms"}, + 需轮询 GET {base}/images/tasks/{task_id} 直到 succeeded,再从成功响应取图。 + +所有请求跳过环境代理(NO_PROXY),适配用户挂 VPN 时直连国内/自建网关。 +""" +import base64 +import time +from pathlib import Path +from typing import Optional + +import requests +import os + +# 模型调用一律直连:用户常开 VPN(系统代理),网关多为国内/自建,走代理会被拦截或变慢。 +# 环境变量级 NO_PROXY 双保险(requests/urllib3 均读取),Google 采集(pytrends)不受影响。 +os.environ.setdefault('NO_PROXY', '*') +os.environ.setdefault('no_proxy', '*') + +from .base import ImageBackend + +# 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求) +NO_PROXY = {"http": None, "https": None} + +_FINAL_STATUS = ("succeeded", "completed", "done") +_FAIL_STATUS = ("failed", "error") + + +def _shrink_blob_to_2mb(img_path: str, blob: bytes, max_bytes: int = 2 * 1024 * 1024) -> bytes: + """把大图压缩到 max_w or img.height > max_h: + img.thumbnail((max_w, max_h)) + has_alpha = img.mode in ("RGBA", "LA") + if not has_alpha: + img = img.convert("RGB") + for q in (88, 70, 50, 35): + buf = io.BytesIO() + if has_alpha: + img.save(buf, format="PNG", optimize=True) + else: + img.save(buf, format="JPEG", quality=q) + if buf.tell() <= max_bytes: + return buf.getvalue() + # 保底:最小质量 PNG/JPEG + buf = io.BytesIO() + if has_alpha: + img.save(buf, format="PNG", optimize=True) + else: + img.save(buf, format="JPEG", quality=30) + return buf.getvalue() + except Exception as e: # noqa: BLE001 + print(f"[img] 图片压缩失败(用原图): {e}") + return blob + + +def _save_from_response(j: dict, out_path: str) -> str: + """从同步/异步最终响应提取图片(data[].b64_json 或 url)并保存。""" + data_item = (j.get("data") or [{}])[0] + b64 = data_item.get("b64_json") + if b64: + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + Path(out_path).write_bytes(base64.b64decode(b64)) + return out_path + url = data_item.get("url") + if not url: + raise RuntimeError(f"图像 API 返回无 b64_json/url: {str(j)[:200]}") + img_resp = requests.get(url, proxies=NO_PROXY, timeout=120) + img_resp.raise_for_status() + Path(out_path).parent.mkdir(parents=True, exist_ok=True) + Path(out_path).write_bytes(img_resp.content) + return out_path + + +def _wait_task(base_url: str, headers: dict, task_id: str, poll_after_ms: int, + timeout: int = 60) -> dict: + """轮询异步图像任务直到完成;uncertain(结果暂时不确定)继续等,不重复提交。 + timeout 默认 60s:异步路径不稳定(ai-media 网关常 uncertain/task not found), + 超时即抛异常由调用方重试同步提交。""" + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + time.sleep(max(int(poll_after_ms or 2000), 2000) / 1000.0) + try: + resp = requests.get(f"{base_url}/images/tasks/{task_id}", headers=headers, + timeout=60, proxies=NO_PROXY) + if resp.status_code >= 400: + continue + j = resp.json() + except Exception as e: # noqa: BLE001 + print(f"[img] 任务轮询异常(重试): {e}") + continue + st = j.get("status") + if st != last: + print(f"[img] 异步任务 {task_id[:20]}… status={st}") + last = st + if st in _FINAL_STATUS: + return j + if st in _FAIL_STATUS: + raise RuntimeError(f"图像异步任务失败: {j.get('error')} {j.get('error_code')}") + # queued / running / uncertain → 继续等 + raise RuntimeError(f"图像异步任务超时({timeout}s)") + + +def _resolve_task_or_sync(j: dict, base_url: str, headers: dict, out_path: str) -> str: + """提交后的统一处理:异步任务则轮询,然后提取图片保存。""" + if j.get("object") == "image.task" or j.get("task_id") or j.get("id", "").startswith("imgtask"): + tid = j.get("task_id") or j.get("id") or "" + if not tid: + raise RuntimeError(f"异步任务无 task_id: {str(j)[:200]}") + j = _wait_task(base_url, headers, tid, int(j.get("poll_after_ms") or 2000)) + return _save_from_response(j, out_path) + + +class OpenAIImageBackend(ImageBackend): + name = "openai" + + def __init__(self): + self._cfg: dict = {} + + def bind_config(self, cfg: dict): + self._cfg = cfg or {} + + def _base_url(self) -> str: + return str(self._cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/") + + def print(self, prompt: str, base_image: str, out_path: str, negative: str = "", + extra_images=None, size: str = "") -> str: + """img2img 编辑:参考图 base_image(+ 可选 extra_images 多参考图)按 prompt 生成新图。 + + - 平铺服装图:base_image=平铺衣服底图(图3),extra_images=[印花设计稿(图2)] + - 三图模特合成:base_image=模特图(图1),extra_images=[印花设计稿(图2), 平铺底图(图3)] + 提交顺序即图1→图2→图3,与提示词中的图片角色一一对应。 + size: 显式尺寸覆盖(如 "1504x2000");留空用配置 size(默认 1024x1024)。 + """ + cfg = self._cfg + api_key = cfg.get("api_key", "") + model = cfg.get("model", "gpt-image-1") + if not api_key: + raise RuntimeError("compose.api_key 未配置,无法调用 OpenAI 图像后端。") + + full_prompt = prompt + (f"\nNegative: {negative}" if negative else "") + base_url = self._base_url() + headers = {"Authorization": f"Bearer {api_key}"} + # 必须把文件读成 bytes 再提交(句柄随 with 关闭会导致 "read of closed file"); + # 输入图(模特/设计/底图)先压缩校验:>2MB 压缩到 <2MB 再上传,避免大图拖垮网关导致超时 + file_payloads = [] + for img_path in [base_image] + list(extra_images or []): + blob = Path(img_path).read_bytes() + if len(blob) > 2 * 1024 * 1024: + shrunk = _shrink_blob_to_2mb(img_path, blob) + print(f"[img] 输入图压缩: {Path(img_path).name} {len(blob)//1024}KB → {len(shrunk)//1024}KB(<2MB)") + blob = shrunk + file_payloads.append((Path(img_path).name, blob)) + files = [("image", (name, blob, "image/png")) for name, blob in file_payloads] + data = { + "prompt": full_prompt, + "n": 1, + "size": size or cfg.get("size", "1024x1024"), + "model": model, + "execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定) + } + # 提交重试:异步路径不稳定 → 失败重试同步提交(最多 3 次); + # 内容政策拦截(content_policy_violation)多为网关误判 → 等待后重试 + last_err: Optional[str] = None + for attempt in range(3): + resp = requests.post(f"{base_url}/images/edits", headers=headers, files=files, + data=data, timeout=300, proxies=NO_PROXY) + if resp.status_code >= 400: + body = resp.text or "" + if "content_policy" in body and attempt < 2: + print(f"[img] 内容政策拦截(可能误判),等待后重试 {attempt + 2}/3") + time.sleep(2) + continue + if attempt == 0: + data.pop("execution_mode", None) # 网关不认识该参数(官方 OpenAI)→ 去掉重试 + continue + raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}") + try: + return _resolve_task_or_sync(resp.json(), base_url, headers, out_path) + except Exception as e: # noqa: BLE001 + last_err = str(e) + print(f"[img] 第 {attempt + 1} 次提交异步失败,重试同步提交: {e}") + raise RuntimeError(f"图像合成多次提交均失败: {last_err}") + + def generate(self, prompt: str, out_path: str, negative: str = "", size: str = "") -> str: + """纯文生图:生成白底纯印花设计稿(standalone pure print design)。 + size: 显式尺寸覆盖(印花设计统一 1024x1024);留空用配置 size。 + background: 配置 compose.background="transparent" 时传 background 参数 → 透明背景 PNG + (gpt-image-1/2 等模型支持;网关不支持该参数时会被忽略或由网关兜底)。""" + cfg = self._cfg + api_key = cfg.get("api_key", "") + model = cfg.get("model", "gpt-image-1") + if not api_key: + raise RuntimeError("compose.api_key 未配置,无法调用 OpenAI 图像后端。") + + full_prompt = prompt + (f"\nNegative: {negative}" if negative else "") + base_url = self._base_url() + headers = {"Authorization": f"Bearer {api_key}"} + data = { + "prompt": full_prompt, + "n": 1, + "size": size or cfg.get("size", "1024x1024"), + "model": model, + "response_format": "b64_json", + "execution_mode": "sync", # 强制同步(ai-media 等网关默认重负载转异步任务,不稳定) + } + bg = str(cfg.get("background") or "").strip() + if bg: + data["background"] = bg # 如 "transparent"(透明背景 PNG) + last_err: Optional[str] = None + for attempt in range(3): + resp = requests.post(f"{base_url}/images/generations", headers=headers, json=data, + timeout=300, proxies=NO_PROXY) + if resp.status_code >= 400: + body = resp.text or "" + if "content_policy" in body and attempt < 2: + print(f"[img] 内容政策拦截(可能误判),等待后重试 {attempt + 2}/3") + time.sleep(2) + continue + if attempt == 0: + data.pop("execution_mode", None) # 网关不认识该参数(官方 OpenAI)→ 去掉重试 + continue + raise RuntimeError(f"图像 API {resp.status_code}: {body[:300]}") + try: + return _resolve_task_or_sync(resp.json(), base_url, headers, out_path) + except Exception as e: # noqa: BLE001 + last_err = str(e) + print(f"[img] 第 {attempt + 1} 次生成异步失败,重试同步提交: {e}") + raise RuntimeError(f"图像生成多次提交均失败: {last_err}") diff --git a/graph/classify.py b/graph/classify.py new file mode 100644 index 0000000..4f3b8d0 --- /dev/null +++ b/graph/classify.py @@ -0,0 +1,47 @@ +"""热点类型分类(规则版)+ Prompt 灵感生成。 + +后续可把 classify() 换成一次 LLM 调用,产出更准的 Event/Meme/Style/Niche 标签。 +""" +from typing import Dict, List + +EVENT_WORDS: List[str] = [ + "eclipse", "olympic", "olympics", "christmas", "halloween", "election", + "thanksgiving", "valentine", "new year", "festival", "concert", "super bowl", + "world cup", "graduation", "wedding", "birthday", "2024", "2025", "2026", +] +MEME_WORDS: List[str] = [ + "meme", "funny", "lol", "joke", "relatable", "viral", "sarcasm", + "hilarious", "pun", "cat", "dog", +] +STYLE_WORDS: List[str] = [ + "vintage", "retro", "kawaii", "minimalist", "anime", "grunge", "boho", + "aesthetic", "streetwear", "gothic", "pastel", "vaporwave", "90s", "80s", + "cottagecore", "y2k", "punk", "minimal", +] + + +def classify(topic: str) -> str: + t = (topic or "").lower() + if any(w in t for w in EVENT_WORDS): + return "Event" + if any(w in t for w in MEME_WORDS): + return "Meme" + if any(w in t for w in STYLE_WORDS): + return "Style" + return "Niche" + + +PROMPT_TEMPLATES: Dict[str, str] = { + "Event": "A vintage poster style design of {topic}, distressed texture, bold typography, vector style, isolated on white background.", + "Meme": "A funny cartoon illustration of {topic}, bold comic style, high contrast, humorous pure print design, isolated on white background.", + "Style": "A {topic} aesthetic illustration, trendy color palette, clean vector graphics, pure print design, isolated on white background.", + "Niche": "A cute illustration of {topic}, kawaii style, flat design, pastel colors, high contrast, pure print design, isolated on white background.", +} + +# 通用负向约束:避免侵权与真实人物 +NEGATIVE = "no copyrighted characters, no real people, no brand logos, no trademarks, no politics, no religion, no hate, no violence, no sexual content, no readable text unless it is a short original English slogan, no gibberish text" + + +def prompt_suggestion(topic: str, type_: str) -> str: + tpl = PROMPT_TEMPLATES.get(type_, PROMPT_TEMPLATES["Niche"]) + return tpl.format(topic=topic) + " --no " + NEGATIVE diff --git a/graph/llms/__init__.py b/graph/llms/__init__.py new file mode 100644 index 0000000..b7da973 --- /dev/null +++ b/graph/llms/__init__.py @@ -0,0 +1,30 @@ +"""LLM 后端注册表(可插拔入口)。 + +config.yaml 的 llm_screen.provider 选择后端;OpenAI 兼容厂商(openai/deepseek/qwen/moonshot) +统一映射到 openai_compat 实现。 +""" +from typing import Dict + +from .base import LLMBackend +from .mock_backend import MockBackend +from .openai_compat_backend import OpenAICompatBackend, DEFAULT_SYSTEM_PROMPT + +LLM_BACKENDS: Dict[str, type] = { + "mock": MockBackend, + "openai_compat": OpenAICompatBackend, +} +# 厂商别名 -> openai_compat(它们都走 OpenAI 兼容协议) +_ALIASES: Dict[str, str] = { + "openai": "openai_compat", + "deepseek": "openai_compat", + "qwen": "openai_compat", + "moonshot": "openai_compat", +} + + +def get_backend(name: str) -> LLMBackend: + key = _ALIASES.get(name, name) + cls = LLM_BACKENDS.get(key) + if cls is None: + raise ValueError(f"未知 LLM 后端: {name},可用: {list(LLM_BACKENDS)} (+别名 {list(_ALIASES)})") + return cls() diff --git a/graph/llms/base.py b/graph/llms/base.py new file mode 100644 index 0000000..de56373 --- /dev/null +++ b/graph/llms/base.py @@ -0,0 +1,55 @@ +"""LLM 后端抽象接口(可插拔核心)。 + +新增一个 LLM 后端只需:① 继承 LLMBackend 实现 screen();② 在 graph/llms/__init__.py +的 LLM_BACKENDS 注册表里登记。config 的 ``llm_screen.provider`` 选择用哪个。 +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, List + + +class LLMBackend(ABC): + #: 注册名(与 config.llm_screen.provider 对应) + name: str = "base" + + @abstractmethod + def screen( + self, + topics: List[str], + country: str, + aesthetic_hint: str, + system_prompt: str, + blacklist: List[str], + batch_size: int = 12, + ) -> List[Dict[str, Any]]: + """对一批话题做合规筛查 + 结构化四要素提取,返回列表。 + + 每条结构(与旧 llm_screen.screen_combined 的 screened 一致): + { + "topic": <原始话题>, + "safe_for_print": bool, + "risk_level": "safe" | "review" | "blocked", + "risk_reasons": [str], + "suitable_for_print": bool, + "design_category": "Style"|"Meme"|"Event"|"Niche"|"Pattern"|"Quote"|"Failed", + "concept": <中文概念, 1 句>, + "motif": <英文主体>, + "art_style": <英文风格>, + "color_palette": <英文配色>, + "composition": <英文构图>, + "negative_prompt": <负向>, + "confidence": 0-1, + } + + 实现内部必须处理调用失败/超时,失败时抛出异常由节点降级逻辑接管(或直接返回兜底结果)。 + """ + raise NotImplementedError + + @abstractmethod + def generate_seeds(self, context: Dict[str, Any]) -> Dict[str, Any]: + """根据上下文(trending 派生 / 历史热点 / 月份节日)生成 Google Trends 相关查询种子词。 + + 返回 {"style_seeds": [str], "related_seeds": [str]}。 + context 字段:country, trending_seeds, history_hotspots, season, month_themes, upcoming_holidays。 + 实现内部必须处理调用失败/超时,失败时抛异常由 seed_node 降级逻辑接管。 + """ + raise NotImplementedError diff --git a/graph/llms/mock_backend.py b/graph/llms/mock_backend.py new file mode 100644 index 0000000..ac4e69d --- /dev/null +++ b/graph/llms/mock_backend.py @@ -0,0 +1,143 @@ +"""Mock LLM 后端:启发式兜底(无 key 也能端到端跑通)。 + +逻辑:黑名单硬拦 -> 常识风险词标 review -> 动态风格/配色推导 -> 分类。 +这是生产环境 LLM 不可用时的安全降级路径,保证流水线永远能产出可用结果。 +""" +from typing import Any, Dict, List + +from ..classify import classify, prompt_suggestion +from ..style_rules import derive_style_palette, derive_composition + + +def _dedup_limit(items: List[str], limit: int) -> List[str]: + """去重(大小写不敏感)并限量,保留首次出现顺序。""" + seen = set() + out: List[str] = [] + for it in items: + it = (it or "").strip() + if not it: + continue + low = it.lower() + if low in seen: + continue + seen.add(low) + out.append(it) + if len(out) >= limit: + break + return out + + +# 常识风险词(兜底用;真实判定交给 LLM)。同时被 seed_node 复用为「种子护栏」, +# 避免真人/IP/平台词作为 Google Trends 相关查询种子浪费抓取。 +COMMON_RISK_WORDS = [ + "disney", "marvel", "nike", "adidas", "apple", "iphone", "mcdonalds", + "mcdonald", "starbucks", "coca", "pepsi", "pokemon", "mario", "hello kitty", + "sanrio", "sonic", "minions", "barbie", "harry potter", "batman", "spiderman", + "star wars", "fortnite", "roblox", "minecraft", "tiktok", "netflix", "pearl jam", + "nirvana", "taylor swift", "trump", "biden", "kardashian", "lebron", "kanye", + "kick", "gta", "ufc", "westmeath", "lottery", "prison break", "margot robbie", + "dana white", "euro", "spotify", "youtube", "instagram", "xbox", "playstation", + "noah kahan", "gina carano", "camry", "hurricanes", "eras tour", + "springsteen", "reiner", "eliza lopes", "camilla", "h&m", "truck accident attorney", +] + +# 原创短标语池(mock 模式的 slogan;纯原创、无版权无品牌) +# 原创短标语池(mock 模式的 slogan;纯原创、无版权无品牌) +# 英语通用 + 各国语言(JP=日语短标语),按国家动态注入 +_STABLE_SLOGANS_EN = [ + "good vibes", "stay cozy", "happy place", "be kind", "dream big", + "keep smiling", "sunshine", "peace love", "stay wild", "pet the cat", + "coffee first", "tiny paws", "warm hugs", "soft life", "grow slowly", + "lucky charm", "sweet dreams", "go outside", "mindful", "lazy days", +] +_STABLE_SLOGANS_JP = [ + "ゆめいっぱい", "やさしい気持ち", "ずっと元気", "おだやかな日", "きょうもハッピー", + "ねこが好き", "いっしょにね", "ぽかぽか", "はるの風", "なつのおもいで", + "きらきら", "わくわく", "のんびり", "しあわせ", "えがお", +] + + +def _stable_slogan(topic: str, country: str = "") -> str: + """按主题哈希稳定选一条原创标语(同一主题缓存一致;mock 兜底用)。 + country=JP → 日语短标语;其他国家 → 英语。""" + import hashlib + pool = _STABLE_SLOGANS_JP if str(country).upper() == "JP" else _STABLE_SLOGANS_EN + h = int(hashlib.md5((topic or "").encode("utf-8")).hexdigest(), 16) + return pool[h % len(pool)] + + +class MockBackend: + name = "mock" + + def screen( + self, + topics: List[str], + country: str, + aesthetic_hint: str, + system_prompt: str, + blacklist: List[str], + batch_size: int = 12, + ) -> List[Dict[str, Any]]: + bl = [b.lower() for b in (blacklist or [])] + out: List[Dict[str, Any]] = [] + for t in topics: + tl = t.lower() + hits = [b for b in bl if b and b in tl] + blocked = bool(hits) + soft_hits = [w for w in COMMON_RISK_WORDS if w in tl] + if blocked: + risk_level = "blocked" + elif soft_hits: + risk_level = "review" + else: + risk_level = "safe" + cat = classify(t) + art_style, palette = derive_style_palette(t, country, category=cat) + # motif:从分类模板取核心描述,去掉配色/白底尾巴,保持干净可复用 + motif = prompt_suggestion(t, cat).split(" --no ")[0].split(",")[0].strip() + composition = derive_composition(t, cat) + negative = ("no real people, no likeness of any person, no copyrighted characters, " + "no brand logos, no trademarks, no celebrity, no readable text unless safe") + slogan = _stable_slogan(t, country) # 按国家语言(JP→日语短标语,其余英语) + out.append({ + "topic": t, + "safe_for_print": not blocked, + "risk_level": risk_level, + "risk_reasons": [f"命中黑名单: {hits}"] if hits + else (["疑似受保护实体,需人工复核"] if soft_hits else []), + "suitable_for_print": not blocked, + "design_category": cat, + "concept": f"(启发式兜底)围绕「{t}」做原创{art_style}风格印花", + "motif": motif, + "art_style": art_style, + "color_palette": palette, + "composition": composition, + "slogan": slogan, + "negative_prompt": negative, + "confidence": 0.55 if risk_level == "safe" else 0.4, + }) + return out + + def generate_seeds(self, context: Dict[str, Any]) -> Dict[str, Any]: + """规则生成种子词(零 API 成本):借用月份主题、临近节日、trending 派生、历史热点。""" + month_themes = context.get("month_themes", []) or [] + upcoming = context.get("upcoming_holidays", []) or [] + trending = context.get("trending_seeds", []) or [] + history = context.get("history_hotspots", []) or [] + + style: List[str] = [] + related: List[str] = [] + # 月份主题 + 临近节日 → 风格种子(带美学倾向) + style += list(month_themes) + style += [f"{h.lower()} aesthetic" for h in upcoming] + style += trending[:4] + # related:历史 safe 热点 + 剩余 trending(行业交叉验证) + related += history[:6] + related += trending[4:8] + + max_style = int(context.get("max_style_seeds", 10) or 10) + max_related = int(context.get("max_related_seeds", 10) or 10) + return { + "style_seeds": _dedup_limit(style, max_style), + "related_seeds": _dedup_limit(related, max_related), + } diff --git a/graph/llms/openai_compat_backend.py b/graph/llms/openai_compat_backend.py new file mode 100644 index 0000000..d24d0f4 --- /dev/null +++ b/graph/llms/openai_compat_backend.py @@ -0,0 +1,412 @@ +"""OpenAI 兼容 LLM 后端(可插拔实现)。 + +支持 OpenAI / DeepSeek / 通义千问 / Kimi 等 OpenAI 兼容协议。 +LLM 调用失败(网络/限流/解析)时抛出异常,由 screen_node 降级到 MockBackend, +保证流水线不中断。内置默认 SYSTEM_PROMPT,国家可在 prompts//system_prompt.md 覆盖。 +""" +import hashlib +import json +import os +import re +import time +from pathlib import Path +from typing import Any, Dict, List + +import requests + +# 模型调用一律直连:用户常开 VPN(系统代理),LLM 网关多为国内/自建,走代理会被拦截或变慢。 +# 环境变量级 NO_PROXY 双保险(requests/urllib3 均读取),Google 采集(pytrends)不受影响。 +os.environ.setdefault("NO_PROXY", "*") +os.environ.setdefault("no_proxy", "*") +from .base import LLMBackend +from graph.paths import runtime_root + +# 直连策略:忽略环境代理(用户挂 VPN 时代理会拦截国内/自建网关的请求) +NO_PROXY = {"http": None, "https": None} + +# —— 默认系统提示词(国家未提供 prompts//system_prompt.md 时使用)—— +DEFAULT_SYSTEM_PROMPT = '''You are a Print-On-Demand (POD) design compliance screener AND a prompt engineer. +You will receive a batch of trending search topics for ONE country, plus that country's aesthetic preference. +For EACH topic, you must decide (a) whether it suits a POD t-shirt/mug print design, and (b) whether using it would risk infringement. + +OUTPUT WORKFLOW CONTEXT: The user will provide a flat-lay photo of a plain garment (t-shirt) as a REFERENCE IMAGE (图1). +The final product is the print artwork placed ONTO that garment via img2img. The artwork itself must be a STANDALONE +PURE PRINT DESIGN: flat, print-ready, crisp and high-resolution, fitting BETWEEN a MINIMUM PRINT AREA OF ABOUT 15x18 cm AND A MAXIMUM OF 26x32 cm (width x height). +Any size within that range is acceptable: the model is FREE to choose the size that best suits the design - do NOT always default to the largest size. +Keep proportions, scale naturally to the content, never stretch, never force full-bleed, leave balanced margins. +Optional text: text is OPTIONAL - a SHORT original English slogan/words may be added ONLY when they fit the print style, +or keep the design text-free. ANY text used must be SAFE: no politics, no religion, no hate, no violence, no sexual content, +no brand names, no logos, no trademarked phrases, no real-person names, no long sentences, no gibberish. +To keep every prompt consistent and rule-based, +DO NOT write free-form image prompts. Instead, provide these STRUCTURED parts for each topic, and the system assembles +the final image_prompt / wearable_prompt / composite_prompt from FIXED templates: +- motif: the central subject/illustration (English, concrete — what the design depicts; a pure flat print design, NO garment, NO model, NO background scene). AVOID words that image-generation content filters may flag — e.g. demon, devil, occult, satanic, blood, gore, gun, weapon, bomb, drug, skeleton, skull, horror, zombie — use harmless, cute or neutral equivalents instead. +- art_style: the visual technique (English, e.g. "clean flat vector graphics", "kawaii minimalist illustration") +- color_palette: the colors (English, e.g. "muted retro palette of oxblood red, cream, distressed black") +- composition: the layout (English, e.g. "centered emblem with balanced negative space") +Never embed a real garment into motif; describe the artwork only. + +INFRINGEMENT RULES — reject or downgrade anything that: +- Uses a trademark, brand name, or logo (e.g. Nike, Disney, Marvel, Apple, NFL, NBA, LEGO, Starbucks...). +- Uses copyrighted characters / franchises / artwork. +- Depicts a REAL person (celebrity, politician, influencer, athlete) — this violates right of publicity, even in caricature. +- Touches sensitive content: politics, religion, hate, violence, sexual content. +NOTE: even "homage", "fan art", or AI "redraws" of protected IP are risky. Do NOT rely on rewording to escape these rules. + +REFRAMING (important): when a topic is HOT but references a protected element, EXTRACT a SAFE, ORIGINAL design angle that captures the *vibe* without the protected element. Examples: +- a celebrity name -> generic "music festival / stage lights / concert crowd" mood, NO likeness. +- a movie franchise -> generic "retro sci-fi adventure / cosmic explorer" mood, NO characters. +- a brand product -> the lifestyle/activity around it (e.g. "cozy reading nook", "outdoor adventure") with NO logo. +RISK ASSIGNMENT after reframing: +- Once you produce a clean safe original angle, mark "safe" and USE IT DIRECTLY — even if the reframed topic keeps a weak thematic echo of the original (e.g. a celebrity name reframed as a generic "music festival" mood is SAFE). +- Mark "review" ONLY when the residual risk is truly sensitive and cannot be cleanly removed: politics, religion, real-person likeness, hate, violence, sexual content, or a strongly protected brand/IP with no viable original angle. +- Mark "blocked" only for unmistakable core violations that cannot be reframed at all. + +OUTPUT: Respond with ONLY a JSON object (no markdown, no prose) of this exact shape: +{ + "results": [ + { + "topic": "", + "safe_for_print": true | false, + "risk_level": "safe" | "review" | "blocked", + "risk_reasons": ["short reason if any"], + "suitable_for_print": true | false, + "design_category": "Style" | "Meme" | "Event" | "Niche" | "Pattern" | "Quote" | "Failed", + "concept": "", + "motif": "", + "art_style": "", + "color_palette": "", + "composition": "", + "slogan": "", + "negative_prompt": "", + "confidence": 0.0 + } + ] +} +- motif / art_style / color_palette / composition must be English and concrete. The final prompts are assembled from these by FIXED templates — do NOT include the white-background suffix or garment text yourself. +- design_category "Failed" only when the topic cannot be made into any safe print design. +- confidence: 0-1, your certainty in the compliance + suitability judgment. +Process every topic in the batch exactly once.''' + + +# —— 种子词生成(动态设立 Google Trends 相关查询种子)—— +SEED_SYSTEM_PROMPT = '''You are a POD (Print-On-Demand) trend strategist. Given a country's current context (denoised trending searches, past safe design hotspots, season, month themes, upcoming holidays), propose SEED KEYWORDS for Google Trends "related queries" exploration. + +Output TWO lists of short English keyword PHRASES (2-4 words each), suitable as Google Trends related-queries seeds: +- style_seeds: aesthetic / style / vibe oriented (e.g. "cottagecore", "retro grunge", "halloween goth") +- related_seeds: niche / subject / product oriented for cross-checking commercial printability (e.g. "funny cat", "vintage car", "skull art") + +Rules: +- Prefer ORIGINAL, non-infringing angles. Avoid brand names, trademarks, real-person names, copyrighted franchises. +- Lean into the provided season / month themes / upcoming holidays where relevant. +- Use the trending + history signals to pick what is CURRENTLY relevant for THIS country. +- Return ONLY JSON of shape: {"style_seeds": [...], "related_seeds": [...]}''' + + +# —— 商品标题生成(多模态:分析服装图片 → 中英双语 SEO 标题)—— +# 模板字典按编号存放;TITLE_TEMPLATE_ROUTE 按国家路由到模板编号。 +# 模板 1:英语市场(US/GB/AU/MX)→ en_title + cn_title +# 模板 2:日本市场(JP)→ en_title + cn_title + ja_title +TITLE_TEMPLATES: Dict[str, str] = { + "1": '''# Role +你是一位资深的跨境服装运营专家,精通英语电商的SEO标题逻辑。你的任务是通过分析服装图片,生成高权重的英语-中文商品标题。 + +# Task +请深度分析图片中的服装特征(品类、风格、材质、剪裁、细节、受众),生成符合英语电商搜索逻辑的中英双语标题。 + +# 当前时间(标题须贴合当下,季节/年份词以此为准) +- **Current time**: {year}-{month}({season}),标题中的年份/季节等时效词必须使用以上时间。 + +# Analysis Focus (视觉分析重点) +- **品类识别**:准确判断英语核心词(如 Dress, Blouse, Sweatshirt)和中文核心词(如 连衣裙, 卫衣)。 +- **风格定位**:判断风格流派(如 Boho, Vintage, Minimalist / 法式, 复古, 极简)。 +- **设计细节**:提取领型、袖型、裙长等(如 V-neck, Puff Sleeve / V领, 阔袖)。 +- **适用场景**:推断穿着场景(如 Beach, Office, Party / 度假, 通勤, 约会)。 + +# Constraints (生成规则) +- **English Title**: 遵循 Amazon US/UK 风格,核心词前置,包含材质、风格、场景等长尾词,符合英语搜索习惯,简洁有力。 +- **Chinese Title**: 遵循淘宝/1688风格,关键词权重递减,包含季节+风格+核心词+卖点+人群。 + +- **Output**: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下: + {"en_title": "Title in English", "cn_title": "中文标题"}''', + "2": '''# Role +你是一位资深的跨境服装运营专家,精通日本电商(楽天市場・Amazon.co.jp・Yahoo!ショッピング)的SEO标题逻辑。你的任务是通过分析服装图片,生成面向日本市场的高权重英语-中文-日语三语商品标题。 + +# Task +请深度分析图片中的服装特征(品类、风格、材质、剪裁、细节、受众),生成符合日本电商搜索逻辑的三语标题。 + +# 当前时间(标题须贴合当下,季节/年份词以此为准) +- **現在の時刻**: {year}-{month}({season}),标题中的年份/季节等时效词必须使用以上时间。 + +# Analysis Focus (视觉分析重点) +- **品类识别**:准确判断英语核心词(如 Dress, Blouse, Sweatshirt)、中文核心词(如 连衣裙, 卫衣)和日语核心词(如 ワンピース, ブラウス, スウェット)。 +- **风格定位**:判断风格流派(如 フェミニン, ヴィンテージ, ミニマル / 法式, 复古, 极简 / フェミニン, レトロ, シンプル)。 +- **设计细节**:提取领型、袖型、裙长等(如 Vネック, パフスリーブ / V领, 阔袖)。 +- **适用场景**:推断穿着场景(如 オフィス, デート, 旅行 / 通勤, 约会, 度假)。 + +# Constraints (生成规则) +- **English Title**: 遵循 Amazon US/UK 风格,核心词前置,包含材质、风格、场景等长尾词,符合英语搜索习惯,简洁有力。 +- **Chinese Title**: 遵循淘宝/1688风格,关键词权重递减,包含季节+风格+核心词+卖点+人群。 +- **Japanese Title (ja_title)**: 遵循楽天市場/Amazon.co.jp 风格,核心词前置,使用自然日语(平假名/片假名/汉字混合),包含材质、风格、场景等长尾词与常用搜索标签(如 レディース, 春夏, 通勤),贴合日本人搜索习惯,简洁有力,不要机器翻译腔。 + +- **Output**: 必须严格返回 JSON 格式,不要包含 Markdown 代码块标记,格式如下: + {"en_title": "Title in English", "cn_title": "中文标题", "ja_title": "日本語タイトル"}''', +} + +# 国家 → 标题模板编号(JP 路由到模板 2,其余默认模板 1;后续可按国家新增模板 3...) +TITLE_TEMPLATE_ROUTE: Dict[str, str] = { + "US": "1", + "GB": "1", + "JP": "2", + "AU": "1", + "MX": "1", +} + + +def _inject_now(prompt: str) -> str: + """把模板中的 {year}/{month}/{season} 替换为当前时间(用 replace 避免 JSON 花括号冲突)。""" + import datetime + now = datetime.datetime.now() + m = now.month + season = {12: "冬", 1: "冬", 2: "冬", 3: "春", 4: "春", 5: "春", + 6: "夏", 7: "夏", 8: "夏", 9: "秋", 10: "秋", 11: "秋"}[m] + return (prompt.replace("{year}", str(now.year)) + .replace("{month}", str(m)) + .replace("{season}", season)) + + +def resolve_title_prompt(country: str = "") -> str: + """按国家解析标题生成提示词(自动注入当前时间变量);未知国家/留空回退模板 1。""" + tpl_no = TITLE_TEMPLATE_ROUTE.get(country or "", "1") + return _inject_now(TITLE_TEMPLATES.get(tpl_no, TITLE_TEMPLATES["1"])) + + +def build_seed_user_prompt(context: Dict[str, Any]) -> str: + trending = context.get("trending_seeds", []) or [] + history = context.get("history_hotspots", []) or [] + lines = [ + f"Country: {context.get('country', '')}", + f"Current date: {context.get('date', '')} " + f"(Year {context.get('year', '')}, Month {context.get('month', '')}, {context.get('season', '')})", + f"Season: {context.get('season', '')}", + f"Month themes: {', '.join(context.get('month_themes', []) or [])}", + f"Upcoming holidays for {context.get('country', '')}: " + f"{', '.join(context.get('upcoming_holidays', []) or [])} " + f"— INCLUDE holiday-themed style seeds from the list above when any is close.", + "", + "Current trending searches (denoised):", + ] + lines += [f"- {t}" for t in trending] or ["- (none)"] + lines += ["", "Past safe design hotspots (for continuity):"] + lines += [f"- {t}" for t in history] or ["- (none)"] + lines += ["", "Return JSON with style_seeds and related_seeds (each 2-4 word English phrases)."] + return "\n".join(lines) + +CACHE_DIR = runtime_root() / ".cache" / "llm_screen" +CACHE_DIR.mkdir(parents=True, exist_ok=True) + + +def _cache_get(key): + p = CACHE_DIR / f"{key}.json" + if p.exists(): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return None + return None + + +def _cache_set(key, val): + try: + (CACHE_DIR / f"{key}.json").write_text(json.dumps(val, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + +def build_user_prompt(country, topics, aesthetic_hint): + topic_lines = "\n".join(f"{i+1}. {t}" for i, t in enumerate(topics)) + return ( + f"Country: {country}\n" + f"Country aesthetic preference: {aesthetic_hint}\n\n" + f"Trending topics to screen (one per line):\n{topic_lines}\n\n" + f"Return JSON with one result per topic, following the schema exactly." + ) + + +def call_openai_compatible(cfg, messages, timeout=90): + base_url = str(cfg.get("base_url", "https://api.openai.com/v1")).rstrip("/") + api_key = cfg.get("api_key", "") + model = cfg.get("model", "gpt-4o-mini") + url = f"{base_url}/chat/completions" + payload = { + "model": model, + "messages": messages, + "temperature": float(cfg.get("temperature", 0.6)), + "response_format": {"type": "json_object"}, + } + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + resp = requests.post(url, json=payload, headers=headers, timeout=timeout, proxies=NO_PROXY) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"] + + +def _retry(func, max_attempts=4, base_delay=4): + last = None + for attempt in range(max_attempts): + try: + return func() + except Exception as e: # noqa: BLE001 + last = e + if attempt == max_attempts - 1: + break + time.sleep(base_delay * (2 ** attempt)) + raise last if last else RuntimeError("llm retry failed") + + +def _extract_json(text): + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text).strip() + try: + return json.loads(text) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", text, re.S) + if m: + return json.loads(m.group(0)) + raise + + +class OpenAICompatBackend(LLMBackend): + name = "openai_compat" + + def screen(self, topics, country, aesthetic_hint, system_prompt, blacklist, batch_size=12): + # 注意:这里 blacklist 已由 screen_node 在更前置阶段过滤,此处仅透传信息给 LLM。 + # 实际硬过滤在 filter 阶段完成;LLM 主要做"热点但涉保护元素"的安全重构。 + cfg = self._cfg # 由 screen_node 注入 + batches = [topics[i:i + batch_size] for i in range(0, len(topics), batch_size)] + all_results: List[Dict[str, Any]] = [] + for b_idx, batch in enumerate(batches): + cache_key = hashlib.md5( + f"{self.name}|{country}|{b_idx}|{','.join(batch)}".encode("utf-8") + ).hexdigest() + screened = _cache_get(cache_key) + if screened is None: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": build_user_prompt(country, batch, aesthetic_hint)}, + ] + raw = _retry(lambda: call_openai_compatible(cfg, messages)) + parsed = _extract_json(raw) + screened = parsed.get("results", []) + _cache_set(cache_key, screened) + all_results.extend(screened) + return all_results + + def bind_config(self, cfg): + # 解析密钥/地址:配置值优先,其次环境变量(避免在 config.yaml 硬编码密钥)。 + resolved = dict(cfg or {}) + resolved["api_key"] = ( + (cfg or {}).get("api_key") + or os.environ.get("LLM_API_KEY") + or os.environ.get("OPENAI_API_KEY") + or "" + ) + resolved["base_url"] = ( + (cfg or {}).get("base_url") + or os.environ.get("LLM_BASE_URL") + or "https://api.openai.com/v1" + ) + self._cfg = resolved + + @property + def has_key(self) -> bool: + return bool((self._cfg or {}).get("api_key")) + + def generate_seeds(self, context: Dict[str, Any]) -> Dict[str, Any]: + cfg = self._cfg # 由 seed_node 注入(含 env 解析后的 api_key/base_url) + cache_key = hashlib.md5( + f"seed|{self.name}|{json.dumps(context, sort_keys=True, ensure_ascii=False)}".encode("utf-8") + ).hexdigest() + cached = _cache_get(cache_key) + if cached is not None: + return cached + messages = [ + {"role": "system", "content": SEED_SYSTEM_PROMPT}, + {"role": "user", "content": build_seed_user_prompt(context)}, + ] + raw = _retry(lambda: call_openai_compatible(cfg, messages, timeout=90)) + parsed = _extract_json(raw) + out = { + "style_seeds": [str(x) for x in (parsed.get("style_seeds", []) or [])][:10], + "related_seeds": [str(x) for x in (parsed.get("related_seeds", []) or [])][:10], + } + _cache_set(cache_key, out) + return out + + def generate_title(self, image_path: str, system_prompt: str = "", country: str = "", + fallback_text: str = "") -> Dict[str, Any]: + """多模态标题生成;图片输入不被模型支持(如 qwen 纯文本模型 400)时, + 自动降级为纯文本生成(fallback_text 为商品描述/热点主题)。""" + """多模态:分析服装图片,生成商品标题(按国家路由模板)。 + + 系统提示词:显式传入优先;否则按 country 经 TITLE_TEMPLATE_ROUTE 路由到对应模板。 + 模板 1(US/GB/AU/MX)返回 {"en_title","cn_title"}; + 模板 2(JP)额外返回 {"ja_title"}。 + 无 key/调用失败返回 {}(调用方兜底不中断)。 + """ + cfg = self._cfg + api_key = cfg.get("api_key", "") + if not api_key: + print("[titles] 未配置 LLM api_key(llm_screen.api_key 或环境变量),跳过标题生成") + return {} + base_url = str(cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/") + model = cfg.get("model", "gpt-4o-mini") + url = f"{base_url}/chat/completions" + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + + # 图片 → base64 data URI(多模态输入) + try: + import base64 as b64 + mime = "image/png" + p = Path(image_path) + if p.suffix.lower() in (".jpg", ".jpeg"): + mime = "image/jpeg" + data_uri = f"data:{mime};base64,{b64.b64encode(p.read_bytes()).decode()}" + except Exception as e: # noqa: BLE001 + print(f"[titles] 图片读取失败: {e}") + return {} + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt or resolve_title_prompt(country)}, + {"role": "user", "content": [ + {"type": "text", "text": "请分析这张服装图片,按规则输出标题,结果以 JSON 格式返回。"}, + {"type": "image_url", "image_url": {"url": data_uri}}, + ]}, + ], + "temperature": 0.4, + "response_format": {"type": "json_object"}, + } + try: + resp = requests.post(url, json=payload, headers=headers, timeout=180, proxies=NO_PROXY) + resp.raise_for_status() + msg = resp.json()["choices"][0]["message"] + content = str(msg.get("content") or "").strip() + if not content: + # qwen 等推理模型可能把输出放在 reasoning_content + content = str(msg.get("reasoning_content") or "").strip() + if not content: + print("[titles] LLM 返回空内容,跳过标题生成") + return {} + parsed = _extract_json(content) + return { + "en_title": str(parsed.get("en_title", "")).strip(), + "cn_title": str(parsed.get("cn_title", "")).strip(), + "ja_title": str(parsed.get("ja_title", "")).strip(), + } + except Exception as e: # noqa: BLE001 + print(f"[titles] 标题生成失败: {e}") + return {} diff --git a/graph/loader.py b/graph/loader.py new file mode 100644 index 0000000..1e2b61d --- /dev/null +++ b/graph/loader.py @@ -0,0 +1,57 @@ +"""配置与提示词加载工具。 + +职责: +- 读取 configs/countries/.yaml(该国专属种子词/权重/limit 等覆盖) +- 读取 prompts//aesthetics.yaml(该国审美 hint、风格-配色 extra 规则、额外黑名单) +- 读取 prompts//system_prompt.md(该国 LLM 系统提示覆盖) +- 把上述合并进 country_config,供节点使用 +""" +from pathlib import Path +from typing import Any, Dict + +import yaml + + +def load_yaml_safe(path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + try: + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception as e: # noqa: BLE001 + print(f"[loader] 解析失败 {path}: {e}") + return {} + + +def load_text_safe(path: Path) -> str: + if not path.exists(): + return "" + try: + return path.read_text(encoding="utf-8").strip() + except Exception: # noqa: BLE001 + return "" + + +def build_country_config(global_config: Dict[str, Any], country: str, project_root: Path) -> Dict[str, Any]: + """合并:全局配置 + 国家专属 yaml + 国家审美 yaml。""" + cc: Dict[str, Any] = load_yaml_safe(project_root / "configs" / "countries" / f"{country}.yaml") + + aesthetics = load_yaml_safe(project_root / "prompts" / country / "aesthetics.yaml") + cc.setdefault("style_hint", aesthetics.get("style_hint", "")) + cc.setdefault("extra_style_rules", aesthetics.get("extra_style_rules", []) or []) + cc.setdefault("extra_blacklist", aesthetics.get("extra_blacklist", []) or []) + # 国家专属趋势/风格/行业种子(若 aesthetics 里有也并入 cc 顶层,便于 source 读取) + for k in ("trending", "style", "related", "timeframe"): + if k in aesthetics and k not in cc: + cc[k] = aesthetics[k] + return cc + + +def load_system_prompt(prompts_dir: Path, default: str) -> str: + """该国 prompts//system_prompt.md 作为「补充段」叠加到默认规则之后。 + + 这样既能按国家定制口吻/合规重点,又不丢失内置核心合规规则。若该国未提供文件则用默认。 + """ + text = load_text_safe(prompts_dir / "system_prompt.md") + if not text: + return default + return default + "\n\n# 该国专属补充指令\n" + text diff --git a/graph/nodes/__init__.py b/graph/nodes/__init__.py new file mode 100644 index 0000000..78f0da8 --- /dev/null +++ b/graph/nodes/__init__.py @@ -0,0 +1,26 @@ +"""流水线节点集合。""" +from .compose_node import compose_node +from .fetch_node import fetch_node +from .filter_node import filter_node +from .oss_upload_node import oss_upload_node +from .product_node import product_node +from .prompt_node import prompt_node +from .score_node import score_node +from .screen_node import screen_node +from .seed_node import seed_node +from .seed_shot_node import seed_shot_node +from .template_export_node import template_export_node + +__all__ = [ + "fetch_node", + "filter_node", + "score_node", + "screen_node", + "prompt_node", + "product_node", + "compose_node", + "seed_node", + "oss_upload_node", + "seed_shot_node", + "template_export_node", +] diff --git a/graph/nodes/compose_node.py b/graph/nodes/compose_node.py new file mode 100644 index 0000000..d96eea5 --- /dev/null +++ b/graph/nodes/compose_node.py @@ -0,0 +1,194 @@ +"""节点 5.5/6:生成印花设计稿 + 导出简报包(compose)。 + +流程位置:prompt_build → compose → product(compose 在 product 之前)。 +职责�� +1. 生成纯印花设计稿:对前 N 个 safe 简报(N=config.compose.design_count,默认 1), + 用 image_prompt 调图像后端 generate()(白底、可直接打印),产物存 output//designs/, + 设计稿路径写回 brief.design_path,并汇总返回 designs 列表供 product 节点使用(图2)。 +2. 导出简报包:design_briefs.json/md、composite_prompts.json/md、report.md。 +""" +import json +import time +from pathlib import Path +from typing import Any, Dict, List + +from graph.validate import with_fallback + +RISK_LABEL = {"safe": "✅ 安全", "review": "⚠️ 待复核", "blocked": "⛔ 拦截"} + + +def _build_briefs_md(briefs: List[Dict[str, Any]], generated_at: str) -> str: + lines = [ + "# POD 印花设计简报(LLM 合规筛选 + 生图提示词)", + "", + f"- 生成时间: {generated_at}", + f"- 通过筛选: {len(briefs)} 条", + "", + "## 一、安全设计清单(按综合分排序)", + "", + "| 排名 | 国家 | 热点词 | 类别 | 风险 | 设计概念 |", + "|---|---|---|---|---|---|", + ] + for i, r in enumerate(briefs, 1): + flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level")) + lines.append( + f"| {i} | {r.get('country','')} | {r.get('topic','')} | {r.get('design_category','')} | {flag} | {r.get('concept','')} |" + ) + lines += ["", "## 二、设计要素 + 封装提示词", ""] + lines.append("> 工作流:① `image_prompt` = 印花设计稿(白底,单独生图);② 上传平铺衣服底图(图1)后,") + lines.append("> 用 `composite_prompt` + 图1 经 img2img 把设计印到衣服;规则写死:保留衣服、胸前居中印花、真实丝网质感。") + lines.append("") + for i, r in enumerate(briefs, 1): + flag = RISK_LABEL.get(r.get("risk_level"), r.get("risk_level")) + lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')} ({flag})") + lines.append(f"- 类别: {r.get('design_category','')}") + lines.append(f"- 设计要素: 主体=「{r.get('motif','')}」 | 风格=「{r.get('art_style','')}」 | 配色=「{r.get('color_palette','')}」 | 构图=「{r.get('composition','')}」") + lines.append(f"- 概念: {r.get('concept','')}") + if r.get("risk_reasons"): + lines.append(f"- 风险提示: {'; '.join(r['risk_reasons'])}") + if r.get("design_path"): + lines.append(f"- **设计稿**: {r['design_path']}") + lines.append(f"- **设计稿 Prompt (image_prompt)**: {r.get('image_prompt','')}") + lines.append(f"- **印到底图 Prompt (composite_prompt)**: {r.get('composite_prompt','')}") + lines.append(f"- **Composite Negative**: {r.get('composite_negative','')}") + lines.append("") + return "\n".join(lines) + + +def _build_composite_md(briefs: List[Dict[str, Any]]) -> str: + lines = [ + "# 封装提示词包(印到平铺衣服底图 图1)", + "", + f"- 共 {len(briefs)} 条,每条含 `composite_prompt`(印图指令)+ `composite_negative`。", + "- 用法:将你的平铺衣服参考图作为图1,连同 `composite_prompt` 送入任意 img2img / inpaint 模型。", + "", + ] + for i, r in enumerate(briefs, 1): + lines.append(f"### {i}. [{r.get('country','')}] {r.get('topic','')}") + lines.append(f"- composite_prompt: {r.get('composite_prompt','')}") + lines.append(f"- composite_negative: {r.get('composite_negative','')}") + lines.append("") + return "\n".join(lines) + + +def _build_report_md(state: Dict[str, Any]) -> str: + country = state.get("country", "") + stats = state.get("stats") or {} + errors = state.get("errors") or [] + lines = [ + f"# POD 热点抓取报告 - {country}", + "", + f"- 生成时间: {time.strftime('%Y-%m-%dT%H:%M:%S')}", + "", + "## 各阶段统计", + "", + "| 阶段 | 指标 |", + "|---|---|", + ] + for k, v in stats.items(): + lines.append(f"| {k} | {v} |") + lines += ["", "## 兜底错误记录(节点级 fallback 捕获)", ""] + if errors: + for e in errors: + lines.append(f"- [{e.get('node')}] {e.get('type')}: {e.get('message')}") + else: + lines.append("- 无(全部节点正常)") + return "\n".join(lines) + + +@with_fallback("compose") +def compose_node(state: Dict[str, Any]) -> Dict[str, Any]: + briefs: List[Dict[str, Any]] = state.get("briefs") or [] + output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹) + output_dir.mkdir(parents=True, exist_ok=True) + cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录) + config = state["config"] + country = state.get("country", "") + + generated_at = time.strftime("%Y-%m-%dT%H:%M:%S") + + # 1) design_briefs.json(缓存 → 根目录,不进时间戳任务文件夹) + (cache_dir / "design_briefs.json").write_text( + json.dumps({"generated_at": generated_at, "total": len(briefs), "design_briefs": briefs}, + ensure_ascii=False, indent=2), encoding="utf-8") + + # 2) design_briefs.md + (cache_dir / "design_briefs.md").write_text( + _build_briefs_md(briefs, generated_at), encoding="utf-8") + + # 3) composite_prompts.json / .md + (cache_dir / "composite_prompts.json").write_text( + json.dumps({"generated_at": generated_at, "total": len(briefs), "composite_prompts": briefs}, + ensure_ascii=False, indent=2), encoding="utf-8") + (cache_dir / "composite_prompts.md").write_text( + _build_composite_md(briefs), encoding="utf-8") + + # 4) report.md(本次任务报告 → 产物目录) + (output_dir / "report.md").write_text(_build_report_md(state), encoding="utf-8") + + # 5) 生成纯印花设计稿(图2):前 N 个 safe 简报用 image_prompt 文生图 + designs: List[Dict[str, Any]] = [] + compose_cfg = config.get("compose") or {} + backend_name = compose_cfg.get("backend", "") + ib = None + if backend_name: + try: + from graph.backends import get_image_backend + ib = get_image_backend(backend_name) + if ib is not None: + ib.bind_config(compose_cfg) + except Exception as e: # noqa: BLE001 + print(f"[compose] 图像后端 {backend_name} 不可用: {e}") + if ib is None: + print("[compose] 未配置 compose.backend(openai/mock),跳过印花设计稿生成。") + else: + # 设计稿覆盖所有简报(含 review):每个热点一张设计,避免 review 热点无设计 + # 导致 product 回退生成重复占位图;风险由 assign 层(allow_review)控制是否分配 + safe_briefs = briefs + design_count = int(compose_cfg.get("design_count", 1)) + # 联动总任务数:每个产品一张设计 → 生成 扩展后 spu_tasks 总数 张设计 + task_n = int(len((state.get("config") or {}).get("product", {}).get("spu_tasks") or [])) + if task_n > design_count: + design_count = task_n + design_dir = output_dir / "designs" + design_dir.mkdir(exist_ok=True) + + from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint + from concurrent.futures import ThreadPoolExecutor, as_completed + + def _gen_one(i: int, b: Dict[str, Any]): + """单张设计稿生成(并发线程内调用,每设计一线程)。""" + try: + img_prompt = sanitize_image_prompt(b.get("image_prompt", "")) + img_prompt = ensure_rebrand_hint(b, img_prompt) # review → 原创化魔改引导 + out_path = ib.generate( + img_prompt, + str(design_dir / f"{country}_{i:02d}_design.png"), + b.get("composite_negative", ""), + size="1024x1024") # 印花设计统一 1024x1024 + return i, b, out_path, None + except Exception as e: # noqa: BLE001 + return i, b, None, e + + targets = [(i, b) for i, b in enumerate(safe_briefs[:design_count], 1)] + # 并发生成:每张设计一个线程(并行调图像网关),数量多时不串行等待 + workers = max(1, min(len(targets), int((config.get("compose") or {}).get("design_workers", 5)))) + print(f"[compose] 并发生成 {len(targets)} 张设计稿({workers} 线程)…") + with ThreadPoolExecutor(max_workers=workers) as _ex: + _futs = [_ex.submit(_gen_one, i, b) for i, b in targets] + for _f in as_completed(_futs): + i, b, out_path, err = _f.result() + if err is not None: + print(f"[compose] 设计稿生成失败 {b.get('topic', '')}: {err}") + state.setdefault("errors", []).append({ + "node": "compose", "type": type(err).__name__, + "message": f"设计稿生成失败 {b.get('topic','')}: {err}", "trace": ""}) + else: + b["design_path"] = out_path + designs.append({"topic": b.get("topic", ""), "path": out_path, "design_path": out_path}) + print(f"[compose] 印花设计稿已生成: {out_path}") + + stats = dict(state.get("stats") or {}) + stats["compose"] = {"written": len(briefs), "designs": len(designs), "output_dir": str(output_dir)} + return {"composite": briefs, "designs": designs, "stats": stats, + "errors": state.get("errors") or []} diff --git a/graph/nodes/fetch_node.py b/graph/nodes/fetch_node.py new file mode 100644 index 0000000..7934be4 --- /dev/null +++ b/graph/nodes/fetch_node.py @@ -0,0 +1,55 @@ +"""节点 1/6:抓取(fetch)。 + +按 config.sources 启用各可插拔数据源,汇总统一格式行。 +单源失败不影响其它源(内部逐个 try),整体再套 with_fallback 兜底。 +""" +from typing import Any, Dict, List + +from graph.sources import get_source +from graph.validate import validate_rows, with_fallback + + +@with_fallback("fetch") +def fetch_node(state: Dict[str, Any]) -> Dict[str, Any]: + country = state["country"] + config = state["config"] + cc = state["country_config"] + enabled = config.get("sources") or ["google_trends"] + rows: List[Dict[str, Any]] = [] + errors = list(state.get("errors") or []) + + # 采集缓存优先:采集(fetch_keywords)成功后写入 output/<国>/collected_keywords.json, + # 这里直接用(跳过 Google 重抓),避免重复撞限流;无缓存才走数据源抓取 + use_collected = (config.get("fetch") or {}).get("use_collected", True) + if use_collected: + try: + import json as _json + from pathlib import Path as _Path + p = _Path(state.get("cache_dir") or state.get("output_dir", "")) / "collected_keywords.json" + if p.exists(): + data = _json.loads(p.read_text(encoding="utf-8")) + cached_rows = data.get("keywords") or [] + if cached_rows: + rows = [dict(r) for r in cached_rows] # 已过滤去重的关键词 + print(f"[fetch] 使用采集缓存 {len(rows)} 条({country},跳过 Google 抓取)") + stats = dict(state.get("stats") or {}) + stats["fetch"] = {"raw_rows": len(rows), "sources": ["collected_cache"], "errors": 0} + return {"raw_rows": rows, "errors": errors, "stats": stats} + except Exception as e: # noqa: BLE001 + print(f"[fetch] 读取采集缓存失败(回退数据源): {e}") + + for name in enabled: + try: + src = get_source(name) + rows.extend(src.fetch(country, cc, config)) + except Exception as e: # noqa: BLE001 + errors.append({ + "node": "fetch", "type": type(e).__name__, + "message": f"source[{name}]: {e}", "trace": "", + }) + print(f"[fetch] 数据源 {name} 失败(跳过): {e}") + + rows = validate_rows(rows, "fetch") + stats = dict(state.get("stats") or {}) + stats["fetch"] = {"raw_rows": len(rows), "sources": enabled, "errors": len(errors)} + return {"raw_rows": rows, "errors": errors, "stats": stats} diff --git a/graph/nodes/filter_node.py b/graph/nodes/filter_node.py new file mode 100644 index 0000000..e96a530 --- /dev/null +++ b/graph/nodes/filter_node.py @@ -0,0 +1,67 @@ +"""节点 2/6:过滤(filter)。 + +三级过滤,全部带兜底、单级失败不影响其它级: +1) 合规黑名单(全局 + 国家 extra) +2) 真实人物(名单 + Firstname Lastname 模式,仅对 gt_trending 源,避免误删风格词) +3) 设计相关性(剔除泛新闻/科技/赛事词) +""" +from typing import Any, Dict, List + +from graph.scoring import apply_blacklist, filter_design_relevance, filter_person_names, filter_query_noise +from graph.validate import validate_rows, with_fallback + + +@with_fallback("filter") +def filter_node(state: Dict[str, Any]) -> Dict[str, Any]: + rows: List[Dict[str, Any]] = state.get("raw_rows") or [] + config = state["config"] + cc = state["country_config"] + country = state.get("country", "") + + # 黑名单:全局 + 国家专属 + bl = [str(b).lower() for b in (config.get("blacklist") or [])] + bl += [str(b).lower() for b in (cc.get("extra_blacklist") or [])] + bl = list(set(bl)) + + name_cfg = config.get("name_filter") or {} + rel_cfg = config.get("relevance") or {} + + dropped_total = 0 + + kept, dropped = apply_blacklist(rows, bl) + dropped_total += len(dropped) + + if name_cfg.get("enabled", True): + kept, dropped = filter_person_names( + kept, + extra_names=name_cfg.get("extra_names"), + patterns=name_cfg.get("patterns"), + exemptions=name_cfg.get("exemptions"), + pattern_sources=set(name_cfg.get("pattern_sources") or ["gt_trending"]), + ) + dropped_total += len(dropped) + + if rel_cfg.get("enabled", True): + kept, dropped = filter_design_relevance( + kept, + drop_patterns=rel_cfg.get("drop_patterns"), + keep_patterns=rel_cfg.get("keep_patterns"), + ) + dropped_total += len(dropped) + + # ③ 新闻类热点(天气/灾害/政治/事故等突发新闻,非印花主题;按国家语言过滤) + if kept: + from graph.scoring import filter_news + kept, dropped = filter_news(kept, country) + dropped_total += len(dropped) + + # ④ 查询噪声(问句/命名清单/损坏碎片/模糊名词)—— 防止被 Mock 误标 safe + qn_cfg = config.get("query_noise") or {} + if qn_cfg.get("enabled", True): + kept, dropped = filter_query_noise(kept, enabled=True) + dropped_total += len(dropped) + + kept = validate_rows(kept, "filter") + stats = dict(state.get("stats") or {}) + stats["filter"] = {"kept": len(kept), "dropped": dropped_total} + return {"filtered_rows": kept, "stats": stats} diff --git a/graph/nodes/oss_upload_node.py b/graph/nodes/oss_upload_node.py new file mode 100644 index 0000000..18acc14 --- /dev/null +++ b/graph/nodes/oss_upload_node.py @@ -0,0 +1,109 @@ +"""节点 7/7:压缩 + 上传阿里云 OSS(oss_upload)。 + +在 product 之后运行:把 product 生成的成品图(composite / printed / design / basemap) +压缩为 3:4 / ≥1340×1785 / <2MB 的 JPEG,上传到 config.oss 图床。 + +上传 key(图床路径):{国家}/{任务时间戳}/{货号}_{4位随机}.jpg + - 任务时间戳:任务开始记录(YYYYMMDDHHMMSS),state["task_timestamp"],缺失时取当前时间 + - 货号:用户自定义前缀(config.product.code_prefix,默认 DG)+ 3 位计数(000 起,最多 999) + - 4 位随机:大小写英文 + 数字 + +压缩/上传均带兜底:单图失败不影响其它;未配置 oss 或 enabled=false 时静默跳过。 +""" +import random +import string +import time +from pathlib import Path +from typing import Any, Dict, List + +from graph.validate import with_fallback + +# design 设计稿是过程稿(不进模板),不压缩不上传;只传最终商品图 +KIND_ORDER = ["composite", "printed", "basemap"] +MAX_CODE = 999 # 货号计数上限(000~998 共 999 张) + + +def _gen_rand4() -> str: + return "".join(random.choices(string.ascii_letters + string.digits, k=4)) + + +@with_fallback("oss_upload") +def oss_upload_node(state: Dict[str, Any]) -> Dict[str, Any]: + products: List[Dict[str, Any]] = state.get("product") or [] + config = state["config"] or {} + oss_cfg = config.get("oss") or {} + country = state.get("country", "") + + if not (oss_cfg.get("oss_bucket") and oss_cfg.get("oss_key_id")): + print("[oss] 未配置 oss(config.oss),跳过压缩上传节点。") + return {"oss": [], "stats": state.get("stats") or {}} + if not bool(oss_cfg.get("enabled", True)): + print("[oss] config.oss.enabled=false,跳过上传。") + return {"oss": [], "stats": state.get("stats") or {}} + + from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss + + # 任务时间戳:任务开始记录;缺失则当前时间 + ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S")) + # 货号前缀:config.product.code_prefix(默认 DG) + prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip() + # 序号从 state 续接(一次任务内跨多次节点调用不重号) + seq = int(state.get("oss_seq") or 0) + + uploaded: List[Dict[str, Any]] = [] + stats = dict(state.get("stats") or {}) + for r in products: + spu = r.get("spu_code", "") + sku = r.get("sku_code", "") + for kind in KIND_ORDER: + src = r.get(f"{kind}_path") + if not src or not Path(src).exists(): + continue + if seq >= MAX_CODE: + print(f"[oss] 货号计数已达上限 999,停止上传后续图片({src})") + break + try: + code = f"{prefix}{seq:03d}" # 货号:前缀 + 3 位计数(000 起) + compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg"))) + key = build_oss_key(country, ts, code, _gen_rand4()) + url = upload_to_oss(oss_cfg, compressed, key) + if url: + r[f"{kind}_url"] = url + r["oss_code"] = code + uploaded.append({"spu_code": spu, "sku_code": sku, "kind": kind, + "code": code, "url": url}) + seq += 1 + except Exception as e: # noqa: BLE001 + print(f"[oss] 处理失败 {src}: {e}") + + # 多色:color_composites 用于模板按颜色路由——首色用主图 url/code,额外色单独上传(独立货号) + color_ups: List[Dict[str, Any]] = [] + comps = r.get("color_composites") or [] + if comps and r.get("composite_url"): + color_ups.append({**comps[0], "url": r["composite_url"], "code": r.get("oss_code", "")}) + for cc in comps[1:]: + src = cc.get("composite_path") + if not src or not Path(src).exists(): + continue + if seq >= MAX_CODE: + print("[oss] 货号计数已达上限 999,停止上传颜色图") + break + try: + code = f"{prefix}{seq:03d}" + compressed = compress_for_oss(src, str(Path(src).with_suffix(".oss.jpg"))) + key = build_oss_key(country, ts, code, _gen_rand4()) + url = upload_to_oss(oss_cfg, compressed, key) + if url: + cc["url"] = url + cc["code"] = code + color_ups.append(cc) + uploaded.append({"spu_code": spu, "sku_code": cc.get("sku_code"), + "kind": "composite_color", "code": code, "url": url}) + seq += 1 + except Exception as e: # noqa: BLE001 + print(f"[oss] 颜色图上传失败 {src}: {e}") + if color_ups: + r["color_composites"] = color_ups + + stats["oss"] = {"uploaded": len(uploaded), "timestamp": ts, "prefix": prefix, "seq": seq} + return {"oss": uploaded, "product": products, "oss_seq": seq, "stats": stats} diff --git a/graph/nodes/product_node.py b/graph/nodes/product_node.py new file mode 100644 index 0000000..c8d4d58 --- /dev/null +++ b/graph/nodes/product_node.py @@ -0,0 +1,584 @@ +"""节点 6.5:产品图生成(product)。 + +在 prompt_build 之后、compose 之前运行(prompt_build → product → compose): + 热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。 +产物写入 output//product/(底图拷贝 / *_design.png / *_model / *_composite.png / products.json)。 + +模板选择按 SPU.mark 驱动: + mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图) + mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计 → 平铺服装图) + +配置(config.yaml product 段): + enabled 开关(默认 true) + db_path SPU/SKU 数据库(默认 db/spu_sku.db,相对路径按运行根解析) + basemap_dir 底图目录(默认 basemap) + material_library_dir 模特图库(默认 material_library) + model_category 模特品类子目录(T-shirt);为空/无图时取 material_library 第一个有图子目录 + brief_index 用第几个 safe 简报的提示词(0=第一个) + spu_code / sku_code 指定款号/颜色编码(留空自动选第一个有本地底图的) + spu_tasks [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品(优先于 spu_code) + spu_count 款号数量上限(0=不限;取任务清单前 N 个) + spu_per_color true=每颜色一个 SPU 块;false=单 SPU 下挂所有颜色 SKU 变体 + backend 图像后端:openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图 + +缺底图/模特图时跳过对应步骤并提示,不中断流水线;多款号逐个处理,单个失败不影响其它。 +""" +import json +import random +import shutil +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from graph.paths import project_root, runtime_root +from graph.product import ( + find_basemap, + find_first_model_folder, + first_available_sku, + list_colors, + list_spus, +) +from graph.validate import with_fallback + + +_USED_LOCK = threading.Lock() # used_designs.json 并发写锁 +_MODEL_LOCK = threading.Lock() # 同款共用模特缓存并发锁 +_MODEL_CACHE: Dict[str, Any] = {} # 同款共用模特:spu_code → model 路径 + + +def _next_img_idx(prod_dir: Path, prefix: str) -> int: + """货号续号:扫 prod_dir 已有 {prefix}{数字}* 文件,返回下一个起始序号(不覆盖旧产物)。""" + import re + max_n = -1 + try: + if prod_dir.exists(): + for f in prod_dir.iterdir(): + m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem) + if m: + max_n = max(max_n, int(m.group(1))) + except Exception: # noqa: BLE001 + pass + return max_n + 1 + +MODEL_WEAR_PROMPT = ( + "你是一个专业的电商AI视觉合成工具,执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上," + "并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n" + "【图片角色,按提交顺序,不要弄反】\n" + "第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影);\n" + "第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号);\n" + "第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n" + "最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n" + "【执行规则】\n" + "1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变," + "严禁偏色、混入图1原衣服颜色或图2背景色。\n" + "2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;" + "将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n" + "3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——" + "居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;" + "严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n" + "4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;" + "将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n" + "5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;" + "印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n" + "6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;" + "图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n" + "7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;" + "图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。" +) + + +def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]: + """选定 SKU:显式指定优先;否则第一个有本地底图的;再无则第一个颜色(便于模板导出)。""" + if sku_code: + return sku_code + s = first_available_sku(db_path, basemap_root, spu_code) + if s: + return s + if colors: + return colors[0]["sku_code"] + return None + + +def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path: + """模板输出路径:默认 {sku}_已填写.xlsx;若文件被其它程序占用(如已打开),自动换名加序号,避免导出失败。""" + base = prod_dir / f"{chosen_sku}_已填写.xlsx" + try: + with open(base, "ab"): + pass + return base + except OSError: + pass + for i in range(2, 100): + cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx" + if not cand.exists(): + return cand + return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx" + + +def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs): + """图像合成带退避重试(网关超载/超时常见):成功返回 out_path;全部失败返回 None。""" + import time as _t + last = None + for i in range(attempts): + try: + return fn(*args, **kwargs) + except Exception as e: # noqa: BLE001 + last = e + if i < attempts - 1: + _t.sleep(backoff[i]) + print(f"[product] 图像合成重试 {attempts} 次均失败: {last}") + return None + + +def _process_spu( + db_path, basemap_root, material_root, category, prod_dir, brief, ib, + spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="", + img_code="", model_img=None, +) -> Optional[Dict[str, Any]]: + """处理单个款号:选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。 + shared_design: compose 节点生成的纯印花设计稿路径(图2);为 None 时回退本节点 generate。 + img_code: 货号(前缀+3位计数);本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹 + (按货号命名,包含该货号对应的所有图片)。 + 返回 result dict;内部异常已兜底,不中断。 + """ + # 输出目录 = 货号子文件夹(product/DG000/…),该货号所有图片都放这里 + prod_dir = prod_dir / img_code + prod_dir.mkdir(parents=True, exist_ok=True) + colors = list_colors(db_path, spu["code"]) + valid_codes = {c["sku_code"] for c in colors} + if sku_code: + sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes] + if not sku_codes: + print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下,可选: {[c['sku_code'] for c in colors]}") + return None + else: + first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors) + if first is None: + print(f"[product] 款号 {spu['code']} 无颜色数据,可选: {[c['sku_code'] for c in colors]}") + return None + sku_codes = [first] + + # 模板模式自动判定(UI 不再选择):单色=每色一SPU;多色=单SPU多色;CLI --single-spu 显式覆盖 + spu_per_color = pcfg.get("spu_per_color") + if spu_per_color is None: + spu_per_color = len(sku_codes) <= 1 + else: + spu_per_color = bool(spu_per_color) + + chosen_sku = sku_codes[0] # 生图用第一个颜色 + tag = f"[product/{img_code or chosen_sku}]" # 日志前缀用货号(DG000),失败/进度一眼定位 + basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku) + if basemap_img is None: + print(f"{tag} 底图缺失({basemap_root}/{spu['code']}/{chosen_sku}/),将跳过印花/模特,仅导出模板") + + result: Dict[str, Any] = { + "spu_code": spu["code"], + "sku_code": chosen_sku, + "color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""), + "topic": brief.get("topic", ""), + "art_style": brief.get("art_style", ""), + "color_palette": brief.get("color_palette", ""), + "basemap": str(basemap_img) if basemap_img else "", + "composite_prompt": brief.get("composite_prompt", ""), + "markup_percent": pcfg.get("markup_percent", 0), # 加价%(后续定价用) + } + + # 拷贝底图(图3):命名 = 货号 + SKU code(如 DG003_DG004-BL01_basemap.jpg), + # 同一 SKU 多个设计(数量 N)时底图文件也各自独立,不覆盖、不混淆 + if basemap_img is not None: + base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}" + shutil.copy2(basemap_img, base_copy) + result["basemap_copy"] = str(base_copy) + print(f"{tag} 底图: {base_copy}") + + if ib is None: + print(f"{tag} 未配置 product.backend(openai/mock),跳过印花/模特生成。") + elif basemap_img is None: + print(f"{tag} 无底图,跳过印花/模特生成。") + else: + # 5) 纯印花设计稿(图2):直接用 compose 节点生成的共享设计稿(designs/ 已有,不拷贝) + if shared_design and Path(shared_design).exists(): + design_path = shared_design + result["design_path"] = design_path + result["design_from"] = "compose" + print(f"{tag} 设计稿(来自 compose 节点,designs/ 已有): {design_path}") + else: + design_path = str(prod_dir / f"{img_code}_design.png") + try: + from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint + prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", ""))) + ib.generate(prompt, design_path, + brief.get("composite_negative", ""), + size="1024x1024") # 印花设计统一 1024x1024 + result["design_path"] = design_path + result["design_from"] = "product" + print(f"{tag} 纯印花设计稿已生成(product 节点): {design_path}") + except Exception as e: # noqa: BLE001 + errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""}) + print(f"{tag} 设计稿生成失败: {e}") + + # 6) 模板选择按 SPU.mark 决定: + # mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT(图1模特 + 图2印花设计 + 图3底图) + # mark!=1 → 旧两图合成模板 composite_prompt(底图 + 印花设计) + if int(spu.get("mark") or 0) == 1: + print(f"{tag} SPU {spu['code']} mark=1 → 使用三图合成模板(图1模特+图2印花+图3底图)") + if model_img is not None: + # 任务级模特分配(product_node 预分配:一个 SPU 一个模特,SPU 数>模特数循环兜底) + model_copy = prod_dir / f"{img_code}_model{model_img.suffix}" + shutil.copy2(model_img, model_copy) + result["model_path"] = str(model_copy) + result["model_folder"] = model_img.parent.name + print(f"{tag} 模特图(任务级分配,{model_img.parent.name}/): {model_copy}") + else: + print(f"{tag} material_library 无模特图,回退两图合成(composite_prompt)") + else: + print(f"{tag} SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt(底图+印花)") + + # 7) 合成: + # 有模特图 → 三图合成(图1=模特 / 图2=印花设计 / 图3=底图) + # 无模特图 → 两图合成平铺服装图(图3=底图 + 图2=印花设计) + if "design_path" not in result: + print(f"{tag} 无设计稿,跳过合成") + elif model_img is not None: + composite_path = str(prod_dir / f"{img_code}_composite.png") + try: + # 三图合成:优先用简报的 composite_prompt(模板化三图文案),回退内置 MODEL_WEAR_PROMPT + wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT + print(f"{tag} 三图合成提交中(3 参考图 img2img,网关处理约 2-6 分钟,请耐心等待)…") + t0 = time.time() + ib.print(wear_prompt, str(model_img), composite_path, + brief.get("composite_negative", ""), + extra_images=[design_path, str(basemap_img)], # 图2印花, 图3底图 + size="1504x2000") # 三合一统一 1504x2000 + result["composite_path"] = composite_path + print(f"{tag} 三图模特合成图已生成(耗时 {int(time.time()-t0)}s): {composite_path}") + except Exception as e: # noqa: BLE001 + # 合成失败 → 带退避重试(网关超载/超时常见,重试 3 次) + print(f"{tag} 三图合成失败,退避重试…: {e}") + retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path, + brief.get("composite_negative", ""), + extra_images=[design_path, str(basemap_img)], size="1504x2000") + if retried is not None: + result["composite_path"] = composite_path + print(f"{tag} 三图合成重试成功(耗时 {int(time.time()-t0)}s): {composite_path}") + else: + errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""}) + print(f"{tag} 三图合成重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}") + return None + else: + printed_path = str(prod_dir / f"{img_code}_printed.png") + try: + # 两图合成(无模特):用平铺印图文案(wearable_prompt),回退旧 composite_prompt + flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "") + ib.print(flat_prompt, str(basemap_img), printed_path, + brief.get("composite_negative", ""), + extra_images=[design_path], # 图2印花 + size="1504x2000") # 合成统一 1504x2000 + result["printed_path"] = printed_path + print(f"{tag} 平铺服装图已生成(无模特,底图+印花): {printed_path}") + except Exception as e: # noqa: BLE001 + print(f"{tag} 平铺服装图失败,退避重试…: {e}") + retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path, + brief.get("composite_negative", ""), + extra_images=[design_path], size="1504x2000") + if retried is not None: + result["printed_path"] = printed_path + print(f"{tag} 平铺服装图重试成功: {printed_path}") + else: + errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""}) + print(f"{tag} 平铺服装图重试仍失败 → 跳过该产品(不生成标题/不写模板): {e}") + return None + + # 7.2) 多色:单 SPU 多色时每个颜色再执行一次三合一(用各自底图),轮播图按颜色路由 + color_composites: List[Dict[str, Any]] = [] + if model_img is not None and result.get("composite_path"): + # 首色主图始终记录(单色/多色都走模板填充) + color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""), + "composite_path": result["composite_path"]}) + for sc in sku_codes[1:]: + bm = find_basemap(basemap_root, spu["code"], sc) + if bm is None: + print(f"{tag} 颜色 {sc} 无底图,跳过该色三合一") + continue + cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png") + try: + ib.print(MODEL_WEAR_PROMPT, str(model_img), cp, + brief.get("composite_negative", ""), + extra_images=[design_path, str(bm)], # 图2印花, 图3该色底图 + size="1504x2000") # 三合一统一 1504x2000 + col = next((c["color"] for c in colors if c["sku_code"] == sc), sc) + color_composites.append({"sku_code": sc, "color": col, "composite_path": cp}) + print(f"{tag} 颜色 {sc}({col})三合一已生成: {cp}") + except Exception as e: # noqa: BLE001 + errors.append({"node": "product", "type": type(e).__name__, + "message": f"颜色 {sc} 三合一失败: {e}", "trace": ""}) + result["color_composites"] = color_composites + + # 7.5) 多模态标题生成:合成图/平铺图/设计稿 → 中英双语 SEO 标题(按国家路由模板) + if title_backend is not None: + title_img = (result.get("composite_path") or result.get("printed_path") + or result.get("design_path")) + if title_img: + t = title_backend.generate_title(title_img, country=country) + if t.get("en_title") or t.get("cn_title") or t.get("ja_title"): + result["en_title"] = t.get("en_title", "") + result["cn_title"] = t.get("cn_title", "") + result["ja_title"] = t.get("ja_title", "") + print(f"{tag} 标题已生成: EN={t.get('en_title','')[:50]}... " + f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...") + + return result + + +@with_fallback("product") +def product_node(state: Dict[str, Any]) -> Dict[str, Any]: + config = state["config"] + pcfg = config.get("product") or {} + stats = dict(state.get("stats") or {}) + if not pcfg.get("enabled", True): + return {"product": [], "stats": stats} + + country = state["country"] + briefs = state.get("briefs") or [] + output_dir = Path(state["output_dir"]) # 本次任务产物(时间戳文件夹) + cache_dir = Path(state.get("cache_dir") or output_dir) # 缓存/去重(根目录) + errors = list(state.get("errors") or []) + + # 路径解析:相对路径 → 优先运行根(exe 旁自定义数据),其次数据根(打包=_MEIPASS 内置) + def _abs(key: str, default: str) -> Path: + p = Path(pcfg.get(key, default)) + if p.is_absolute(): + return p + for root in (runtime_root(), project_root()): + cand = root / p + if cand.exists(): + return cand + return project_root() / p + + db_path = _abs("db_path", "db/spu_sku.db") + basemap_root = _abs("basemap_dir", "basemap") + material_root = _abs("material_library_dir", "material_library") + category = pcfg.get("model_category", "T-shirt") + brief_index = int(pcfg.get("brief_index", 0)) + spu_code = (pcfg.get("spu_code") or "").strip() + sku_code = (pcfg.get("sku_code") or "").strip() + spu_tasks = pcfg.get("spu_tasks") or [] + spu_count = int(pcfg.get("spu_count") or 0) + + # 1) 选简报(优先 safe) + safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs + if not safe: + print("[product] 无可用简报,跳过产品图生成") + return {"product": [], "stats": stats} + brief = safe[brief_index] if brief_index < len(safe) else safe[0] + + # 2) 图像后端 + backend_name = (pcfg.get("backend") or "").strip() + ib = None + if backend_name: + from graph.backends import get_image_backend + ib = get_image_backend(backend_name) + if ib is not None: + ib.bind_config(config.get("compose") or {}) # 复用 compose.api_key/model/size + + # 3) 构造款号工作清单:spu_tasks(多款号)优先;否则 spu_code / 自动第一个 + spus = list_spus(db_path) + worklist: List[tuple] = [] # (spu, skus, brief) —— 每个款号可绑定自己的热点简报 + by_topic = {str(b.get("topic", "")).lower(): b for b in briefs} + if not spus: + print(f"[product] db 无 SPU 数据({db_path}),跳过") + return {"product": [], "stats": stats} + if spu_tasks: + for ti, t in enumerate(spu_tasks): + code = (t.get("spu") or t.get("spu_code") or "").strip() + spu = next((s for s in spus if s["code"] == code), None) + if spu is None: + print(f"[product] 任务款号 {code} 不在 db,跳过(可选: {[s['code'] for s in spus][:12]})") + continue + tb = None + tp = (t.get("topic") or "").strip() + if tp: + tb = by_topic.get(tp.lower()) + if tb is None: + print(f"[product] 任务热点「{tp}」不在简报中,回退按序号分配") + if tb is None: + # 未指定热点(完整流水线):按任务序号取不同简报,避免多个产品用同一个 + idx = min(ti, len(safe) - 1) if safe else brief_index + tb = safe[idx] if safe else None + if tb is None: + print(f"[product] 无可用简报,跳过任务 {code}") + continue + worklist.append((spu, (t.get("skus") or "").strip(), tb)) + if not worklist: + print("[product] 任务清单无有效款号,跳过产品图生成") + return {"product": [], "stats": stats} + elif spu_code: + spu = next((s for s in spus if s["code"] == spu_code), None) + if spu is None: + print(f"[product] 款号 {spu_code} 不在 db,可选: {[s['code'] for s in spus][:12]}") + return {"product": [], "stats": stats} + worklist.append((spu, sku_code, brief)) + else: + spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None) + if spu is None: + print(f"[product] 没有任何款号存在本地底图({basemap_root}/<款号>//)") + return {"product": [], "stats": stats} + worklist.append((spu, sku_code, brief)) + + # 4) 不再按 spu_count 截断:worklist 已是扩展后的完整任务(数量 N = 每款设计数), + # 全部任务进入队列处理(并发 5)。 + + prod_dir = output_dir / "product" + prod_dir.mkdir(parents=True, exist_ok=True) + + # 4.1) 任务级模特分配(material_library-): + # 一个 SPU 对应一个模特;SPU(不同款)数 > 模特数 → 从全部模特循环兜底(允许重复) + model_assign: Dict[str, Any] = {} + _all_models: List[str] = [] + try: + _folder, _all_models = find_first_model_folder(material_root, category) + except Exception: # noqa: BLE001 + _all_models = [] + if _all_models: + seen_spu: Dict[str, str] = {} + for _i, (_spu, _skus, _tb) in enumerate(worklist): + code = _spu.get("code", "") + if code not in seen_spu: + seen_spu[code] = _all_models[_i % len(_all_models)] # SPU>模特数 → 循环兜底 + model_assign[code] = seen_spu[code] + print(f"[product] 任务级模特分配:{len(seen_spu)} 个 SPU,模特池 {len(_all_models)} 张" + f"{'(SPU>模特,循环兜底)' if len(seen_spu) > len(_all_models) else ''}") + + # 5) compose 节点生成的共享设计稿(图2):每个任务用自己的热点简报设计(tb.design_path), + # 一个货号对应一个设计(多颜色共用该设计),不再所有产品共用第一个 + designs_dir = output_dir / "designs" + designs_dir.mkdir(parents=True, exist_ok=True) + designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "") + for d in (state.get("designs") or []) if isinstance(d, dict)} + for _d in (state.get("briefs") or []): + if isinstance(_d, dict) and _d.get("design_path"): + designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path")) + + def _resolve_design(tb) -> Optional[str]: + """任务绑定的简报 → 该热点自己的设计稿路径(按货号命名拷贝到 designs/)。""" + topic = str(tb.get("topic", "")).strip().lower() + src = designs_map.get(topic) or tb.get("design_path") + if not src or not Path(src).exists(): + return None + return src + + # 6) 逐个款号处理(每款号用自己绑定的热点简报) + title_backend = None + ls_cfg = config.get("llm_screen") or {} + if (ls_cfg.get("provider") or "") not in ("", "mock"): + try: + from graph.llms import get_backend as _glb + _tb = _glb(ls_cfg.get("provider")) + _tb.bind_config(ls_cfg) + if _tb.has_key: + title_backend = _tb + except Exception as e: # noqa: BLE001 + print(f"[product] 标题后端初始化失败: {e}") + + results: List[Dict[str, Any]] = [] + prefix = str(pcfg.get("code_prefix") or "DG").strip() + # 并发:默认每个 SPU 一个独立线程(任务数即并发数,提速); + # config.product.concurrency 显式配置可覆盖(如限流时设 3-5); + # 默认并发上限 5:全量并发(任务数)会压垮图像网关(10 并发 → 全部超时), + # 超出上限的任务在线程池排队,逐批处理 + concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5) + print(f"[product] 并发 {concurrency}(每 SPU 一线程,上限 {concurrency})处理 {len(worklist)} 个产品任务") + + def _run_one(idx: int, spu, skus, tb): + """并发执行单个产品:返回 (result or None, img_code)。失败由 _process_spu 内部兜底。""" + img_code = f"{prefix}{idx:03d}" # 货号:图片按此命名(DG000_design.png…) + try: + # 每个任务用自己的热点设计(designs_map),并拷贝为货号命名(designs/DG000_design.png) + design_src = _resolve_design(tb) + design_path = None + if design_src: + design_path = str(designs_dir / f"{img_code}_design.png") + try: + shutil.copy2(design_src, design_path) + except Exception: # noqa: BLE001 + design_path = design_src + tb = dict(tb) + tb["design_path"] = design_path + r = _process_spu(db_path, basemap_root, material_root, category, prod_dir, + tb, ib, spu, skus, pcfg, errors, design_path, title_backend, + country, img_code=img_code, + model_img=model_assign.get(spu.get("code", ""))) + if r: + r["img_code"] = img_code + return r, img_code + except Exception as e: # noqa: BLE001 # 单产品任何异常都不拖垮整体 + print(f"[product/{img_code}] 产品处理异常(跳过该产品): {e}") + return None, img_code + + import concurrent.futures + # 货号自动续号:任务一开始全部按序分配(start_idx 起),不覆盖已生成的产物 + start_idx = _next_img_idx(prod_dir, prefix) + with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex: + futures = [ex.submit(_run_one, start_idx + i, spu, skus, tb) + for i, (spu, skus, tb) in enumerate(worklist)] + for f in concurrent.futures.as_completed(futures): + r, img_code = f.result() + if r: + results.append(r) + try: + _record_used(cache_dir, r) # (热点-风格) 去重记录 → 缓存根目录 + except Exception: # noqa: BLE001 + pass + + results.sort(key=lambda x: x.get("img_code", "")) # 按货号排序,模板/清单顺序稳定 + _write_products(prod_dir, results) + stats["product"] = { + "spus": [r.get("spu_code") for r in results], + "skus": [r.get("sku_code") for r in results], + "topic": brief.get("topic", ""), + "count": len(results), + "composite": sum(1 for r in results if r.get("composite_path")), + "printed": sum(1 for r in results if r.get("printed_path")), + "templates": sum(1 for r in results if r.get("template_path")), + "output_dir": str(prod_dir), + } + return {"product": results, "stats": stats, "errors": errors} + + +def _record_used(output_dir: Path, r: Dict[str, Any]): + """记录已用 (热点-风格-配色),供后续去重:output/<国家>/used_designs.json。""" + topic = r.get("topic", "") + if not topic: + return + with _USED_LOCK: # 并发下 used_designs.json 读写互斥 + p = output_dir / "used_designs.json" + used = [] + if p.exists(): + try: + used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or [] + except Exception: + used = [] + entry = { + "topic": topic, + "art_style": r.get("art_style", ""), + "color_palette": r.get("color_palette", ""), + "spu_code": r.get("spu_code", ""), + "sku_code": r.get("sku_code", ""), + "date": time.strftime("%Y-%m-%d"), + } + # 同 (topic, art_style) 已记录则跳过,避免去重记录重复堆积 + if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower() + and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower() + for u in used): + return + used.append(entry) + p.write_text(json.dumps( + {"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used}, + ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_products(prod_dir: Path, products: List[Dict[str, Any]]): + (prod_dir / "products.json").write_text( + json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products}, + ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/graph/nodes/prompt_node.py b/graph/nodes/prompt_node.py new file mode 100644 index 0000000..82d041e --- /dev/null +++ b/graph/nodes/prompt_node.py @@ -0,0 +1,90 @@ +"""节点 5/6:提示词构造(prompt_build)。 + +读取 prompts// 的 extra 风格规则,用固定模板装配四种最终提示词 +(image_prompt / wearable_prompt / composite_prompt / composite_negative)。 +四要素缺失时用 derive_style_palette 动态兜底,保证每条提示词结构一致、有规则。 +""" +from typing import Any, Dict, List +import random + +from graph.style_rules import derive_style_palette, derive_composition +from graph.templates import assemble_prompts +from graph.validate import validate_brief, with_fallback + +# 图像生成策略敏感词 → 安全等效描述(生成设计稿前清洗 motif, +# 避免 gpt-image 等内容策略频繁拦截导致"生图限制多") +_IMG_RISKY_SWAP = { + "skull": "smiley mascot", "skeleton": "cute mascot", "blood": "red accents", + "gore": "bold shapes", "gun": "star", "weapon": "tool", "bomb": "firework", + "drug": "confetti", "demon": "cute monster", "devil": "mischievous imp", + "occult": "mystic pattern", "satanic": "dark pattern", "nazi": "retro emblem", + "hitler": "retro emblem", "zombie": "friendly ghoul", "horror": "spooky-cute", + "vampire": "night owl", "politics": "abstract shapes", "political": "abstract", + "president": "captain", "army": "team", "police": "officer", +} + + +def _safe_motif(motif: str) -> str: + """清洗 motif 中的图像策略敏感词(替换为安全等效描述),降低生图内容政策拦截率。""" + low = motif.lower() + for k, v in _IMG_RISKY_SWAP.items(): + if k in low: + # 按词边界替换(避免误伤 "letterhead" 等) + import re + motif = re.sub(rf"\b{re.escape(k)}\b", v, motif, flags=re.IGNORECASE) + low = motif.lower() + return motif + + +@with_fallback("prompt_build") +def prompt_node(state: Dict[str, Any]) -> Dict[str, Any]: + screened: List[Dict[str, Any]] = state.get("screened") or [] + config = state["config"] + country = state["country"] + cc = state["country_config"] + extra_rules = cc.get("extra_style_rules") or [] + tpls = config.get("prompt_templates") or {} + + briefs: List[Dict[str, Any]] = [] + for r in screened: + r = validate_brief(r) + art, pal = derive_style_palette( + r["topic"], country, extra_rules=extra_rules, category=r.get("design_category") + ) + motif = (r.get("motif") or "").strip() or r.get("topic", "") + cleaned = _safe_motif(motif) + if cleaned != motif: + print(f"[prompt] motif 敏感词清洗: 「{motif}」→「{cleaned}」(降低生图内容政策拦截)") + r["motif"] = cleaned + motif = cleaned + art_style = (r.get("art_style") or art).strip() + palette = (r.get("color_palette") or pal).strip() + composition = (r.get("composition") or derive_composition(r["topic"], r.get("design_category"))).strip() + + prompts = assemble_prompts(motif, art_style, palette, composition, tpls, country) + # 文字印花(约 30% 概率):简报有 slogan 时,随机注入文字段到设计稿提示词 + slogan = (r.get("slogan") or "").strip() + if slogan and random.random() < float(config.get("prompt_templates", {}).get("text_ratio", 0.3)): + text_seg = (f', with the text "{slogan}" rendered as bold retro typography, ' + f'lettering clean and correctly spelled, high contrast, as the focal text of the print') + prompts["image_prompt"] = prompts["image_prompt"] + text_seg + r["used_slogan"] = slogan + # review(疑似商标/受保护主题)→ 动态注入「原创化魔改」引导:只做风格参考,禁止复刻品牌/商标/角色, + # 换名换细节,生成通用非侵权的致敬式设计 + if str(r.get("risk_level", "")).strip().lower() == "review": + prompts["image_prompt"] = (prompts["image_prompt"] + + " IMPORTANT: this theme is ONLY a loose stylistic reference. " + "Do NOT reproduce any brand logo, trademark, character, mascot, copyrighted artwork or real person. " + "Create a fully ORIGINAL design with a different name and distinct visual details and colors — " + "a generic, non-infringing homage in the same mood, clearly distinct from the original.") + print(f"[prompt] review 简报注入原创化魔改引导: 「{r['topic']}」") + r.update(prompts) + r["motif"] = motif + r["art_style"] = art_style + r["color_palette"] = palette + r["composition"] = composition + briefs.append(r) + + stats = dict(state.get("stats") or {}) + stats["prompt"] = {"briefs": len(briefs)} + return {"briefs": briefs, "stats": stats} diff --git a/graph/nodes/score_node.py b/graph/nodes/score_node.py new file mode 100644 index 0000000..a6cb766 --- /dev/null +++ b/graph/nodes/score_node.py @@ -0,0 +1,26 @@ +"""节点 3/6:打分(score)。 + +归一化(按 source/kind 分组 min-max)-> 跨源融合(combine)-> 综合分阈值预筛。 +纯逻辑节点,with_fallback 兜底。 +""" +from typing import Any, Dict, List + +from graph.scoring import combine, normalize +from graph.validate import with_fallback + + +@with_fallback("score") +def score_node(state: Dict[str, Any]) -> Dict[str, Any]: + rows: List[Dict[str, Any]] = state.get("filtered_rows") or [] + config = state["config"] + weights = config.get("weights") or {} + llm_cfg = config.get("llm_screen") or {} + min_score = float(llm_cfg.get("min_score", 0.0)) + + normalize(rows) + combined = combine(rows, weights) + combined = [c for c in combined if float(c.get("score", 0)) >= min_score] + + stats = dict(state.get("stats") or {}) + stats["score"] = {"combined": len(combined)} + return {"scored_rows": combined, "stats": stats} diff --git a/graph/nodes/screen_node.py b/graph/nodes/screen_node.py new file mode 100644 index 0000000..5962108 --- /dev/null +++ b/graph/nodes/screen_node.py @@ -0,0 +1,127 @@ +"""节点 4/6:合规筛选(screen)。 + +调用可插拔 LLM 后端做合规筛查 + 结构化四要素;后端调用失败时降级 MockBackend。 +按 topic 把筛查结果映射回 scored 候选(补 score/sources/country),再做最终风险过滤 +(blocked 丢弃;review 按 keep_review 决定)。 +""" +from pathlib import Path +from typing import Any, Dict, List + +from graph.loader import load_system_prompt +from graph.llms import DEFAULT_SYSTEM_PROMPT, get_backend +from graph.style_rules import COUNTRY_AESTHETICS +from graph.validate import with_fallback + + +@with_fallback("screen") +def screen_node(state: Dict[str, Any]) -> Dict[str, Any]: + country = state["country"] + scored: List[Dict[str, Any]] = state.get("scored_rows") or [] + config = state["config"] + cc = state["country_config"] + llm_cfg = config.get("llm_screen") or {} + provider = llm_cfg.get("provider", "mock") + keep_review = bool(llm_cfg.get("keep_review", False)) + batch_size = int(llm_cfg.get("max_topics_per_call", 12)) + blacklist = [str(b).lower() for b in (config.get("blacklist") or [])] + + prompts_dir = Path(state["prompts_dir"]) + system_prompt = load_system_prompt(prompts_dir, DEFAULT_SYSTEM_PROMPT) + aesthetic_hint = cc.get("style_hint") or COUNTRY_AESTHETICS.get(country, {}).get("style_hint", "") + + # 排除已用热点(去重生效:已用 topic 不再进入本次简报,每次跑都用新热点) + try: + import json as _json + used_p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "used_designs.json" + if used_p.exists(): + ud = _json.loads(used_p.read_text(encoding="utf-8")).get("used", []) or [] + used_topics = {str(u.get("topic", "")).strip().lower() for u in ud} + before = len(scored) + scored = [c for c in scored if str(c.get("topic", "")).strip().lower() not in used_topics] + if len(scored) < before: + print(f"[screen] 排除已用热点 {before - len(scored)} 条(去重),剩余 {len(scored)} 条可选") + except Exception: # noqa: BLE001 + pass + + # 简报数量 = 用多少生成多少:llm_screen.max_briefs 配置优先,否则按扩展后的总任务数 + #(每个产品一个热点;数量 N=每个款-颜色条目的设计数 → 总任务=条目数×N); + # 前台显示不依赖简报(读 collected 完整池 + used_designs 剔除已用,用完即从前台消失)。 + pcfg = config.get("product") or {} + ls_cfg = config.get("llm_screen") or {} + limit = int(ls_cfg.get("max_briefs") or 0) + if not limit and pcfg.get("spu_tasks"): + limit = len(pcfg.get("spu_tasks") or []) # 扩展后总任务数(=产品数) + if not limit: + limit = int(pcfg.get("spu_count") or 0) + if limit > 0: + ordered = sorted(scored, key=lambda c: -(float(c.get("score") or 0))) + scored_limited = ordered[:limit] + print(f"[screen] 简报限量 {limit} → 筛前 {len(scored_limited)} 个高分热点(共 {len(scored)} 个)") + else: + scored_limited = scored + print(f"[screen] 简报全量 {len(scored_limited)} 条(未限量,全部生成简报)") + + topics = [c["topic"] for c in scored_limited] + + backend = get_backend(provider) + if provider != "mock": + backend.bind_config(llm_cfg) + if not backend.has_key: + print("[screen] 未检测到 LLM api_key(请配置 llm_screen.api_key 或环境变量 " + "LLM_API_KEY/OPENAI_API_KEY),降级 Mock 兜底。") + backend = get_backend("mock") + try: + screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size) + except Exception as e: # noqa: BLE001 + print(f"[screen] {provider} 调用失败,降级 Mock: {e}") + backend = get_backend("mock") + screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size) + + # 映射回 scored 候选 + by_topic = {s.get("topic", "").lower(): s for s in screened} + out: List[Dict[str, Any]] = [] + missing: List[Dict[str, Any]] = [] + for it in scored_limited: + s = by_topic.get(it["topic"].lower()) + if s is None: + missing.append(it) + continue + s = dict(s) + s["country"] = country + s["score"] = it.get("score", 0) + s["sources"] = it.get("sources", "") + out.append(s) + + # LLM 漏判的主题用 Mock 单独补全,避免丢数据(而非整批降级) + if missing: + print(f"[screen] LLM 漏判 {len(missing)} 个主题,用 Mock 单独补全:" + f"{[m['topic'] for m in missing]}") + mock = get_backend("mock") + m_res = mock.screen( + [m["topic"] for m in missing], country, aesthetic_hint, + system_prompt, blacklist, batch_size, + ) + m_by = {r.get("topic", "").lower(): r for r in m_res} + for it in missing: + s = m_by.get(it["topic"].lower()) + if s is None: + continue + s = dict(s) + s["country"] = country + s["score"] = it.get("score", 0) + s["sources"] = it.get("sources", "") + out.append(s) + + # 最终风险过滤:只滤 blocked(硬拦截);review(待复核)保留—— + # 由 assign_hotspots 的 allow_review 决定是否参与分配(openai 模式 review+concept 可用), + # 避免 review 被静默丢弃导致"任务 N 个但简报不足、设计缺失" + kept: List[Dict[str, Any]] = [] + for r in out: + lvl = r.get("risk_level", "safe") + if lvl == "blocked": + continue + kept.append(r) + + stats = dict(state.get("stats") or {}) + stats["screen"] = {"screened": len(out), "kept": len(kept)} + return {"screened": kept, "stats": stats} diff --git a/graph/nodes/seed_node.py b/graph/nodes/seed_node.py new file mode 100644 index 0000000..ebd6418 --- /dev/null +++ b/graph/nodes/seed_node.py @@ -0,0 +1,190 @@ +"""节点 0/6:动态种子词(seed)。 + +在 fetch 之前运行:收集「trending 派生 + 历史 safe 热点 + 月份/节日」上下文, +按 seed_provider 策略(static / mock / LLM)生成/合并种子词,注入 country_config 的 +style.seeds / related.seed_keywords,供后续 fetch 的 related_queries 展开使用。 + +关键机制:动态种子词按 (国家, provider, 日期) 缓存(.cache/seeds/)。 +同一天内多次运行使用同一套种子词 → related_queries 的 24h 缓存稳定命中, +避免「history 每次跑完都变 → 种子词震荡 → Google 反复全量重抓 → 429 限流」。 + +带 with_fallback:任何异常都降级为"仅用 yaml 静态种子",不阻塞整图。 +""" +import datetime +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Dict + +from graph.llms import get_backend +from graph.llms.mock_backend import COMMON_RISK_WORDS +from graph.paths import runtime_root +from graph.scoring import filter_person_names, filter_query_noise +from graph.seeds import get_seed_strategy +from graph.seeds.holidays import build_holiday_context +from graph.sources.google_trends_source import fetch_trending +from graph.validate import with_fallback + +_CACHE_DIR = runtime_root() / ".cache" / "seeds" + + +def _cache_key(country: str, provider: str, cfg: Dict[str, Any]) -> str: + """缓存键含「配置指纹」:改了种子相关参数(数量/上下文上限)即换新键重新生成, + 避免命中旧参数生成的种子;旧文件保留(不删缓存,取最新)。""" + fp = hashlib.md5( + json.dumps( + {k: cfg.get(k) for k in ("max_style_seeds", "max_related_seeds", + "trending_context_limit", "history_limit")}, + sort_keys=True, ensure_ascii=False, + ).encode("utf-8") + ).hexdigest()[:8] + return f"{country}-{provider}-{fp}-{datetime.date.today().isoformat()}" + + +def _cache_get(key: str): + try: + p = _CACHE_DIR / f"{key}.json" + if p.exists(): + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + pass + return None + + +def _cache_set(key: str, val: Dict[str, Any]): + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + (_CACHE_DIR / f"{key}.json").write_text( + json.dumps(val, ensure_ascii=False), encoding="utf-8") + except Exception: + pass + + +@with_fallback("seed") +def seed_node(state: Dict[str, Any]) -> Dict[str, Any]: + country = state["country"] + config = state["config"] + cc = dict(state.get("country_config") or {}) + errors = list(state.get("errors") or []) + + provider = (config.get("seed_provider") or "mock").strip().lower() + cfg = config.get("seed_provider_cfg") or {} + trending_limit = int(cfg.get("trending_context_limit", 15)) + history_limit = int(cfg.get("history_limit", 20)) + max_style = int(cfg.get("max_style_seeds", 12)) + max_related = int(cfg.get("max_related_seeds", 12)) + guard = COMMON_RISK_WORDS + [b.lower() for b in (config.get("blacklist") or [])] + + ckey = _cache_key(country, provider, cfg) + cached = _cache_get(ckey) if provider != "static" else None + from_cache = cached is not None + + if cached is not None: + res = cached + context: Dict[str, Any] = { + "country": country, + "max_style_seeds": max_style, + "max_related_seeds": max_related, + } + else: + # 1) 收集上下文 + context: Dict[str, Any] = { + "country": country, + "max_style_seeds": max_style, + "max_related_seeds": max_related, + } + try: + tl = int((cc.get("trending") or {}).get("limit", 40)) + rows = fetch_trending(geo=country, limit=min(tl, 40)) + # 保留 rows 自带的 source=gt_trending,filter_person_names 的人名模式仅对该源生效 + kept, _ = filter_query_noise(rows, enabled=True) + kept, _ = filter_person_names(kept) + kept = [r for r in kept if not any(w and w in r["topic"].lower() for w in guard)] + context["trending_seeds"] = [r["topic"] for r in kept][:trending_limit] + except Exception as e: # noqa: BLE001 + print(f"[seed] trending 上下文收集失败(跳过): {e}") + context["trending_seeds"] = [] + + try: + p = os.path.join(state.get("output_dir", ""), "design_briefs.json") + if os.path.exists(p): + data = json.load(open(p, encoding="utf-8")).get("design_briefs", []) + safe = [d for d in data if d.get("risk_level") == "safe"] + safe.sort(key=lambda d: -(d.get("score") or 0)) + hrows = [{"topic": d["topic"], "source": "history"} for d in safe] + hrows, _ = filter_person_names(hrows, pattern_sources={"history"}) + hrows = [r for r in hrows if not any(w and w in r["topic"].lower() for w in guard)] + context["history_hotspots"] = [r["topic"] for r in hrows][:history_limit] + else: + context["history_hotspots"] = [] + except Exception as e: # noqa: BLE001 + print(f"[seed] 历史热点读取失败(跳过): {e}") + context["history_hotspots"] = [] + + # 月份/节日(按国家:各国节日表不同) + hol = build_holiday_context(country) + context["season"] = hol["season"] + context["year"] = hol["year"] + context["month"] = hol["month"] + context["date"] = hol["date"] + context["month_themes"] = hol["month_themes"] + context["upcoming_holidays"] = hol["upcoming_holidays"] + + # 2) 选策略 + LLM 后端 + strategy = get_seed_strategy(provider) + llm_backend = None + if provider != "static": + llm_backend = get_backend(provider) + # 注入 llm_screen 配置(api_key/base_url/model),否则 has_key 永远 False 降级 mock + try: + llm_backend.bind_config(config.get("llm_screen") or {}) + except Exception as e: # noqa: BLE001 + print(f"[seed] LLM 配置绑定失败: {e}") + if provider not in ("mock",) and not getattr(llm_backend, "has_key", False): + print(f"[seed] {provider} 未配置 API key,降级 mock 规则生成种子词") + llm_backend = get_backend("mock") + + # 3) 生成/合并种子词 + try: + res = strategy.resolve(country, cc, context, llm_backend) + except Exception as e: # noqa: BLE001 + print(f"[seed] 策略解析失败,回退静态种子: {e}") + res = { + "style_seeds": list((cc.get("style", {}) or {}).get("seeds", []) or []), + "related_seeds": list((cc.get("related", {}) or {}).get("seed_keywords", []) or []), + "dynamic": False, + } + if provider != "static": + _cache_set(ckey, res) + + # 4) 注入 cc + style_block = dict(cc.get("style") or {}) + related_block = dict(cc.get("related") or {}) + style_block["seeds"] = res["style_seeds"] + related_block["seed_keywords"] = res["related_seeds"] + cc["style"] = style_block + cc["related"] = related_block + + stats = dict(state.get("stats") or {}) + stats["seed"] = { + "provider": provider, + "dynamic": res.get("dynamic", False), + "from_cache": from_cache, + "style_count": len(res["style_seeds"]), + "related_count": len(res["related_seeds"]), + "trending_ctx": len(context.get("trending_seeds", [])), + "history_ctx": len(context.get("history_hotspots", [])), + "holidays": context.get("upcoming_holidays", []), + } + print( + f"[seed] provider={provider}{'(当日缓存命中)' if from_cache else ''} " + f"种子词 style={len(res['style_seeds'])} related={len(res['related_seeds'])}" + ) + + return { + "country_config": cc, + "seed_words": res, + "stats": stats, + "errors": errors, + } diff --git a/graph/nodes/seed_shot_node.py b/graph/nodes/seed_shot_node.py new file mode 100644 index 0000000..e4dd7fd --- /dev/null +++ b/graph/nodes/seed_shot_node.py @@ -0,0 +1,133 @@ +"""节点 8/8:种草图生成(seed_shot)——在 oss_upload 之后。 + +对每个 product 的合成图(图1),按 seed_shot_templates.yaml 模板 + model_features.yaml 随机模特特征 +生成 N 张种草图(config.seed_shot.count,默认 1): + - [商品名称] ← product 的 cn_title(上一节点多模态生成) + - [材质] ← 数据库 SPU.material 字段 + - [模特特征] ← model_features.yaml 随机一条 +种草图同样压缩上传到 OSS(货号计数与 oss_upload 共用 state["oss_seq"] 续接)。 + +未配置图像后端 / 无合成图 / count=0 时跳过,不中断。 +""" +import time +from pathlib import Path +from typing import Any, Dict, List + +from graph.validate import with_fallback + + +@with_fallback("seed_shot") +def seed_shot_node(state: Dict[str, Any]) -> Dict[str, Any]: + products: List[Dict[str, Any]] = state.get("product") or [] + config = state["config"] or {} + country = state.get("country", "") + output_dir = Path(state["output_dir"]) + + ss_cfg = config.get("seed_shot") or {} + count = int(ss_cfg.get("count", 1)) + if not bool(ss_cfg.get("enabled", True)) or count <= 0 or not products: + return {"seed_shots": [], "stats": state.get("stats") or {}} + + # 图像后端(复用 compose 配置) + compose_cfg = config.get("compose") or {} + ib = None + if compose_cfg.get("backend"): + from graph.backends import get_image_backend + try: + ib = get_image_backend(compose_cfg["backend"]) + if ib is not None: + ib.bind_config(compose_cfg) + except Exception as e: # noqa: BLE001 + print(f"[seed_shot] 图像后端不可用: {e}") + if ib is None: + print("[seed_shot] 未配置 compose.backend(openai/mock),跳过种草图生成") + return {"seed_shots": [], "stats": state.get("stats") or {}} + + # 材质映射:db SPU.material(清洗换行) + material_map: Dict[str, str] = {} + try: + from graph.product import list_spus + import yaml + dbp = (config.get("product") or {}).get("db_path", "db/spu_sku.db") + p = Path(dbp) + if not p.is_absolute(): + from graph.paths import project_root, runtime_root + for root in (runtime_root(), project_root()): + if (root / p).exists(): + p = root / p + break + for s in list_spus(str(p)): + m = " ".join(str(s.get("material", "")).replace("\r", " ").replace("\n", " ").split()) + material_map[s["code"]] = m + except Exception as e: # noqa: BLE001 + print(f"[seed_shot] 材质读取失败(用空): {e}") + + from graph.seed_shot import generate_seed_shots + from graph.oss_upload import build_oss_key, compress_for_oss, upload_to_oss + from graph.nodes.oss_upload_node import _gen_rand4, MAX_CODE + + ts = str(state.get("task_timestamp") or time.strftime("%Y%m%d%H%M%S")) + prefix = str(((config.get("product") or {}).get("code_prefix")) or "DG").strip() + seq = int(state.get("oss_seq") or 0) + oss_cfg = config.get("oss") or {} + oss_enabled = bool(oss_cfg.get("enabled", True)) and bool(oss_cfg.get("oss_bucket")) + + all_shots: List[Dict[str, Any]] = [] + shot_dir = output_dir / "seed_shots" + shot_dir.mkdir(parents=True, exist_ok=True) + + import concurrent.futures + import threading as _th + seq_lock = _th.Lock() # seq(货号计数)跨线程共享,需加锁 + + def _shot_one(r: Dict[str, Any]): + """单个产品的种草图生成+上传(每产品独立线程)。""" + nonlocal seq + base = r.get("composite_path") or r.get("printed_path") + if not base or not Path(base).exists(): + print(f"[seed_shot] {r.get('spu_code', '')} 无合成图,跳过种草图") + return None + cn = (r.get("cn_title") or "").strip() or r.get("topic", "") + material = material_map.get(r.get("spu_code", ""), "") + paths = generate_seed_shots(ib, base, cn, material, count, str(shot_dir), + r.get("composite_negative", ""), + size=str((config.get("seed_shot") or {}).get("size") or "1504x2000"), + prefix=r.get("img_code") or r.get("oss_code") or "") + if not paths: + return None + r["seed_shot_paths"] = paths + urls: List[str] = [] + for pth in paths: + with seq_lock: + if seq >= MAX_CODE: + print(f"[seed_shot] 货号计数达上限 999,停止上传种草图") + break + code = f"{prefix}{seq:03d}" + seq += 1 + if oss_enabled: + try: + compressed = compress_for_oss(pth, str(Path(pth).with_suffix(".oss.jpg"))) + url = upload_to_oss(oss_cfg, compressed, + build_oss_key(country, ts, code, _gen_rand4())) + if url: + urls.append(url) + r["seed_shot_urls"] = urls + except Exception as e: # noqa: BLE001 + print(f"[seed_shot] 种草图上传失败 {pth}: {e}") + else: + print(f"[seed_shot] oss 未启用,仅本地保存: {pth}") + return {"spu_code": r.get("spu_code"), "sku_code": r.get("sku_code"), + "paths": paths, "urls": urls} + + # 并发:每个产品一个独立线程(默认);config.seed_shot.concurrency 可覆盖 + seed_concurrency = int((config.get("seed_shot") or {}).get("concurrency") or 0) or len(products) or 1 + if len(products) > 1: + print(f"[seed_shot] 并发 {seed_concurrency} 生成种草图({len(products)} 个产品)") + with concurrent.futures.ThreadPoolExecutor(max_workers=seed_concurrency) as _ex: + for item in _ex.map(_shot_one, products): + if item: + all_shots.append(item) + + stats = dict(state.get("stats") or {}) + stats["seed_shot"] = {"count": len(all_shots), "seq": seq} + return {"seed_shots": all_shots, "product": products, "oss_seq": seq, "stats": stats} diff --git a/graph/nodes/template_export_node.py b/graph/nodes/template_export_node.py new file mode 100644 index 0000000..14980d6 --- /dev/null +++ b/graph/nodes/template_export_node.py @@ -0,0 +1,124 @@ +"""节点 9/9:商品上传模板导出(template_export)——在 seed_shot 之后。 + +把最终结果导入模板: + - SPU货号 / SKU货号 = 设计货号(oss_code,前缀+3位计数) + - 商品名称 = cn_title(多模态标题生成) + - 英文名称 = en_title + - 商品轮播图1:SKU 行按颜色路由(该颜色三合一链接),SPU 行随机一张 + - 详情图文(SPU 行):全部三合一主图链接 + 种草图链接,| 分割 + +需在 oss_upload / seed_shot 之后运行(图床链接与货号已生成)。 +""" +import time +from pathlib import Path +from typing import Any, Dict, List + +from graph.paths import project_root, runtime_root +from graph.validate import with_fallback + + +def _template_out_path(prod_dir: Path, tpl_name: str) -> Path: + """模板输出路径:默认 {tpl_name}_已填写.xlsx;已存在/被占用则自动换名加序号(同款号多产品不互相覆盖)。""" + base = prod_dir / f"{tpl_name}_已填写.xlsx" + try: + with open(base, "ab"): + pass + except OSError: + pass + else: + if not base.exists(): + return base + for i in range(2, 100): + cand = prod_dir / f"{tpl_name}_已填写_{i}.xlsx" + if not cand.exists(): + return cand + return prod_dir / f"{tpl_name}_已填写_{int(time.time())}.xlsx" + + +@with_fallback("template_export") +def template_export_node(state: Dict[str, Any]) -> Dict[str, Any]: + products: List[Dict[str, Any]] = state.get("product") or [] + config = state["config"] or {} + pcfg = config.get("product") or {} + output_dir = Path(state["output_dir"]) + errors = list(state.get("errors") or []) + stats = dict(state.get("stats") or {}) + + # 模板写入时机:所有集合/产品(含种草图、OSS)全部完成后才执行本节点 + print(f"[template] 全部集合({len(products)} 个产品)处理完成,开始统一写入模板…") + + tp = (pcfg.get("template_path") or "").strip() + if not tp: + print("[template] 未配置 product.template_path,跳过模板导出") + return {"stats": stats, "errors": errors} + if not Path(tp).exists(): + cand = None + for root in (runtime_root(), project_root()): + c = root / tp + if c.exists(): + cand = str(c) + break + if cand: + tp = cand + else: + print(f"[template] 模板文件不存在: {tp}") + return {"stats": stats, "errors": errors} + + # db 路径 + dbp = (pcfg.get("db_path") or "db/spu_sku.db") + db_path = Path(dbp) + if not db_path.is_absolute(): + for root in (runtime_root(), project_root()): + if (root / db_path).exists(): + db_path = root / db_path + break + + from graph.template_export import export_product + tdir = (pcfg.get("template_dir") or "").strip() or str(Path(tp).parent) + prod_dir = output_dir / "product" + prod_dir.mkdir(parents=True, exist_ok=True) + + exported: List[str] = [] + skipped = 0 + merged_out: Optional[str] = None # 合并模式:一次任务所有产品填同一个模板 + is_first = True + for r in products: + # 失败跳过:合成图(composite/printed)与标题都失败的产品不写进模板 + has_img = bool(r.get("composite_path") or r.get("printed_path")) + has_title = bool((r.get("cn_title") or "").strip()) + if not (has_img and has_title): + skipped += 1 + print(f"[template] 跳过失败产品 {r.get('spu_code')}/{r.get('img_code','')}: " + f"合成图={'有' if has_img else '无'} 标题={'有' if has_title else '无'}(不写入模板)") + continue + sku_codes = [cc.get("sku_code") for cc in (r.get("color_composites") or [])] + if not sku_codes: + sku_codes = [r.get("sku_code") or ""] + try: + if is_first: + merged_out = str(_template_out_path(prod_dir, "商品上传")) + out = export_product( + db_path, r.get("spu_code", ""), sku_codes, tdir, tp, + merged_out, + images=[], + spu_per_color=True, # 每颜色一个独立 SPU 块(单色多 SPU) + oss_code=r.get("oss_code") or (r.get("color_composites") or [{}])[0].get("code", ""), + cn_title=r.get("cn_title", ""), + en_title=r.get("en_title", ""), + ja_title=r.get("ja_title", ""), + composite_urls=r.get("color_composites") or [], + seed_shot_urls=r.get("seed_shot_urls") or [], + append_to="" if is_first else merged_out, # 首个产品从模板创建,后续追加合并 + markup_percent=float(pcfg.get("markup_percent") or 0), + ) + r["template_path"] = str(out) + exported.append(str(out)) + print(f"[template] 商品上传模板已生成({len(exported)}/{len(products)} 合并): {out}") + except Exception as e: # noqa: BLE001 + errors.append({"node": "template_export", "type": type(e).__name__, + "message": f"模板导出失败 {r.get('spu_code')}: {e}", "trace": ""}) + print(f"[template] 模板导出失败 {r.get('spu_code')}: {e}") + is_first = False + + stats["template_export"] = {"exported": len(exported)} + return {"product": products, "errors": errors, "stats": stats} diff --git a/graph/oss_upload.py b/graph/oss_upload.py new file mode 100644 index 0000000..17e75c8 --- /dev/null +++ b/graph/oss_upload.py @@ -0,0 +1,97 @@ +"""阿里云 OSS 图床:图片压缩(3:4、≥1340×1785、<2MB)+ 上传。 + +- compress_for_oss(image_path, out_path): + 中心裁剪到 3:4 → 缩放/放大到 1340×1785 → JPEG quality 迭代压到 <2MB。 +- upload_to_oss(cfg, local_path, object_key): + 用 oss2 上传到 config.oss 指定的 bucket,返回可访问 URL。 +- build_oss_key(country, timestamp, code, rand4): + key = {国家}/{时间戳}/{货号}_{4位随机}.jpg(货号=前缀+3位计数,000 起最多 999) +- oss 配置(config.yaml oss 段): + oss_bucket / oss_endpoint / oss_key_id / oss_key_secret(或环境变量 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET) +""" +import io +from pathlib import Path +from typing import Optional + +from PIL import Image + +# 目标规格:3:4 宽高比,最小 1340×1785,文件 < 2MB +TARGET_W, TARGET_H = 1340, 1785 +MAX_BYTES = 2 * 1024 * 1024 + + +def compress_for_oss(image_path: str, out_path: str, max_bytes: int = MAX_BYTES) -> str: + """压缩图片到 3:4 / ≥1340×1785 / <2MB(JPEG)。返回输出路径。""" + with Image.open(image_path) as im: + im = im.convert("RGB") + + # 1) 中心裁剪到 3:4 + w, h = im.size + if w / h > 3 / 4: # 太宽 → 裁左右 + new_w = int(h * 3 / 4) + x0 = (w - new_w) // 2 + im = im.crop((x0, 0, x0 + new_w, h)) + elif w / h < 3 / 4: # 太高 → 裁上下 + new_h = int(w * 4 / 3) + y0 = (h - new_h) // 2 + im = im.crop((0, y0, w, y0 + new_h)) + + # 2) 缩放到目标尺寸(≥1340×1785,正好 3:4) + im = im.resize((TARGET_W, TARGET_H), Image.LANCZOS) + + # 3) JPEG quality 迭代,保证 <2MB + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + for quality in (92, 85, 78, 70, 62, 55, 48, 40): + buf = io.BytesIO() + im.save(buf, "JPEG", quality=quality, optimize=True, progressive=True) + if buf.tell() <= max_bytes: + out.write_bytes(buf.getvalue()) + return str(out) + # 全部超限 → 用最低质量兜底(仍可能 >2MB,打印警告) + out.write_bytes(buf.getvalue()) + print(f"[oss] 警告: {Path(image_path).name} 压缩后仍 {buf.tell()/1024/1024:.1f}MB > 2MB(质量 {quality})") + return str(out) + + +def upload_to_oss(cfg: dict, local_path: str, object_key: str) -> Optional[str]: + """上传本地文件到 OSS,返回 URL;配置缺失/失败返回 None(不中断)。""" + bucket = (cfg or {}).get("oss_bucket") or "" + endpoint = (cfg or {}).get("oss_endpoint") or "" + key_id = (cfg or {}).get("oss_key_id") or "" + key_secret = (cfg or {}).get("oss_key_secret") or "" + if not (bucket and endpoint and key_id and key_secret): + print("[oss] 配置缺失(config.oss),跳过上传") + return None + try: + import oss2 + # 直连 session:忽略环境代理(挂 VPN 时代理会拦截国内 OSS); + # oss2.Bucket 的 session 必须是 oss2.Session(内部封装 requests),设其底层 trust_env=False + _session = oss2.Session() + try: + _session.session.trust_env = False + except AttributeError: + pass + auth = oss2.Auth(key_id, key_secret) + bkt = oss2.Bucket(auth, endpoint, bucket, session=_session) + with open(local_path, "rb") as f: + bkt.put_object(object_key, f) + url = f"https://{bucket}.{endpoint}/{object_key}" + print(f"[oss] 已上传: {url}") + return url + except Exception as e: # noqa: BLE001 + print(f"[oss] 上传失败 {local_path}: {e}") + return None + + +def build_oss_key(country: str, timestamp: str, code: str, rand4: str, ext: str = ".jpg") -> str: + """对象 key:{国家}/{时间戳}/{货号}_{4位随机}.jpg(如 GB/20260820150945/DG000_Ab3x.jpg)。""" + safe = lambda s: "".join(c for c in (s or "") if c.isalnum() or c in "-_").strip() + return f"{safe(country)}/{safe(timestamp)}/{safe(code)}_{safe(rand4)}{ext}" + + +def random_code4() -> str: + """4 位随机:大小写英文 + 数字。""" + import random + import string + return "".join(random.choices(string.ascii_letters + string.digits, k=4)) diff --git a/graph/paths.py b/graph/paths.py new file mode 100644 index 0000000..3f09d5e --- /dev/null +++ b/graph/paths.py @@ -0,0 +1,23 @@ +"""路径工具:区分「数据根(只读)」与「运行根(可写)」。 + +开发模式:两者都是项目根(pod_trend_agent/)。 +打包模式(PyInstaller -F): + - 数据根 = sys._MEIPASS(解压的临时目录,只读;config.yaml / configs / prompts 在这里) + - 运行根 = exe 所在目录(可写;output/ 产物与 .cache/ 缓存写到这,避免重启丢失) +""" +import sys +from pathlib import Path + + +def project_root() -> Path: + """数据根:开发=graph 上级目录;打包=_MEIPASS(数据文件解压处)。""" + if getattr(sys, "frozen", False): + return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)) + return Path(__file__).resolve().parent.parent + + +def runtime_root() -> Path: + """运行根(可写):打包=exe 旁;开发=项目根。output/.cache 写这里。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent diff --git a/graph/product.py b/graph/product.py new file mode 100644 index 0000000..730f282 --- /dev/null +++ b/graph/product.py @@ -0,0 +1,102 @@ +"""SPU/SKU 数据库查询 + 底图/模特图资源查找(产品图生成流水线的数据层)。 + +数据关系(已核实 spu_sku.db): + - SPU.code = 款号(如 DG004) + - SKU.code = "款号-颜色编码"(如 DG004-BL01),SKU.color = 中文色名(黑/灰/...) + - basemap 目录 = basemap/<款号>//xxx.jpg + - material_library/<品类>/ 存放模特图 + - SKU.img_url_2~5 = CDN 图 URL(底图/细节/模特图,仅作参考字段) +""" +import sqlite3 +from pathlib import Path +from typing import Any, Dict, List, Optional + +# 支持的图片格式:模特图/底图均按此识别(png/jpg 等常见格式全覆盖;AVIF/GIF/TIFF 亦支持) +IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".avif", ".gif", ".tiff", ".tif"} + + +def _connect(db_path) -> sqlite3.Connection: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + return conn + + +def list_spus(db_path, country: Optional[str] = None) -> List[Dict[str, Any]]: + """全部 SPU(可选按国家过滤)。""" + conn = _connect(db_path) + sql = "SELECT id, code, style, material, printing_type, target_audience, pattern, country, mark FROM SPU" + params: list = [] + if country: + sql += " WHERE country = ?" + params.append(country) + sql += " ORDER BY code" + rows = conn.execute(sql, params).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def list_colors(db_path, spu_code: str) -> List[Dict[str, Any]]: + """款号 → 颜色列表(SKU.code 去重,附中文色名、CDN 底图 URL、最低价)。""" + conn = _connect(db_path) + rows = conn.execute( + """SELECT s.code AS sku_code, s.color, s.img_url_2 AS img_url, MIN(s.price) AS price + FROM SKU s JOIN SPU p ON s.spu_id = p.id + WHERE p.code = ? + GROUP BY s.code, s.color ORDER BY s.code""", (spu_code,)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def find_basemap(basemap_root, spu_code: str, sku_code: str) -> Optional[Path]: + """basemap/<款号>// 下第一张图片;无返回 None。""" + d = Path(basemap_root) / spu_code / sku_code + if not d.exists(): + return None + for f in sorted(d.iterdir()): + if f.is_file() and f.suffix.lower() in IMG_EXTS: + return f + return None + + +def list_model_images(material_root, category: str = "T-shirt") -> List[Path]: + """material_library/<品类>/ 下所有图片;无返回空列表。""" + d = Path(material_root) / category + if not d.exists(): + return [] + return [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS] + + +def find_first_model_folder(material_root, preferred: Optional[str] = None): + """material_library 下「第一个有图片的子目录」及其图片列表。 + + - preferred(如 config 的 model_category)优先:该目录有图就直接用; + - 否则按子目录名排序,取第一个有图的目录; + - 全空返回 (None, [])。 + 返回 (dir_name or None, images: List[Path])。 + """ + root = Path(material_root) + if not root.exists(): + return None, [] + candidates = [] + if preferred: + d = root / preferred + if d.is_dir(): + candidates.append(d) + candidates += [d for d in sorted(root.iterdir()) if d.is_dir()] + seen = set() + for d in candidates: + if d in seen: + continue + seen.add(d) + imgs = [f for f in sorted(d.iterdir()) if f.is_file() and f.suffix.lower() in IMG_EXTS] + if imgs: + return d.name, imgs + return None, [] + + +def first_available_sku(db_path, basemap_root, spu_code: str) -> Optional[str]: + """返回该款号下第一个「本地有底图」的 SKU.code;无则 None。""" + for c in list_colors(db_path, spu_code): + if find_basemap(basemap_root, spu_code, c["sku_code"]) is not None: + return c["sku_code"] + return None diff --git a/graph/product_batch.py b/graph/product_batch.py new file mode 100644 index 0000000..9ee32dd --- /dev/null +++ b/graph/product_batch.py @@ -0,0 +1,309 @@ +"""缓存热点批量产品流程(用户新流程入口)。 + +流程: +1. 选定国家 → 直接加载最新缓存热点(output/<国家>/design_briefs.json,不重跑种子/抓取); + 无缓存时回退跑一次完整流水线(run_country)生成缓存。 +2. 按 SPU 数量(count)取 N 个「未用过」热点(safe 按分降序,跳过 used_designs.json 里已用的), + 一个款号分配一个热点(spu_tasks 每项绑定 topic)。 +3. 调 product_node:每款号用自己热点的 image_prompt 生成设计稿 → 三图合成 → 多模态标题 → 模板导出; + product_node 内部成功后把 (热点-风格-配色) 写入 used_designs.json 去重。 +""" +import json +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +from graph.paths import project_root, runtime_root + +# 全项目 review 兜底:不适合 T 恤印花的类目关键词(美甲/食谱/彩票/赛果/天气/比分/日程等) +_UNSUITABLE = re.compile( + r"\b(nails?|manicure|pedicure|recipes?|cooking|lottery|jackpot|results?|score|scores?|" + r"fixtures?|forecast|weather|temperature|map|directions?|parking|opening hours?|" + r"prices?|price|reviews?|jobs?|salary|mortgage|council tax|election|referendum|" + r"stock market|exchange rate|gas prices?)\b", + re.IGNORECASE, +) + + +def load_cached_briefs(output_dir: Path) -> List[Dict[str, Any]]: + """读缓存简报(design_briefs.json,含 image_prompt/composite_prompt 四要素)。""" + p = output_dir / "design_briefs.json" + if not p.exists(): + return [] + try: + data = json.loads(p.read_text(encoding="utf-8")) + return [b for b in (data.get("design_briefs") or []) + if b.get("motif") and b.get("image_prompt")] + except Exception: + return [] + + +def load_used(output_dir: Path) -> List[Dict[str, Any]]: + p = output_dir / "used_designs.json" + if not p.exists(): + return [] + try: + return json.loads(p.read_text(encoding="utf-8")).get("used", []) or [] + except Exception: + return [] + + +def fingerprint(b: Dict[str, Any]) -> str: + """(热点-风格) 指纹,用于去重(不按配色,配色不影响主题唯一性)。""" + return "|".join(str(b.get(k, "")).strip().lower() for k in ("topic", "art_style")) + + +def assign_hotspots(briefs: List[Dict[str, Any]], used: List[Dict[str, Any]], + count: int, allow_review: bool = False, + exclude_topics: Optional[List[str]] = None) -> List[Dict[str, Any]]: + """三级热点分配(严格 → 放宽 → 兜底): + + ① 热点去重:只用「没用过」的热点(topic 不在 used_designs) + ② 风格去重:热点池不够时放宽——同一热点允许换风格((topic, art_style) 组合未用过) + ③ 规则匹配:还不够时兜底——全部简报按分数/规则取(允许完全重复,mock 风格) + + allow_review=True(openai 模式,LLM 已安全改写):review 且带 concept 的也进候选池。 + exclude_topics:不分配黑名单(如地名/美甲/版权剧名/真实人物等,来自国家配置 exclude_topics)。 + 每级内部按 score 降序 + 高分池随机(不总取第一个)。""" + import random + used_topics = {str(u.get("topic", "")).strip().lower() for u in used} + used_fp = {fingerprint(u) for u in used} + safe = sorted([b for b in briefs if b.get("risk_level") == "safe"], + key=lambda b: -(b.get("score") or 0)) + pool = list(safe) + if allow_review: + reviewed = [b for b in briefs if b.get("risk_level") == "review" and (b.get("concept") or "").strip()] + pool += sorted(reviewed, key=lambda b: -(b.get("score") or 0)) + # 黑名单过滤:不分配的热点(国家配置 exclude_topics,大小写不敏感) + if exclude_topics: + ban = {str(x).strip().lower() for x in exclude_topics if str(x).strip()} + _before = len(pool) + pool = [b for b in pool if str(b.get("topic", "")).strip().lower() not in ban] + if len(pool) < _before: + print(f"[batch] 黑名单过滤 {_before - len(pool)} 个不分配热点(exclude_topics)") + # 全项目 review 兜底:剔除不适合 T 恤印花的类目(美甲/食谱/彩票/赛果/天气等通用识别) + _before2 = len(pool) + pool = [b for b in pool if not _UNSUITABLE.search(str(b.get("topic", "")))] + if len(pool) < _before2: + print(f"[batch] review 兜底剔除 {_before2 - len(pool)} 个不适合类目热点(美甲/食谱/彩票/赛果/天气等)") + + def _shuffle_top(stage: List[Dict[str, Any]], need: int) -> List[Dict[str, Any]]: + k = max(need * 2, 4) + top, rest = stage[:k], stage[k:] + random.shuffle(top) + return top + rest + + # ① 热点去重(topic 未用过) + stage1 = [b for b in pool if str(b.get("topic", "")).strip().lower() not in used_topics] + # ② 风格去重(topic 用过,但 热点-风格 指纹未用过) + stage2 = [b for b in pool if str(b.get("topic", "")).strip().lower() in used_topics + and fingerprint(b) not in used_fp] + # ③ 规则匹配兜底(剩余未用指纹,允许低分热点;已用指纹一律不重复出) + stage3 = [b for b in pool if fingerprint(b) not in used_fp] + + out: List[Dict[str, Any]] = [] + out_fp: set = set() # 本批内 (topic, style) 指纹去重 + for si, stage in enumerate((stage1, stage2, stage3)): + for b in _shuffle_top(stage, count - len(out)): + if len(out) >= count: + break + # 去重策略(用户指定): + # ① 先热点去重:本批内优先不同 topic(stage1 全未用热点); + # ② 热点用完后自动切风格去重:同一热点换新风格(topic 可重复,style 不同, + # 即 热点1-风格1 → 热点1-风格2 → 热点2-风格2); + # fingerprint(topic+art_style)本批内绝不重复,保证同热点同风格只出一次。 + fp = fingerprint(b) + if fp in out_fp: + continue + # 热点去重优先:本批已用过的 topic 只在「没有未用热点可挑」时放行(stage2/3) + topic_used = any(str(x.get("topic", "")).strip().lower() + == str(b.get("topic", "")).strip().lower() for x in out) + if topic_used and si == 0: + continue + out_fp.add(fp) + out.append(b) + if len(out) >= count: + break + return out[:count] + + +def _rebuild_briefs_from_cache(country: str, config: Dict[str, Any], project_root: Path, + cache_dir: Path, need: int) -> List[Dict[str, Any]]: + """简报不足时:直接用采集缓存热点(collected_keywords)生成简报。 + 跳过 seed/fetch/score(无需重新采集、无需种子词),screen(排除已用)+ prompt 组装即可。""" + import json as _json + import time as _tm + cp = cache_dir / "collected_keywords.json" + if not cp.exists(): + return [] + ck = _json.loads(cp.read_text(encoding="utf-8")).get("keywords") or [] + if not ck: + return [] + from graph.loader import build_country_config + from graph.nodes.screen_node import screen_node + from graph.nodes.prompt_node import prompt_node + cc = build_country_config(config, country, project_root) + config.setdefault("product", {})["spu_count"] = need + state: Dict[str, Any] = { + "country": country, "config": config, "country_config": cc, + "prompts_dir": str(project_root / "prompts" / country), + "cache_dir": str(cache_dir), "output_dir": str(cache_dir), + "scored_rows": [dict(r) for r in ck], + "screened": [], "briefs": [], "errors": [], "stats": {}, + } + try: + r1 = screen_node(state) + r2 = prompt_node({**state, "screened": r1.get("screened", [])}) + briefs = r2.get("briefs", []) or [] + if briefs: + (cache_dir / "design_briefs.json").write_text( + _json.dumps({"generated_at": _tm.strftime("%Y-%m-%dT%H:%M:%S"), + "total": len(briefs), "design_briefs": briefs}, + ensure_ascii=False, indent=2), encoding="utf-8") + return briefs + except Exception as e: # noqa: BLE001 + print(f"[batch] 采集缓存生成简报失败: {e}") + return [] + + +def run_product_batch(country: str, config: Dict[str, Any], project_root: Path, + output_root: Optional[Path], tasks: List[Dict[str, Any]], + count: int, log_q=None, task_timestamp: Optional[str] = None) -> Dict[str, Any]: + """缓存模式入口:加载缓存热点 → 按量分配 → product_node 批量处理。 + + task_timestamp: 任务开始时间戳(YYYYMMDDHHMMSS),作为 OSS 路径段;缺失取当前时间。 + """ + if log_q: + log_q.put(("log", f"\n===== 缓存热点产品流程 {country}(SPU 数量 {count})=====\n")) + import time as _tm + cache_dir = (output_root or project_root) / "output" / country # 缓存/去重(根目录) + cache_dir.mkdir(parents=True, exist_ok=True) + ts = task_timestamp or _tm.strftime("%Y%m%d_%H%M%S") + _base = ts + _i = 1 + while (cache_dir / ts).exists(): # 时间戳文件夹唯一(同秒多任务防冲突/覆盖) + ts = f"{_base}_{_i}" + _i += 1 + output_dir = cache_dir / ts # 本次任务产物(时间戳文件夹) + output_dir.mkdir(parents=True, exist_ok=True) + + briefs = load_cached_briefs(cache_dir) + # 任务扩展:每个「集合」(款-颜色集 + 独立数量 count)按自己数量复制 count 份; + # 每份 skus 保留该款全部颜色集合(多色)→ 每个设计配全部颜色各出一张主图 + picked_tasks: List[Dict[str, Any]] = [] + if tasks: + for t in tasks: + n = int(t.get("count") or 0) or count or 1 + for _ in range(n): + tt = dict(t) + tt.pop("count", None) + picked_tasks.append(tt) + else: + picked_tasks = [dict(t) for t in (tasks or [])] + if not briefs or len(briefs) < len(picked_tasks): + # 简报不足:直接用采集缓存热点生成简报(无需重新采集/种子词) + print(f"[batch] 简报 {len(briefs)} 条 < 需要 {len(picked_tasks)} 条 → 直接用采集缓存热点生成简报(无需重新采集)…") + briefs = _rebuild_briefs_from_cache(country, config, project_root, cache_dir, len(picked_tasks)) + if not briefs: + print("[batch] 采集缓存无有效热点,无法生成简报") + return {"product": [], "briefs": [], "errors": [{"node": "batch", "message": "无缓存热点"}]} + + used = load_used(cache_dir) + used_topics = {str(u.get("topic", "")).strip().lower() for u in used} + # 简报池未用数(design_briefs.json 旧简报里还没用过的) + fresh_count = sum(1 for b in briefs + if str(b.get("topic", "")).strip().lower() not in used_topics) + total_needed = len(picked_tasks) or count + # 采集池未用数(collected_keywords 全量里的未用热点——佐证热点池是否真的充足) + pool_fresh = fresh_count + try: + import json as _json + cp = cache_dir / "collected_keywords.json" + if cp.exists(): + ck = _json.loads(cp.read_text(encoding="utf-8")).get("keywords") or [] + pool_fresh = sum(1 for k in ck + if str(k.get("topic", "")).strip().lower() not in used_topics) + except Exception: # noqa: BLE001 + pass + # 旧简报用完了(简报池未用 < 需要)→ 直接用采集缓存热点生成新简报(无需重新采集) + if fresh_count < total_needed: + print(f"[batch] 简报池未用 {fresh_count}/{len(briefs)} 条 < 需要 {total_needed} 条 → " + f"直接用采集缓存热点生成新简报(采集池未用 {pool_fresh} 条充足,无需重新采集)…") + briefs = _rebuild_briefs_from_cache(country, config, project_root, cache_dir, total_needed) + + # openai 模式:LLM 已安全改写,review(带 concept)也可用;mock 模式:review 留人工复核 + allow_review = str((config.get("llm_screen") or {}).get("provider", "")).strip() != "mock" + from graph.loader import build_country_config + _cc = build_country_config(config, country, project_root) + exclude_topics = list((_cc.get("exclude_topics") or []) or []) # 国家配置的黑名单热点 + assigned = assign_hotspots(briefs, used, total_needed, allow_review=allow_review, + exclude_topics=exclude_topics) + if not assigned: + print("[batch] 无可用热点分配") + return {"product": [], "briefs": briefs, "errors": [{"node": "batch", "message": "无可用热点"}]} + if len(assigned) < total_needed: + print(f"[batch] ⚠ 热点不足:简报 {len(briefs)} 条,仅分配到 {len(assigned)}/{total_needed} 个热点(已用去重后剩余热点少,第 {len(assigned)+1} 个起无热点)") + else: + print(f"[batch] 缓存热点 {len(briefs)} 条 → 分配 {len(assigned)} 个热点") + + # 款号与热点一一绑定:第 i 个款号用第 i 个热点(缓存模式;完整流水线由 product 自行绑定) + for i, t in enumerate(picked_tasks): + if i < len(assigned): + t["topic"] = assigned[i].get("topic", "") + if log_q: + for i, t in enumerate(picked_tasks): + tp = t.get("topic", "") + log_q.put(("log", f"[batch] 款号 {t.get('spu')} ← 热点「{tp}」\n")) + + # 直接构造 state 调 product_node(跳过 seed/fetch 等重跑) + from graph.nodes.product_node import product_node + from graph.loader import build_country_config + import time as _time + cc = build_country_config(config, country, project_root) + config.setdefault("product", {})["spu_tasks"] = picked_tasks + if count > 0: + config.setdefault("product", {})["spu_count"] = count + state: Dict[str, Any] = { + "country": country, + "config": config, + "country_config": cc, + "prompts_dir": str(project_root / "prompts" / country), + "output_dir": str(output_dir), + "briefs": briefs, + "designs": [], + "composite": [], + "errors": [], + "stats": {}, + "task_timestamp": task_timestamp or _time.strftime("%Y%m%d%H%M%S"), + "oss_seq": 0, + } + out = product_node(state) + # 缓存模式也走压缩+上传+种草图节点(与 graph 全流程一致) + if out.get("product"): + from graph.nodes.oss_upload_node import oss_upload_node + out2 = oss_upload_node({**state, "product": out.get("product"), + "stats": out.get("stats") or {}, + "errors": out.get("errors") or []}) + out["oss"] = out2.get("oss") or [] + out["product"] = out2.get("product") or out.get("product") + out["stats"] = out2.get("stats") or out.get("stats") or {} + if out2.get("oss_seq") is not None: + state["oss_seq"] = out2["oss_seq"] + + from graph.nodes.seed_shot_node import seed_shot_node + out3 = seed_shot_node({**state, "product": out.get("product"), + "stats": out.get("stats") or {}, + "errors": out.get("errors") or []}) + out["seed_shots"] = out3.get("seed_shots") or [] + out["product"] = out3.get("product") or out.get("product") + out["stats"] = out3.get("stats") or out.get("stats") or {} + if out3.get("oss_seq") is not None: + state["oss_seq"] = out3["oss_seq"] + + from graph.nodes.template_export_node import template_export_node + out4 = template_export_node({**state, "product": out.get("product"), + "stats": out.get("stats") or {}, + "errors": out.get("errors") or []}) + out["product"] = out4.get("product") or out.get("product") + out["stats"] = out4.get("stats") or out.get("stats") or {} + return out diff --git a/graph/scoring.py b/graph/scoring.py new file mode 100644 index 0000000..51a96b0 --- /dev/null +++ b/graph/scoring.py @@ -0,0 +1,253 @@ +"""归一化、跨源融合、合规黑名单过滤、人名过滤(从原 src/scoring.py 迁移到 graph 包)。 + +所有函数纯逻辑、无 IO,便于节点内调用与单元测试。 +""" +import re +from collections import defaultdict +from typing import Dict, List, Optional, Tuple + + +DEFAULT_NAME_PATTERNS = [r"^[A-Z][a-z]+(?: [A-Z][a-z]+){1,2}$"] +DEFAULT_EXTRA_NAMES = [ + "taylor swift", "trump", "biden", "kardashian", "lebron", "charlie sheen", + "bernie sanders", "elon musk", "beyonce", "drake", "rihanna", "justin bieber", + "ariana grande", "selena gomez", "eminem", "kanye", "travis scott", "messi", + "ronaldo", "harry styles", "bts", "blackpink", "pewdiepie", "mrbeast", + "kamala harris", "joe biden", "donald trump", "kim kardashian", "pearl jam", + "nirvana", "michael jackson", "madonna", "britney spears", "lady gaga", + "justin timberlake", "tom cruise", "brad pitt", "keanu reeves", "robert downey", + "cristiano ronaldo", "lionel messi", "billie eilish", "the weeknd", "post malone", + "kendrick lamar", "joe rogan", "andrew tate", "elon", "musk", "obama", "clinton", + "springsteen", "reiner", "eliza lopes", "camilla", "noah kahan", "gina carano", + # —— 常见人名扩充(歌手/演员/运动员/政客/企业家/网红/王室,子串匹配,避免真人印花)—— + "ed sheeran", "dua lipa", "adele", "bruno mars", "shakira", "elton john", + "david bowie", "freddie mercury", "whitney houston", "celine dion", "olivia rodrigo", + "sabrina carpenter", "chappell roan", "ice spice", "nicki minaj", "cardi b", + "doja cat", "sza", "lil nas x", "bad bunny", "shawn mendes", "zayn malik", + "dwayne johnson", "johnny depp", "leonardo dicaprio", "chris hemsworth", "chris evans", + "tom holland", "zendaya", "jennifer lawrence", "emma watson", "scarlett johansson", + "miley cyrus", "hugh jackman", "nicole kidman", "cate blanchett", "steve irwin", + "kylie minogue", "morgan freeman", "will smith", "denzel washington", "angelina jolie", + "jennifer aniston", "george clooney", "robert pattinson", "daniel radcliffe", "emma stone", + "ryan reynolds", "ryan gosling", "kobe bryant", "michael jordan", "serena williams", + "venus williams", "tiger woods", "usain bolt", "tom brady", "patrick mahomes", + "stephen curry", "kevin durant", "lewis hamilton", "max verstappen", "novak djokovic", + "rafael nadal", "roger federer", "conor mcgregor", "putin", "zelensky", + "boris johnson", "rishi sunak", "narendra modi", "justin trudeau", "emmanuel macron", + "olaf scholz", "bill gates", "jeff bezos", "mark zuckerberg", "logan paul", "jake paul", + "ksi", "charli damelio", "addison rae", "kylie jenner", "kendall jenner", + "queen elizabeth", "king charles", "prince william", "prince harry", "meghan markle", + "princess diana", +] +DEFAULT_EXEMPTIONS = [ + "new album", "best seller", "top gear", "red cross", "black cat", "blue moon", + "green day", "red hot chili peppers", "cold play", "one direction", "little mix", + "west life", "back street", "new york", "los angeles", "san diego", "new orleans", + "san francisco", "las vegas", "white house", "high school", "middle earth", +] + + +def filter_person_names( + rows: List[Dict], + extra_names: Optional[List[str]] = None, + patterns: Optional[List[str]] = None, + exemptions: Optional[List[str]] = None, + pattern_sources: Optional[set] = None, +) -> Tuple[List[Dict], List[Dict]]: + """剔除真实人物(明星/政客/名人),避免肖像权风险。返回 (kept, dropped)。""" + extra = [e.lower() for e in (extra_names if extra_names is not None else DEFAULT_EXTRA_NAMES)] + pats = patterns if patterns is not None else DEFAULT_NAME_PATTERNS + exempt = [e.lower() for e in (exemptions if exemptions is not None else DEFAULT_EXEMPTIONS)] + compiled = [re.compile(p) for p in pats] + pattern_sources = set(pattern_sources) if pattern_sources is not None else {"gt_trending"} + + kept, dropped = [], [] + for r in rows: + topic = str(r.get("topic", "")).strip() + tl = topic.lower() + if any(e and e in tl for e in exempt): + kept.append(r) + continue + reason = None + hit_name = [n for n in extra if n and n in tl] + if hit_name: + reason = f"命中人名名单: {hit_name}" + elif r.get("source") in pattern_sources and any(p.search(topic) for p in compiled): + reason = "匹配人名模式(疑似真实人物)" + if reason: + r2 = dict(r) + r2["drop_reason"] = reason + dropped.append(r2) + else: + kept.append(r) + return kept, dropped + + +def filter_design_relevance( + rows: List[Dict], + drop_patterns: Optional[List[str]] = None, + keep_patterns: Optional[List[str]] = None, +) -> Tuple[List[Dict], List[Dict]]: + """丢弃不可作印花主体的泛新闻/科技/赛事词。返回 (kept, dropped)。""" + drop = [re.compile(p, re.I) for p in (drop_patterns or [])] + keep = [re.compile(p, re.I) for p in (keep_patterns or [])] + kept, dropped = [], [] + for r in rows: + topic = str(r.get("topic", "")).strip() + tl = topic.lower() + if keep and not any(p.search(tl) for p in keep): + r2 = dict(r) + r2["drop_reason"] = "未命中设计相关性白名单" + dropped.append(r2) + continue + if drop and any(p.search(tl) for p in drop): + r2 = dict(r) + r2["drop_reason"] = "非印花设计主体(泛新闻/科技/赛事)" + dropped.append(r2) + continue + kept.append(r) + return kept, dropped + + +# 查询噪声:非“可印花设计概念”的检索问句 / 命名清单 / 损坏碎片,应直接丢弃而非标 safe。 +_QUERY_NOISE_LEAD = re.compile( + r"^(what|who|how|why|when|where|which|is|are|was|were|do|does|did|can|will|" + r"should|would|may|might|has|have|whose|whom)\b", re.I) +# 任意位置的疑问词:覆盖 "punk sprite what does it do" 这类词序在中的问句 +_QUERY_NOISE_WH_ANY = re.compile(r"\b(what|who|how|why|when|where|which)\b", re.I) +# names/surnames:覆盖 "cottagecore surnames" 这类变体 +_QUERY_NOISE_NAMES = re.compile( + r"\b((?:boy|girl|baby|pet|dog|cat|last|first|middle)?\s*names?|surnames)" + r"(?:\s+(?:ideas|list))?\b$", re.I) +# 损坏/拼接碎片:数字前缀可选,覆盖 "gothic remake review"(无数字)与 "gothic remake metacritic" +_QUERY_NOISE_CORRUPT = re.compile( + r"\b(?:\d{1,2}\s+)?(remake|review|version|copy|edit|replica|metacritic)\b", re.I) +_QUERY_NOISE_WORDS = [re.compile(p, re.I) for p in + [r"\bstory\b", r"\bmeaning\b", r"\bdefinition\b", + r"\btutorial\b", r"\bguide\b", r"\bquests?\b"]] + + +# —— 新闻类热点过滤(突发新闻不适合做印花主题,各国语言词表)—— +NEWS_WORDS_GLOBAL = [ + "weather", "forecast", "typhoon", "earthquake", "tsunami", "hurricane", + "missile", "election", "vote", "prime minister", "president", "minister", + "cabinet", "senate", "congress", "parliament", "shooting", "ceasefire", + "nuclear", "summit", "hostage", "emergency", "warning", "breaking news", + "stock market", "oil price", "inflation", "deadline", "live update", +] +NEWS_WORDS_JP = [ + "天気", "台風", "気象", "地震", "津波", "ミサイル", "首相", "大臣", + "会見", "速報", "選挙", "防衛", "自衛隊", "警報", "注意報", "ニュース", + "報道", "豪雨", "猛暑", "熱中症", "株価", "円相場", "物価", "国会", + "衆院", "参院", "裁判", "逮捕", "捜査", "事故", "死亡", "追悼", "慰霊", +] + + +def filter_news(rows: List[Dict], country: str = "") -> Tuple[List[Dict], List[Dict]]: + """丢弃新闻类热点(天气/灾害/政治/事故等突发新闻,非印花主题)。按国家语言补充词表。""" + words = list(NEWS_WORDS_GLOBAL) + if str(country).upper() == "JP": + words += NEWS_WORDS_JP + elif str(country).upper() == "US": + words += ["weather alert", "live coverage", "breaking"] + kept, dropped = [], [] + for r in rows: + tl = str(r.get("topic", "")).lower() + hit = next((w for w in words if w in tl), None) + if hit: + r2 = dict(r) + r2["drop_reason"] = f"新闻类热点(非印花主题): {hit}" + dropped.append(r2) + else: + kept.append(r) + return kept, dropped + + +def filter_query_noise( + rows: List[Dict], + enabled: bool = True, +) -> Tuple[List[Dict], List[Dict]]: + """丢弃“查询噪声/非设计概念”词(问句、命名清单、损坏碎片、模糊名词)。 + + 返回 (kept, dropped)。这些词不是可印花主体,进入 screen 会被 Mock 误标 safe, + 故在过滤阶段就剔除,避免污染生图环节。 + """ + if not enabled: + return rows, [] + kept, dropped = [], [] + for r in rows: + topic = str(r.get("topic", "")).strip() + tl = topic.lower() + reason = None + if _QUERY_NOISE_WH_ANY.search(tl) or _QUERY_NOISE_LEAD.search(tl): + reason = "查询问句(非设计概念)" + elif _QUERY_NOISE_NAMES.search(tl): + reason = "命名清单类查询(非设计概念)" + elif _QUERY_NOISE_CORRUPT.search(tl): + reason = "损坏/拼接的查询碎片" + elif any(p.search(tl) for p in _QUERY_NOISE_WORDS): + reason = "模糊名词(非设计概念)" + if reason: + r2 = dict(r) + r2["drop_reason"] = reason + dropped.append(r2) + else: + kept.append(r) + return kept, dropped + + +def normalize(rows: List[Dict], key: str = "raw_score") -> List[Dict]: + """min-max 归一化到 0-1,按 (source, kind) 分组分别归一化。""" + if not rows: + return rows + groups = defaultdict(list) + for r in rows: + groups[(r.get("source", "_"), r.get("kind", "_"))].append(r) + for grp in groups.values(): + vals = [r[key] for r in grp if r.get(key) is not None] + if not vals: + for r in grp: + r["norm"] = 0.0 + continue + lo, hi = min(vals), max(vals) + span = (hi - lo) or 1.0 + for r in grp: + v = r.get(key) + r["norm"] = (v - lo) / span if v is not None else 0.0 + return rows + + +def apply_blacklist(rows: List[Dict], blacklist: List[str]) -> Tuple[List[Dict], List[Dict]]: + """命中黑名单的词丢弃,返回 (保留, 丢弃)。""" + if not blacklist: + return rows, [] + bl = [b.lower() for b in blacklist] + kept, dropped = [], [] + for r in rows: + text = f"{r.get('topic', '')} {r.get('seed', '')}".lower() + if any(b in text for b in bl): + dropped.append(r) + else: + kept.append(r) + return kept, dropped + + +def combine(rows: List[Dict], weights: Dict[str, float]) -> List[Dict]: + """按 topic 跨源融合,权重来自 config。""" + agg = {} + for r in rows: + t = r["topic"].lower().strip() + if t not in agg: + agg[t] = {"topic": r["topic"], "countries": set(), "sources": set(), "score": 0.0} + w = weights.get(r["source"], 0.5) + agg[t]["score"] += r.get("norm", 0.0) * w + if r.get("country"): + agg[t]["countries"].add(r["country"]) + agg[t]["sources"].add(r["source"]) + out = [] + for o in agg.values(): + o["countries"] = ",".join(sorted(o["countries"])) or "GLOBAL" + o["sources"] = ",".join(sorted(o["sources"])) + out.append(o) + out.sort(key=lambda x: x["score"], reverse=True) + return out diff --git a/graph/seed_shot.py b/graph/seed_shot.py new file mode 100644 index 0000000..318dcdb --- /dev/null +++ b/graph/seed_shot.py @@ -0,0 +1,103 @@ +"""种草图(Seed Shot)生成。 + +- 模板:configs/seed_shot_templates.yaml(可自定义,占位符 [商品名称]/[材质]/[模特特征]) +- 模特特征:configs/model_features.yaml(可自定义,随机取一条) +- 生成:以 product 合成图(图1)为参考,img2img 生成 N 张种草图(保留衣服外观、换场景/模特) +- 占位替换:[商品名称]→cn_title(缺省回退 topic);[材质]→SPU.material;[模特特征]→随机 +""" +import random +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from graph.paths import project_root + + +def _load_yaml(rel: str) -> Dict[str, Any]: + for root in (project_root(),): + p = root / rel + if p.exists(): + try: + return yaml.safe_load(p.read_text(encoding="utf-8")) or {} + except Exception as e: # noqa: BLE001 + print(f"[seed_shot] 读取 {rel} 失败: {e}") + return {} + + +def load_templates() -> List[Dict[str, str]]: + """种草图提示词模板列表(无配置时给内置兜底)。""" + data = _load_yaml("configs/seed_shot_templates.yaml") + tpls = data.get("seed_shot_templates") or [] + if not tpls: + tpls = [{ + "name": "default", + "prompt": ( + "【最高优先级约束】请严格保留参考图1中模特所穿衣服的完整外观,包括其原有的颜色、" + "印花图案、版型款式、材质纹理与缝线细节,绝对禁止对衣服本身进行任何形式的修改、" + "重新设计、变色或改变图案。仅提取这件[商品名称],将其穿在一位[模特特征]的身上。" + "全身动态抓拍构图,行走在阳光斑驳的城市林荫道上,微微低头微笑,凸显[材质]的透气与百搭。" + "徕卡Q2摄影质感,高对比度色彩,35mm镜头,f/1.7大光圈,8k分辨率。" + ), + }] + return [{"name": str(t.get("name", "default")), "prompt": str(t.get("prompt", ""))} + for t in tpls if t.get("prompt")] + + +def load_model_features() -> List[str]: + """模特特征列表(无配置时给内置兜底)。""" + data = _load_yaml("configs/model_features.yaml") + feats = [str(f) for f in (data.get("model_features") or []) if str(f).strip()] + if not feats: + feats = ["20岁清新少女,素颜通透感", "25岁都市职场女性,干练气质"] + return feats + + +def load_style_features() -> List[str]: + """服装风格列表(style_features.yaml,随机取一条替换 [服装风格];无配置时内置兜底)。""" + data = _load_yaml("configs/style_features.yaml") + feats = [str(f) for f in (data.get("style_features") or []) if str(f).strip()] + if not feats: + feats = ["极简基础款风格,干净纯粹,无过多繁复装饰", + "日系City Boy/Girl风,微宽松版型,注重舒适度与层次感"] + return feats + + +def render_prompt(template_prompt: str, cn_title: str, material: str, model_feature: str, + style_feature: str = "") -> str: + """占位替换:[商品名称]/[材质]/[模特特征]/[服装风格]""" + out = template_prompt.replace("[商品名称]", (cn_title or "").strip() or "这件衣服") + out = out.replace("[材质]", (material or "").strip() or "面料") + out = out.replace("[模特特征]", (model_feature or "").strip() or "模特") + out = out.replace("[服装风格]", (style_feature or "").strip() or "日常休闲风") + return out + + +def generate_seed_shots(image_backend, base_image: str, cn_title: str, material: str, + count: int, out_dir: str, negative: str = "", + size: str = "1504x2000", prefix: str = "") -> List[str]: + """生成 count 张种草图(img2img,图1=合成图)。返回产物路径列表。 + size: 种草图统一 1504x2000。 + prefix: 货号前缀(对应产品货号,命名 {prefix}_seedshot_{n}.png,不覆盖旧文件)。 + 占位符 [商品名称]/[材质]/[模特特征]/[服装风格] 均随机组合(模板/模特/服装风格各随机取一条)。""" + templates = load_templates() + features = load_model_features() + style_features = load_style_features() + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + paths: List[str] = [] + for i in range(count): + tpl = random.choice(templates) + feat = random.choice(features) + style_feat = random.choice(style_features) + prompt = render_prompt(tpl["prompt"], cn_title, material, feat, style_feat) + out_path = str(out / f"{prefix}_seedshot_{i + 1:02d}.png" if prefix + else out / f"seed_shot_{i + 1:02d}.png") + try: + image_backend.print(prompt, base_image, out_path, negative, size=size) + paths.append(out_path) + print(f"[seed_shot] 已生成种草图 {i + 1}/{count}: {out_path}" + f"(模板={tpl['name']},模特={feat[:14]}…,风格={style_feat[:14]}…)") + except Exception as e: # noqa: BLE001 + print(f"[seed_shot] 种草图 {i + 1} 生成失败: {e}") + return paths diff --git a/graph/seeds/__init__.py b/graph/seeds/__init__.py new file mode 100644 index 0000000..7cf9c15 --- /dev/null +++ b/graph/seeds/__init__.py @@ -0,0 +1,27 @@ +"""graph/seeds 可插拔种子词策略注册表。 + +seed_provider 取值: + - static : 仅用 yaml 写死种子,零动态 + - mock : 规则生成(借用 trending/历史/月份节日),零 API 成本 + - openai_compat : 真 LLM 生成(OpenAI / DeepSeek / Qwen / Kimi 等兼容协议) + - openai / deepseek / qwen / moonshot : 同 openai_compat,仅别名 +""" +from typing import Dict + +from .base import SeedStrategy +from .static_strategy import StaticStrategy +from .dynamic_strategy import DynamicStrategy + +SEED_STRATEGIES: Dict[str, SeedStrategy] = { + "static": StaticStrategy(), + "mock": DynamicStrategy(), + "openai_compat": DynamicStrategy(), + "openai": DynamicStrategy(), + "deepseek": DynamicStrategy(), + "qwen": DynamicStrategy(), + "moonshot": DynamicStrategy(), +} + + +def get_seed_strategy(name: str) -> SeedStrategy: + return SEED_STRATEGIES.get((name or "static").strip().lower(), StaticStrategy()) diff --git a/graph/seeds/base.py b/graph/seeds/base.py new file mode 100644 index 0000000..c9847dc --- /dev/null +++ b/graph/seeds/base.py @@ -0,0 +1,31 @@ +"""种子词策略基类(可插拔核心)。 + +新增一个种子词策略只需:① 继承 SeedStrategy 实现 resolve();② 在 __init__.py +的 SEED_STRATEGIES 注册表里登记。config 的 ``seed_provider`` 选择用哪个。 +""" +from typing import Any, Dict, List, Optional + + +class SeedStrategy: + #: 注册名(与 config.seed_provider 对应) + name: str = "base" + + def resolve( + self, + country: str, + cc: Dict[str, Any], + context: Dict[str, Any], + llm_backend: Optional[Any] = None, + ) -> Dict[str, Any]: + """产出种子词。 + + 返回至少包含: + - "style_seeds": [str] 风格/美学向种子 + - "related_seeds": [str] 行业/主体向种子(行业交叉验证) + - "dynamic": bool 是否经过 LLM 动态生成 + 可选附带 "llm_style_seeds" / "llm_related_seeds" 便于观测。 + + cc 为合并后的国家配置(含 yaml 静态种子);context 为 seed_node 收集的 + trending/历史/月份节日上下文;llm_backend 为 LLM 后端实例(可能 None)。 + """ + raise NotImplementedError diff --git a/graph/seeds/dynamic_strategy.py b/graph/seeds/dynamic_strategy.py new file mode 100644 index 0000000..c64bcc6 --- /dev/null +++ b/graph/seeds/dynamic_strategy.py @@ -0,0 +1,111 @@ +"""动态策略:以 yaml 静态种子为基础,叠加动态种子(统一池 + 加权随机 + 用完全用)。 + +种子词机制(v49 起): +1. 全部类型放一起(统一池):静态 style + 静态 related + 月份主题 + 节日 + LLM 动态 + ——合并去重(跨类型同词只保留一个,权重累加 = 多来源更受重视); +2. 节日种子词提供权重:节日权重 3.0 > 月份主题 2.0 > 静态/动态 1.0,随机抽取时加权; +3. 每次随机取:从池中按权重随机抽取(不重复),limit 内数量; +4. 用完全用:池中种子数 ≤ 需要数时全部使用(不再随机限量/截断); +5. 每个国家独立配置:configs/countries/.yaml 的 style.seeds / related.seed_keywords。 + +limit 由 seed_node 从 context 注入(max_style_seeds / max_related_seeds,0 或缺失=不限)。 +LLM 后端生成失败时自动回退到静态+节日主题,保证不中断。 +""" +import random +from typing import Any, Dict, List + +from .base import SeedStrategy + + +def _weighted_sample(pool: List[Dict[str, Any]], k: int) -> List[Dict[str, Any]]: + """按权重随机不重复取 k 个;池数量 ≤ k(或用完)时全部返回(不随机限量)。""" + if k <= 0 or len(pool) <= k: + return list(pool) + out: List[Dict[str, Any]] = [] + rest = list(pool) + for _ in range(k): + weights = [max(float(it["weight"]), 0.0) for it in rest] + if sum(weights) <= 0: + out.extend(rest) + break + idx = random.choices(range(len(rest)), weights=weights)[0] + out.append(rest.pop(idx)) + return out + + +class DynamicStrategy(SeedStrategy): + name = "dynamic" + + def resolve( + self, + country: str, + cc: Dict[str, Any], + context: Dict[str, Any], + llm_backend: Any = None, + ) -> Dict[str, Any]: + base_style = list((cc.get("style", {}) or {}).get("seeds", []) or []) + base_related = list((cc.get("related", {}) or {}).get("seed_keywords", []) or []) + month_style = list(context.get("month_themes", []) or []) + holidays = list(context.get("upcoming_holidays", []) or []) + holiday_style = [f"{h.lower()} aesthetic" for h in holidays] + # 节日主题同时扩充 related 源(提高节日权重 + 增加 related 扩展) + holiday_related = [f"{h.lower()} tee" if not h.lower().endswith("day") else f"{h.lower()} gift" + for h in holidays] + + # LLM 动态种子(失败回退,不影响静态/节日) + dyn_style: List[str] = [] + dyn_related: List[str] = [] + if llm_backend is not None and hasattr(llm_backend, "generate_seeds"): + try: + res = llm_backend.generate_seeds(context) or {} + dyn_style = list(res.get("style_seeds", []) or []) + dyn_related = list(res.get("related_seeds", []) or []) + except Exception as e: # noqa: BLE001 + print(f"[seed] LLM 生成种子失败,仅用静态+节日主题: {e}") + + # 1) 统一池:全部类型合并,跨类型去重(同词权重累加 = 多来源更受重视) + pool: Dict[str, Dict[str, Any]] = {} + + def add(items: List[str], weight: float, src: str) -> None: + for it in items: + it = (it or "").strip() + if not it: + continue + key = it.lower() + if key in pool: + pool[key]["weight"] += weight + pool[key]["sources"].append(src) + else: + pool[key] = {"word": it, "weight": weight, "sources": [src]} + + add(base_style, 1.0, "static") + add(base_related, 1.0, "static") + add(month_style, 2.0, "month") + add(holiday_style, 3.0, "holiday") + add(holiday_related, 3.0, "holiday") + add(dyn_style, 1.0, "dynamic") + add(dyn_related, 1.0, "dynamic") + + items = list(pool.values()) + limit_style = int(context.get("max_style_seeds") or 0) + limit_related = int(context.get("max_related_seeds") or 0) + + # 2) 每次随机取(加权,不重复);池不足 → 全部用 + style_pick = _weighted_sample(items, limit_style) + style_keys = {id(it) for it in style_pick} + remaining = [it for it in items if id(it) not in style_keys] + related_pick = _weighted_sample(remaining, limit_related) + + return { + "style_seeds": [it["word"] for it in style_pick], + "related_seeds": [it["word"] for it in related_pick], + "dynamic": True, + "pool_size": len(items), + "pool": [it["word"] for it in items], + "llm_style_seeds": dyn_style, + "llm_related_seeds": dyn_related, + "holiday_style_seeds": holiday_style, + "holiday_related_seeds": holiday_related, + "static_style_seeds": base_style, + "static_related_seeds": base_related, + } diff --git a/graph/seeds/holidays.py b/graph/seeds/holidays.py new file mode 100644 index 0000000..756909e --- /dev/null +++ b/graph/seeds/holidays.py @@ -0,0 +1,186 @@ +"""月份 / 季节 / 临近节日上下文(按国家),供动态种子词生成的 LLM 上下文使用。 + +提供 build_holiday_context(country, now=None) -> dict: + { + "date": "2026-08-21", + "year": 2026, + "month": 8, + "season": "Summer", + "month_themes": ["back to school", "late summer", "outdoor adventure"], + "upcoming_holidays": ["Back to School", "Summer Solstice"] + } +- month_themes:固定月度灵感,作为种子词生成的稳定基础。 +- upcoming_holidays:按国家节日表,用窗口计算临近(含刚过的)固定/浮动节日, + 让 LLM 注入"当前该国的可能节日",生成对应节日主题种子。 +""" +import datetime +from typing import Dict, List, Optional + +# 北半球季节(南半球可反向扩展) +SEASON_BY_MONTH = { + 12: "Winter", 1: "Winter", 2: "Winter", + 3: "Spring", 4: "Spring", 5: "Spring", + 6: "Summer", 7: "Summer", 8: "Summer", + 9: "Autumn", 10: "Autumn", 11: "Autumn", +} + +# 月度主题词(通用印花设计灵感) +MONTH_THEMES: Dict[int, List[str]] = { + 1: ["new year", "winter cozy", "resolution"], + 2: ["valentine", "love", "heart"], + 3: ["spring bloom", "st patrick", "fresh start"], + 4: ["easter", "spring garden", "pastel"], + 5: ["mother day", "flower", "spring outdoor"], + 6: ["pride", "summer start", "beach"], + 7: ["summer vibe", "travel", "festival"], + 8: ["back to school", "late summer", "outdoor adventure"], + 9: ["autumn equinox", "harvest", "cozy"], + 10: ["halloween", "autumn goth", "spooky"], + 11: ["thanksgiving", "gratitude", "autumn warm"], + 12: ["christmas", "winter holiday", "cozy festive"], +} + +# (name, month, day, rule, window_days) +# rule: None=固定日; "mother"=第2周日; "father"=第3周日; "thanks"=第4周四; "easter"=Computus; "bf"=thanks+1 +_HOLIDAYS_BY_COUNTRY: Dict[str, List[tuple]] = { + "US": [ + ("New Year", 1, 1, None, 14), + ("Valentine's Day", 2, 14, None, 21), + ("St Patrick's Day", 3, 17, None, 14), + ("Easter", 0, 0, "easter", 21), + ("Mother's Day", 5, 0, "mother", 14), + ("Father's Day", 6, 0, "father", 14), + ("Pride Month", 6, 1, None, 7), + ("Independence Day", 7, 4, None, 21), + ("Summer Solstice", 6, 21, None, 14), + ("Back to School", 8, 15, None, 30), + ("Labor Day", 9, 0, "labor", 14), + ("Halloween", 10, 31, None, 30), + ("Thanksgiving (US)", 11, 0, "thanks", 21), + ("Black Friday", 11, 0, "bf", 14), + ("Christmas", 12, 25, None, 30), + ("Winter Solstice", 12, 21, None, 14), + ], + "GB": [ + ("New Year", 1, 1, None, 14), + ("Valentine's Day", 2, 14, None, 21), + ("St Patrick's Day", 3, 17, None, 14), + ("Easter", 0, 0, "easter", 21), + ("Mother's Day (UK)", 3, 0, "mother_uk", 14), + ("Father's Day", 6, 0, "father", 14), + ("Summer Bank Holiday", 8, 25, None, 14), + ("Halloween", 10, 31, None, 30), + ("Bonfire Night", 11, 5, None, 21), + ("Remembrance Day", 11, 11, None, 14), + ("Christmas", 12, 25, None, 30), + ("Boxing Day", 12, 26, None, 14), + ], + "JP": [ + ("New Year", 1, 1, None, 14), + ("Valentine's Day", 2, 14, None, 21), + ("Hinamatsuri", 3, 3, None, 14), # 雏祭 + ("Hanami", 4, 1, None, 21), # 花见(樱花季) + ("Golden Week", 4, 29, None, 21), + ("Children's Day", 5, 5, None, 14), # 子供の日 + ("Tanabata", 7, 7, None, 14), # 七夕 + ("Fireworks Season", 8, 1, None, 30), # 花火大会 + ("Obon", 8, 13, None, 21), # お盆 + ("Halloween", 10, 31, None, 30), + ("Christmas", 12, 25, None, 30), + ("New Year Eve", 12, 31, None, 14), # 大晦日 + ], + "AU": [ + ("New Year", 1, 1, None, 14), + ("Australia Day", 1, 26, None, 21), + ("Valentine's Day", 2, 14, None, 21), + ("Easter", 0, 0, "easter", 21), + ("Anzac Day", 4, 25, None, 21), + ("Mother's Day (AU)", 5, 0, "mother", 14), + ("Father's Day (AU)", 9, 0, "father", 14), + ("Summer Christmas", 12, 25, None, 30), + ("Boxing Day", 12, 26, None, 21), + ("Halloween", 10, 31, None, 21), + ], +} + + +def _easter(year: int) -> datetime.date: + a = year % 19 + b = year // 100 + c = year % 100 + d = b // 4 + e = b % 4 + f = (b + 8) // 25 + g = (b - f + 1) // 3 + h = (19 * a + b - d - g + 15) % 30 + i = c // 4 + k = c % 4 + l = (32 + 2 * e + 2 * i - h - k) % 7 + m = (a + 11 * h + 22 * l) // 451 + month = (h + l - 7 * m + 114) // 31 + day = ((h + l - 7 * m + 114) % 31) + 1 + return datetime.date(year, month, day) + + +def _resolve(name: str, month: int, day: int, rule, year: int) -> Optional[datetime.date]: + if rule is None: + return datetime.date(year, month, day) + if rule == "easter": + return _easter(year) + if rule in ("mother", "mother_uk"): + # 第2个周日(UK 用"母亲节"但实际 3 月第4周日前的第4大斋期周日——简化为 3 月第2周日) + if rule == "mother_uk": + month, week = 3, 2 + else: + month, week = month, 2 + first = datetime.date(year, month, 1) + return first + datetime.timedelta(days=(6 - first.weekday()) % 7 + (week - 1) * 7) + if rule == "father": + first = datetime.date(year, month, 1) + return first + datetime.timedelta(days=(6 - first.weekday()) % 7 + 2 * 7) + if rule == "thanks": + first = datetime.date(year, month, 1) + return first + datetime.timedelta(days=(3 - first.weekday()) % 7 + 3 * 7) + if rule == "bf": + t = _resolve("", 11, 0, "thanks", year) + return t + datetime.timedelta(days=1) if t else None + if rule == "labor": + first = datetime.date(year, month, 1) + return first + datetime.timedelta(days=(0 - first.weekday()) % 7) + return None + + +def holidays_for(country: str = "US") -> List[tuple]: + key = (country or "US").upper() + return _HOLIDAYS_BY_COUNTRY.get(key, _HOLIDAYS_BY_COUNTRY["US"]) + + +def upcoming_holidays(now: Optional[datetime.date] = None, country: str = "US", + lower: int = -10) -> List[str]: + """返回该国临近(未来 window 内,或刚过 lower 天内)的节日名。""" + now = now or datetime.date.today() + out: List[str] = [] + for name, month, day, rule, window in holidays_for(country): + try: + d = _resolve(name, month, day, rule, now.year) + except Exception: + continue + if d is None: + continue + delta = (d - now).days + if lower <= delta <= window: + out.append(name) + return out + + +def build_holiday_context(country: str = "US", + now: Optional[datetime.date] = None) -> Dict[str, object]: + now = now or datetime.date.today() + return { + "date": now.isoformat(), + "year": now.year, + "month": now.month, + "season": SEASON_BY_MONTH.get(now.month, ""), + "month_themes": MONTH_THEMES.get(now.month, []), + "upcoming_holidays": upcoming_holidays(now, country), + } diff --git a/graph/seeds/static_strategy.py b/graph/seeds/static_strategy.py new file mode 100644 index 0000000..8ac6aa4 --- /dev/null +++ b/graph/seeds/static_strategy.py @@ -0,0 +1,23 @@ +"""静态策略:直接使用 configs/countries/.yaml 里写死的种子词,不做任何动态生成。""" +from typing import Any, Dict, List + +from .base import SeedStrategy + + +class StaticStrategy(SeedStrategy): + name = "static" + + def resolve( + self, + country: str, + cc: Dict[str, Any], + context: Dict[str, Any], + llm_backend: Any = None, + ) -> Dict[str, Any]: + style = list((cc.get("style", {}) or {}).get("seeds", []) or []) + related = list((cc.get("related", {}) or {}).get("seed_keywords", []) or []) + return { + "style_seeds": style, + "related_seeds": related, + "dynamic": False, + } diff --git a/graph/sources/__init__.py b/graph/sources/__init__.py new file mode 100644 index 0000000..ee34e5a --- /dev/null +++ b/graph/sources/__init__.py @@ -0,0 +1,22 @@ +"""数据源注册表(可插拔入口)。 + +config.yaml 的 ``sources: [google_trends, pinterest]`` 决定启用哪些。 +新增数据源:实现 graph/sources/base.DataSource,在此登记即可。 +""" +from typing import Dict + +from .base import DataSource +from .google_trends_source import GoogleTrendsSource +from .pinterest_source import PinterestSource + +SOURCES: Dict[str, type] = { + "google_trends": GoogleTrendsSource, + "pinterest": PinterestSource, +} + + +def get_source(name: str) -> DataSource: + cls = SOURCES.get(name) + if cls is None: + raise ValueError(f"未知数据源: {name},可用: {list(SOURCES)}") + return cls() diff --git a/graph/sources/base.py b/graph/sources/base.py new file mode 100644 index 0000000..9cacb08 --- /dev/null +++ b/graph/sources/base.py @@ -0,0 +1,30 @@ +"""数据源抽象接口(可插拔核心)。 + +新增一个数据源只需:① 继承 DataSource 实现 fetch();② 在 graph/sources/__init__.py +的 SOURCES 注册表里登记。config.yaml 通过 ``sources: [google_trends, pinterest]`` 决定启用哪些。 +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, List + + +class DataSource(ABC): + #: 注册名(与 config.sources 中的字符串对应) + name: str = "base" + + @abstractmethod + def fetch( + self, + country: str, + country_config: Dict[str, Any], + global_config: Dict[str, Any], + ) -> List[Dict[str, Any]]: + """抓取该国热点,返回统一格式行。 + + 每行字段:``country, topic, seed, source, kind, raw_score`` + - source 标签用于后续权重与归一化(如 gt_trending / gt_style / gt_related / pinterest) + - kind:trending / rising / top(用于按组归一化) + - raw_score:Google Trends 相对指数 0-100,或 RSS 排名分 + + 实现内部必须自行处理限流/重试/异常,返回空列表也不应抛异常到上层。 + """ + raise NotImplementedError diff --git a/graph/sources/google_trends_source.py b/graph/sources/google_trends_source.py new file mode 100644 index 0000000..bb84460 --- /dev/null +++ b/graph/sources/google_trends_source.py @@ -0,0 +1,263 @@ +"""Google Trends 数据源(可插拔实现)。 + +封装 pytrends + 官方 RSS,带本地缓存、指数退避重试、urllib3 兼容补丁。 +- gt_trending:国家实时趋势榜(RSS,稳定) +- gt_style:按国家风格种子词抓 related_queries(设计灵感) +- gt_related:按 POD 行业种子词抓 related_queries(行业交叉验证) + +注意:related_queries 是「单关键词」接口,一次传多个词会触发 Google /sorry(429), +因此逐词串行 + 节流 + 快速失败。缓存按 (key, 日期) 分文件:不删除历史文件, +24h 内读最新;超过 24h 重新抓取写当日新文件;抓取失败回退最新历史缓存兜底。 +""" +import datetime +import hashlib +import json +import re +import time +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Dict, List + +import requests +import urllib3 +from urllib3.util.retry import Retry as _Retry + +# pytrends 4.x 仍用 method_whitelist;urllib3>=2 已改名 allowed_methods。做兼容补丁。 +if "method_whitelist" not in _Retry.__init__.__code__.co_varnames: + _orig_retry_init = _Retry.__init__ + + def _patched_retry_init(self, *args, **kwargs): + if "method_whitelist" in kwargs: + kwargs["allowed_methods"] = kwargs.pop("method_whitelist") + _orig_retry_init(self, *args, **kwargs) + + _Retry.__init__ = _patched_retry_init + +from pytrends.request import TrendReq + +from graph.paths import runtime_root +from .base import DataSource + +CACHE_DIR = runtime_root() / ".cache" / "google_trends" +CACHE_TTL = 24 * 3600 +CACHE_VERSION = "v3" + + +def _cache_fname(key: str, date_suffix: str = "") -> str: + digest = hashlib.md5((CACHE_VERSION + "|" + key).encode("utf-8")).hexdigest() + return f"{digest}.{date_suffix}.json" if date_suffix else f"{digest}.json" + + +def _cache_date(p: Path) -> datetime.date: + """解析文件名里的 YYYYMMDD;无日期后缀则用 mtime。""" + for token in p.name.split("."): + if len(token) == 8 and token.isdigit(): + try: + return datetime.datetime.strptime(token, "%Y%m%d").date() + except ValueError: + pass + try: + return datetime.date.fromtimestamp(p.stat().st_mtime) + except Exception: + return datetime.date.min + + +def _cache_paths(key: str) -> List[Path]: + """该 key 的所有缓存文件(含旧版无日期后缀),按日期新旧降序。""" + digest = hashlib.md5((CACHE_VERSION + "|" + key).encode("utf-8")).hexdigest() + files = list(CACHE_DIR.glob(f"{digest}.*.json")) + legacy = CACHE_DIR / f"{digest}.json" + if legacy.exists(): + files.append(legacy) + files.sort(key=_cache_date, reverse=True) + return files + + +def _cache_get(key: str): + """返回 24h 内有效的最新缓存;无则 None。""" + for p in _cache_paths(key): + try: + fresh = (time.time() - p.stat().st_mtime) < CACHE_TTL + except Exception: + fresh = False + if fresh: + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + continue + return None + + +def _cache_latest(key: str): + """取最新缓存文件内容(不限时效),用于抓取失败时的兜底(不删除缓存,取最新)。""" + for p in _cache_paths(key): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + continue + return None + + +def _cache_set(key: str, data) -> None: + """写当日新文件(保留历史,不覆盖)。""" + CACHE_DIR.mkdir(parents=True, exist_ok=True) + today = datetime.datetime.now().strftime("%Y%m%d") + path = CACHE_DIR / _cache_fname(key, today) + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + +def _retry(func, max_attempts=3, base_delay=3): + last = None + for attempt in range(max_attempts): + try: + return func() + except Exception as e: # noqa: BLE001 + last = e + if attempt == max_attempts - 1: + break + time.sleep(base_delay * (2 ** attempt)) + raise last if last else RuntimeError("retry failed") + + +def fetch_related(keywords, geo="US", timeframe="today 3-m"): + """逐关键词串行请求 related_queries(单关键词接口,避免 429)。""" + merged = {} + for kw in keywords: + time.sleep(3) # 节流 + + def _call(kw=kw): + # timeout=(connect, read):pytrends 默认 connect=2s 太短,网络波动即全挂,放宽到 10/30s + pytrends = TrendReq(hl="en-US", tz=360, retries=2, backoff_factor=0.5, timeout=(10, 30)) + pytrends.build_payload(kw_list=[kw], timeframe=timeframe, geo=geo) + return pytrends.related_queries() + + try: + data = _retry(_call, max_attempts=2, base_delay=1) + except Exception as e: # noqa: BLE001 + print(f"[GoogleTrends] {geo} 种子「{kw}」抓取失败(跳过): {e}") + continue + if isinstance(data, dict): + merged.update(data) + return merged + + +def parse_related(raw, geo, source="gt_related"): + rows = [] + for kw, payload in raw.items(): + if not isinstance(payload, dict): + continue + for kind in ("rising", "top"): + df = payload.get(kind) + if df is None or getattr(df, "empty", True): + continue + for _, r in df.iterrows(): + val = r["value"] + if isinstance(val, str) and val.strip().lower() == "breakout": + num = 100.0 + else: + try: + num = float(val) + except (TypeError, ValueError): + continue + rows.append({ + "country": geo, + "topic": str(r["query"]).strip(), + "seed": kw, + "source": source, + "kind": kind, + "raw_score": num, + }) + return rows + + +def _parse_traffic(desc): + m = re.search(r"([\d,]+)\+?\s*searches", desc or "", re.I) + if m: + try: + return float(m.group(1).replace(",", "")) + except ValueError: + return None + return None + + +def fetch_trending(geo="US", limit=40): + key = f"trending|{geo}|{limit}" + cached = _cache_get(key) + if cached is not None: + return cached + url = f"https://trends.google.com/trending/rss?geo={geo}" + try: + resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}) + resp.raise_for_status() + root = ET.fromstring(resp.content) + rows = [] + for idx, it in enumerate(root.findall(".//item")[:limit]): + title = (it.findtext("title") or "").strip() + if not title: + continue + score = _parse_traffic(it.findtext("description")) + if score is None: + score = float(limit - idx) + rows.append({ + "country": geo, "topic": title, "seed": "", + "source": "gt_trending", "kind": "trending", "raw_score": score, + }) + _cache_set(key, rows) + return rows + except Exception as e: # noqa: BLE001 + print(f"[GoogleTrends 趋势] {geo} 抓取失败: {e}") + latest = _cache_latest(key) + if latest is not None: + print(f"[GoogleTrends 趋势] {geo} 回退最新缓存({len(latest)}条)") + return latest + return [] + + +def get_rows(keywords, geo="US", timeframe="today 3-m", source="gt_related"): + key = f"{','.join(keywords)}|{geo}|{timeframe}|{source}|rows" + cached = _cache_get(key) + if cached is not None: + return cached + raw = fetch_related(keywords, geo=geo, timeframe=timeframe) + rows = parse_related(raw, geo, source=source) + if raw: # 有结果才写当日新缓存 + _cache_set(key, rows) + return rows + # 抓取无果(429/超时):回退最新历史缓存,保证流水线不中断 + latest = _cache_latest(key) + if latest is not None: + print(f"[GoogleTrends] {geo} 种子「{','.join(keywords)}」抓取无结果,回退最新缓存({len(latest)}行)") + return latest + return rows + + +class GoogleTrendsSource(DataSource): + name = "google_trends" + + def fetch(self, country, country_config, global_config): + cc = country_config or {} + trending_cfg = cc.get("trending", {}) + style_cfg = cc.get("style", {}) + related_cfg = cc.get("related", {}) + tf = cc.get("timeframe", "today 3-m") + + rows: List[Dict[str, Any]] = [] + + # 1) 国家实时趋势榜(主源) + if trending_cfg.get("enabled", True): + limit = int(trending_cfg.get("limit", 40)) + rows.extend(fetch_trending(geo=country, limit=limit)) + + # 2) 风格种子词 + if style_cfg.get("enabled", True): + seeds = style_cfg.get("seeds", []) or [] + if seeds: + rows.extend(get_rows(seeds, geo=country, timeframe=tf, source="gt_style")) + + # 3) 行业种子词 + if related_cfg.get("enabled", True): + seeds = related_cfg.get("seed_keywords", []) or [] + if seeds: + rows.extend(get_rows(seeds, geo=country, timeframe=tf, source="gt_related")) + + return rows diff --git a/graph/sources/pinterest_source.py b/graph/sources/pinterest_source.py new file mode 100644 index 0000000..4abeadd --- /dev/null +++ b/graph/sources/pinterest_source.py @@ -0,0 +1,31 @@ +"""Pinterest 数据源(可插拔实现,默认不启用)。 + +Pinterest 官方 API v5 需要商业账号 + access token(pins:read scope),且 App 需通过 API Review。 +未配置或 Review 未过时直接返回空列表(不抛异常),由 config 的 sources 列表控制是否启用。 + +要启用:在 config.yaml 的 sources 里加入 "pinterest",并填 pinterest.token / board / query。 +""" +from typing import Any, Dict, List + +from .base import DataSource + + +class PinterestSource(DataSource): + name = "pinterest" + + def fetch(self, country, country_config, global_config): + cfg = (global_config or {}).get("pinterest", {}) or {} + if not cfg.get("enabled", False): + return [] + token = cfg.get("token", "") + if not token: + print("[Pinterest] 未配置 token(pinterest.enabled=true 但未填 token),跳过。") + return [] + + # 官方 API v5 抓取逻辑(需商业账号 + Review 通过)。 + # 这里保留可插拔接口骨架;实际调用示例: + # headers = {"Authorization": f"Bearer {token}"} + # r = requests.get("https://api.pinterest.com/v5/pins/?query=...", headers=headers) + # 因多数项目难以通过 Review,默认返回空,避免阻塞主流程。 + print("[Pinterest] 已配置但抓取逻辑未启用(需商业 Review)。返回空。") + return [] diff --git a/graph/state.py b/graph/state.py new file mode 100644 index 0000000..8bbe811 --- /dev/null +++ b/graph/state.py @@ -0,0 +1,30 @@ +"""LangGraph 共享状态定义。 + +所有节点都读写 AgentState。键全部 optional(total=False), +因此单个节点崩溃 / 返回空时不会破坏整图,下游可用上一节点的残余数据继续。 +""" +from typing import TypedDict, List, Dict, Any, Optional + + +class AgentState(TypedDict, total=False): + # —— 路由 / 配置 —— + country: str # 当前处理的国家代码(US/GB/JP/AU) + config: Dict[str, Any] # 全局配置(config.yaml) + country_config: Dict[str, Any] # 该国合并后的配置(全局 + configs/countries/*.yaml + prompts//aesthetics.yaml) + prompts_dir: str # prompts/ 绝对路径 + output_dir: str # output/ 绝对路径 + + # —— 流水线数据(逐节点累积)—— + raw_rows: List[Dict[str, Any]] # fetch 产出:各源原始行(统一格式) + filtered_rows: List[Dict[str, Any]] # filter 产出:去黑名单/人名/泛词后 + scored_rows: List[Dict[str, Any]] # score 产出:归一化 + 融合 + 综合分 + screened: List[Dict[str, Any]] # screen 产出:合规风险 + 结构化四要素 + briefs: List[Dict[str, Any]] # prompt_build 产出:含最终 image/wearable/composite 提示词 + composite: List[Dict[str, Any]] # compose 产出:封装提示词包(= briefs 子集,便于下游印图) + designs: List[Dict[str, Any]] # compose 产出:纯印花设计稿 [{topic, path}](product 用它做图2) + product: List[Dict[str, Any]] # product 产出:产品图生成(SPU/SKU/底图/印花/模特合成) + seed_words: Dict[str, Any] # seed 产出:动态种子词(含 llm_style_seeds / llm_related_seeds) + + # —— 可观测性 —— + errors: List[Dict[str, Any]] # 各节点兜底捕获的错误:{node, type, message, trace} + stats: Dict[str, Any] # 各阶段统计:{fetch, filter, score, screen, prompt, compose} diff --git a/graph/style_rules.py b/graph/style_rules.py new file mode 100644 index 0000000..188942c --- /dev/null +++ b/graph/style_rules.py @@ -0,0 +1,340 @@ +"""动态风格 / 配色推导(领域知识,可插拔)。 + +关键:art_style 与 color_palette 不再按国家写死,而是根据「热点词本身语义」动态推导: + ① 该国专属 extra 规则(prompts//aesthetics.yaml,最具体优先) + ② 全局关键词规则 STYLE_PALETTE_RULES(覆盖更全、配色更丰富、风格更具体新颖) + ③ 确定性风格微调 STYLE_TWISTS(按主题哈希选,同词稳定、跨词不同,打破千篇一律) + ④ 按 classify 类别兜底 + ⑤ 国家基调最末兜底(极少走到) + +关键词匹配用「单词边界」,避免 eco 误中 cottagecore、kit 误中 jacket 等子串歧义。 +具体规则排在通用 retro/vintage 之前,避免 retro games / retro trainers 被笼统归为复古风。 +""" +import hashlib +import re +from typing import Dict, List, Optional, Tuple + +# 规则按「优先级」排列:越靠前越具体,命中任一关键词即采用该条(取第一个命中)。 +# 关键词匹配为单词边界。配色统一 5 色,艺术风格尽量具体+新颖。 +STYLE_PALETTE_RULES: List[Tuple[tuple, str, str]] = [ + (("skull", "death", "gothic", "horror", "zombie", "vampire", "spooky", "occult"), + "dark gothic engraving with ornate filigree", + "oxblood red, charcoal black, antique silver, deep purple, bone white"), + (("metal", "rock", "band", "grunge", "punk", "emo", "anarchist"), + "gritty risograph grunge zine", + "muted olive, rust orange, dirty cream, faded black, safety-pin silver"), + (("kawaii", "cute", "chibi", "sanrio"), + "kawaii chibi sticker with thick outlines", + "pastel pink, baby blue, mint, butter yellow, cream"), + (("cat", "kitten"), + "cute cat cartoon with bold outlines", + "warm cream, soft pink, charcoal, peach, sky blue"), + (("dog", "puppy"), + "playful dog cartoon with wagging tail", + "warm brown, cream, navy, tan, rust"), + (("coffee", "cafe", "tea", "latte", "brew"), + "cozy drink line illustration with steam curls", + "espresso brown, cream, caramel, sage, terracotta"), + (("mountain", "nature", "forest", "camping", "hiking", "outdoor", "wilderness", "woodland"), + "minimal outdoor landscape with layered ridgelines", + "forest green, slate gray, sand, cream, pine"), + (("beach", "surf", "ocean", "summer", "tropical", "sun", "seaside"), + "bright tropical screen-print", + "sky blue, coral, sand, sun-bleached white, turquoise"), + (("synthwave", "vaporwave", "retrowave"), + "synthwave neon with grid horizon", + "neon purple, magenta, cyan, deep navy, hot pink"), + (("anime", "manga", "otaku", "waifu"), + "anime-inspired flat cel with speed lines", + "vivid cyan, magenta, white, ink black, lemon"), + (("space", "galaxy", "star", "astro", "cosmic", "moon", "universe", "nebula"), + "cosmic vector with nebula glow", + "deep navy, violet, silver, starlight white, magenta"), + (("heart", "love", "valentine", "romance", "couple"), + "romantic badge with hand-lettered flourish", + "rose red, blush pink, cream, gold, burgundy"), + (("book", "reading", "library", "bookish", "novel"), + "cozy bookish line art with marginalia", + "warm brown, cream, forest green, oxblood, gold"), + (("music", "song", "concert", "festival", "dj", "gig"), + "dynamic gig poster with spotlight beams", + "electric purple, magenta, black, neon lime, silver"), + (("food", "pizza", "burger", "baking", "donut", "taco"), + "appetizing flat food illustration", + "warm red, cheese yellow, leaf green, cream, tomato"), + (("cyber", "cyberpunk", "neon", "tech", "robot", "mecha", "glitch"), + "neon digital with glitch grid", + "neon magenta, cyan, electric purple, black, lime"), + (("steam", "steampunk", "gear", "cog", "airship", "mechanical"), + "detailed mechanical engraving with brass", + "brass, copper, aged brown, sepia, gunmetal"), + (("solar", "eco", "green", "sustainable", "earth", "climate"), + "hopeful solarpunk eco with clean lines", + "leaf green, solar gold, sky blue, terracotta, cream"), + (("cottage", "cottagecore", "pastoral", "farm", "rustic"), + "soft storybook watercolor with wildflowers", + "sage green, butter yellow, dusty rose, cream, moss"), + (("car", "automotive", "vehicle", "classic", "racer"), + "retro automotive poster with chrome sheen", + "cherry red, cream, chrome silver, navy, tan"), + (("game", "gaming", "arcade", "pixel", "8-bit"), + "8-bit pixel-art with scanlines", + "neon green, magenta, cyan, black, yellow"), + (("shoe", "sneaker", "trainer", "footwear", "boots"), + "retro product sneaker illustration", + "white, red, navy, gum-sole tan, court grey"), + (("watch", "clock", "timepiece", "chronograph"), + "elegant engraving with roman numerals", + "antique gold, navy, cream, burgundy, slate"), + (("lion", "shield", "crest", "heraldic", "crown", "queen", "king", "royal"), + "heraldic emblem with original rampant beast", + "royal blue, crimson, gold, cream, navy"), + (("bee", "animal", "wildlife", "bird", "fox", "bear", "rabbit"), + "charming zoological illustration", + "honey gold, charcoal, leaf green, cream, rust"), + (("map", "city", "london", "travel", "trip", "skyline", "landmark"), + "mid-century travel poster with skyline", + "royal red, teal, navy, cream, mustard"), + (("rain", "weather", "cloud", "moody", "storm", "fog"), + "moody rain illustration with droplets", + "slate blue, pewter, cream, oxblood, charcoal"), + (("witch", "magic", "halloween", "wizard", "spell"), + "whimsical witchy illustration with moon", + "deep purple, black, moss green, gold, amethyst"), + (("christmas", "xmas", "santa", "snowflake", "festive"), + "festive christmas with needle-felt texture", + "pine green, berry red, cream, gold, ice blue"), + (("sport", "gym", "football", "soccer", "baseball", "workout", "jersey", "kit", "athletic", "england"), + "bold athletic emblem with motion streaks", + "navy, white, athletic red, silver, volt green"), + (("patriotic", "flag", "america", "freedom", "usa"), + "bold patriotic emblem with stars", + "navy, red, cream, gold, slate"), + (("retro", "80s", "90s", "y2k", "memphis"), + "1980s Memphis pop with geometric confetti", + "faded navy, cream, burnt orange, hot pink, teal"), + (("vintage", "antique", "distressed", "classic", "aged"), + "aged letterpress vintage with halftone", + "faded sepia, cream, muted teal, oxblood, distressed black"), +] + +# 预编译单词边界正则,避免 eco 误中 cottagecore、kit 误中 jacket 等子串歧义。 +# 每个关键词附加可选复数 (?:s)?,覆盖 games/cats 等复数形态。 +def _pattern_from(kws: List[str]) -> "re.Pattern": + return re.compile(r"\b(?:" + "|".join(re.escape(k) + "(?:s)?" for k in kws) + r")\b") + + +_RULE_PATTERNS = [ + (_pattern_from(list(kws)), art, pal) + for kws, art, pal in STYLE_PALETTE_RULES +] + + +def _kw_pattern(kws: List[str]) -> "re.Pattern": + return _pattern_from(kws) + + +# 风格微调池:确定性地给每条设计追加一个“技法/质感”修饰,提升新颖度与差异度。 +# 选择基于主题的稳定哈希,保证「同词稳定、跨词不同」(不依赖进程随机种子)。 +STYLE_TWISTS: List[str] = [ + "with subtle risograph grain and slight misregistration", + "with bold halftone dot shading", + "with hand-drawn imperfect ink edges", + "with limited-palette screen-print separation", + "with fine stipple and engraving texture", + "with paper-cut layered depth", + "with art-deco geometric framing", + "with Memphis-style confetti shapes", + "with soft watercolor bleed at the edges", + "with iridescent foil accent", +] + + +def _stable_hash(text: str) -> int: + """跨进程稳定的字符串哈希(不依赖 PYTHONHASHSEED)。""" + return int(hashlib.md5(text.encode("utf-8")).hexdigest(), 16) + + +# 类别兜底:关键词都没命中时,按 classify 类别给一个合理风格/配色(含 5 色) +STYLE_BY_CATEGORY: Dict[str, str] = { + "Event": "festive badge illustration", + "Meme": "bold comic meme illustration", + "Style": "trendy flat vector illustration", + "Niche": "clean modern vector illustration", + "Pattern": "seamless pattern tile illustration", + "Quote": "bold typographic illustration", +} +PALETTE_BY_CATEGORY: Dict[str, str] = { + "Event": "festive multi-color: red, gold, forest green, cream, berry", + "Meme": "bold high-contrast: black, white, pop yellow, magenta, cyan", + "Style": "trendy balanced modern: navy, coral, cream, sage, slate", + "Niche": "versatile balanced: teal, sand, charcoal, blush, white", + "Pattern": "harmonious repeating: terracotta, olive, cream, rust, gold", + "Quote": "high-contrast typographic: ink black, off-white, accent red, gold", +} + +# 国家基调最末兜底(代码内置;真正按国家定制走 prompts//aesthetics.yaml) +COUNTRY_AESTHETICS: Dict[str, Dict[str, str]] = { + "US": { + "label": "美国", + "style_hint": "Bold vintage / retro Americana, humorous and punchy, high-contrast poster style.", + "art_style": "bold vintage retro Americana poster, clean vector", + "palette": "muted retro Americana palette: faded navy, cream, burnt orange, distressed black, mustard", + }, + "GB": { + "label": "英国", + "style_hint": "Witty, self-deprecating British humor; punk / bold lettering; tea-and-rain mood.", + "art_style": "witty punk zine illustration, bold hand-lettered", + "palette": "punk-zine palette: high-contrast black, off-white, safety-orange, oxblood red, slate", + }, + "JP": { + "label": "日本", + "style_hint": "Kawaii / minimalist / anime-inspired; clean lines, Tokyo street edge, original kanji accents.", + "art_style": "kawaii minimalist flat illustration, clean lines", + "palette": "soft kawaii palette: pastel pink, mint, butter yellow, soft lavender, cream", + }, + "AU": { + "label": "澳大利亚", + "style_hint": "Sunny, laid-back coastal vibe; surf / beach / BBQ; relaxed and warm.", + "art_style": "sunny laid-back coastal illustration, relaxed", + "palette": "sunny coastal palette: sky blue, sand beige, coral, sun-bleached white, turquoise", + }, +} + + +def derive_style_palette( + topic: str, + country: str, + extra_rules: Optional[List[Dict[str, str]]] = None, + category: Optional[str] = None, + apply_twist: bool = True, +) -> Tuple[str, str]: + """根据热点词语义确定性地推导 (art_style, color_palette)。 + + 优先级:① 国家 extra 规则(最具体)→ ② 全局关键词规则 → ③ 类别兜底 → ④ 国家基调最末兜底。 + 结果与具体词一一对应,跨词不同、同词稳定。apply_twist=True 时追加确定性风格微调。 + """ + from .classify import classify + + tl = (topic or "").lower() + cat = category or classify(topic) + + # ① 国家专属 extra 规则(来自 prompts//aesthetics.yaml) + for rule in (extra_rules or []): + kws = [k.lower() for k in (rule.get("keywords") or [])] + if kws and _kw_pattern(kws).search(tl): + art = rule.get("art_style", "clean vector illustration") + pal = rule.get("color_palette", "balanced modern palette") + if apply_twist: + art = _apply_twist(art, topic) + return art, pal + + # ② 全局关键词规则(单词边界匹配,具体规则优先于通用 retro/vintage) + for pat, art, pal in _RULE_PATTERNS: + if pat.search(tl): + if apply_twist: + art = _apply_twist(art, topic) + return art, pal + + # ③ 类别兜底 + art = STYLE_BY_CATEGORY.get(cat, "clean vector illustration") + pal = PALETTE_BY_CATEGORY.get(cat, "balanced modern color palette") + + # ④ 国家基调最末兜底(极少走到) + if art == "clean vector illustration": + a = COUNTRY_AESTHETICS.get(country, {}) + art = a.get("art_style", art) + pal = a.get("palette", pal) + + if apply_twist: + art = _apply_twist(art, topic) + return art, pal + + +def _apply_twist(art: str, topic: str) -> str: + """确定性地给 art_style 追加一个技法微调(同词稳定、跨词不同)。""" + twist = STYLE_TWISTS[_stable_hash(topic) % len(STYLE_TWISTS)] + return f"{art}, {twist}" + + +# 构图变体池:与风格 twist 同理,确定性地为每条设计选一个差异化构图,打破千篇一律。 +COMPOSITION_VARIANTS: List[str] = [ + "centered circular emblem with balanced negative space", + "all-over repeat pattern with a centered focal badge", + "side-profile hero with motion lines", + "symmetrical mandala emblem, centered", + "scattered botanical border framing a centered wreath", + "bold central emblem with spray texture", + "layered landscape with a centered focal subject", + "centered badge inside a decorative ring", + "dynamic diagonal composition with speed streaks", + "tiled geometric grid with a centered motif", + "vertical stack emblem with a banner ribbon", + "framed portrait window with ornate border", +] + + +def derive_composition(topic: str, category: Optional[str] = None) -> str: + """确定性地推导构图(同词稳定、跨词不同),避免所有设计共用同一句构图。""" + return COMPOSITION_VARIANTS[_stable_hash(topic) % len(COMPOSITION_VARIANTS)] + + +# 图像生成策略敏感词 → 安全等效描述(生成提示词前清洗,降低内容政策拦截) +_IMG_RISKY_SWAP = { + "skull": "smiley mascot", "skeleton": "cute mascot", "blood": "red accents", + "gore": "bold shapes", "gun": "star", "weapon": "tool", "bomb": "firework", + "drug": "confetti", "demon": "cute monster", "devil": "mischievous imp", + "occult": "mystic pattern", "satanic": "dark pattern", "nazi": "retro emblem", + "hitler": "retro emblem", "zombie": "friendly ghoul", "horror": "spooky-cute", + "vampire": "night owl", "politics": "abstract shapes", "political": "abstract", + "president": "captain", "army": "team", "police": "officer", +} + + +def sanitize_image_prompt(prompt: str) -> str: + """清洗生图提示词: + 1) 删除一切背景描述(官方要求:透明背景由 background="transparent" 参数控制, + 提示词中不得提到背景,否则无法正常生成透明背景); + 2) 敏感词替换为安全等效描述(避免内容政策拦截)。 + """ + import re + out = prompt or "" + # 1) 删除背景短语(中英文都处理) + for pat in (r",\s*isolated on (transparent|pure white|white) background\b", + r"\s+isolated on (transparent|pure white|white) background\b", + r",\s*(transparent|white) background\b", + r"\s+on a (transparent|white|pure white) background\b", + r",\s*no background scene\b", r",\s*no background\b", r",\s*plain (transparent|white) background\b"): + out = re.sub(pat, "", out, flags=re.IGNORECASE) + # 1.5) 删除内容策略触发段(旧模板残留的安全规则说明:no politics/religion/hate/violence/sexual 等 + # 一旦出现在生图提示词中,图像 API 直接 content_policy_violation) + out = re.sub(r';?\s*any text must be safe[^;]*?(?:no gibberish|gibberish|\.[^,;]*)', '', out, flags=re.IGNORECASE) + out = re.sub(r'no (politics|religion|hate|violence|sexual|nude|nudity|bikini|nsfw|racist|profanity|swearing|adult content)s?,?', '', out, flags=re.IGNORECASE) + out = re.sub(r'(politics|religion|hate|violence|sexual content|nudity|nsfw)', '', out, flags=re.IGNORECASE) + # 2) 敏感词替换 + low = out.lower() + for k, v in _IMG_RISKY_SWAP.items(): + if k in low: + out = re.sub(rf"\b{re.escape(k)}\b", v, out, flags=re.IGNORECASE) + low = out.lower() + # 3) 清理多余空格/逗号 + out = re.sub(r",\s*,+", ",", out) + out = re.sub(r"\s{2,}", " ", out).strip(" ,") + return out + + +# review(疑似商标/受保护主题)简报的「原创化魔改」引导:只做风格参考,禁止复刻商标/品牌/角色 +REVIEW_REBRAND_HINT = ( + " IMPORTANT: this theme is ONLY a loose stylistic reference. " + "Do NOT reproduce any brand logo, trademark, character, mascot, copyrighted artwork or real person. " + "Create a fully ORIGINAL design with a different name and distinct visual details and colors — " + "a generic, non-infringing homage in the same mood, clearly distinct from the original." +) + + +def ensure_rebrand_hint(brief: dict, prompt: str) -> str: + """review 简报生成设计时兜底追加原创化魔改引导(旧缓存简报未注入时补上)。""" + if str(brief.get("risk_level", "")).strip().lower() == "review" \ + and "IMPORTANT: this theme is ONLY" not in (prompt or ""): + return (prompt or "") + REVIEW_REBRAND_HINT + return prompt or "" diff --git a/graph/template_export.py b/graph/template_export.py new file mode 100644 index 0000000..f4398ca --- /dev/null +++ b/graph/template_export.py @@ -0,0 +1,442 @@ +"""商品上传模板导出:从 db 读 SPU/SKU → 调 template_router 路由填入上传模板 Excel。 + +流程(product_node 生成产品图后调用): + 1. 从 spu_sku.db 读 SPU(款号)+ 该款选定颜色的全部尺码 SKU; + 2. 用 model/template_router.py 的 TemplateRouter: + - insert 一行 SPU(SPU货号 + 商品属性字段) + - 每个尺码 insert 一行 SKU(路由到 SPU 行下方,SKU货号 = 款号-颜色编码-尺码,填尺码表) + - 商品轮播图1~N 填生成的产品图路径(底图/印花/模特/合成) + 3. save 输出 <模板名>_已填写.xlsx 到指定目录。 + +字段映射:db 字段名 → 上传模板列名(见 SPU_MAP / SKU_MAP)。 +""" +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +from graph.product import _connect + +# 商品轮播图列名关键词(模板存在中/英/日变体,如 商品轮播图1 / Product Carousel Image 1 / 商品カルーセル画像1) +_CAROUSEL_KW = ("轮播", "carousel", "カルーセル") + + +def _carousel_col(router, idx: int) -> Optional[int]: + """定位「商品轮播图{idx}」列号:先精确匹配(中文列名),失败则按中/英/日关键词模糊匹配序号。""" + try: + return router.resolve_col(f"商品轮播图{idx}") + except KeyError: + pass + for name, col in router.column_map.items(): + low = str(name).lower().replace(" ", "").replace(" ", "") + if not any(k in low for k in _CAROUSEL_KW): + continue + m = re.search(r"(\d+)$", low) + if m and int(m.group(1)) == idx: + return col + return None + + +def _detail_col(router) -> Optional[int]: + """定位「详情图文」列:优先英语(详情图文-英语),回退日语(详情图文-日语),再回退任意详情图文。""" + for name in ("详情图文-英语", "详情图文-英文", "详情图文-EN"): + try: + return router.resolve_col(name) + except KeyError: + pass + try: + return router.resolve_col("详情图文-日语") + except KeyError: + pass + for name, col in router.column_map.items(): + if "详情图文" in str(name): + return col + return None + + +def _ja_col(router) -> Optional[int]: + """定位「日语名称」列(基础信息组,如 日语名称/日语标题)。""" + try: + return router.resolve_col("日语名称") + except KeyError: + pass + for name, col in router.column_map.items(): + low = str(name) + if "日语" in low and "详情图文" not in low and "轮播图" not in low and "名称" in low: + return col + return None + + +def _fill_design_fields(router, spu_code: str, oss_code: str, cn_title: str, en_title: str, + ja_title: str, composite_by_sku: Dict[str, Any], + all_composite_urls: List[str], seed_shot_urls: List[str], + only_rows: Optional[List[int]] = None) -> None: + """按用户要求填充设计联动字段: + - SPU 行:SPU货号=设计货号、SKU货号=设计货号、商品名称=cn_title、英文名称=en_title、 + 日语名称=ja_title、商品轮播图1=随机一张三合一主图、详情图文=全部主图+种草图链接 | 分割 + - SKU 行:SPU货号=设计货号、SKU货号=该颜色货号、商品轮播图1=该颜色三合一链接、 + 商品名称/英文名称/日语名称 与 SPU 一致 + only_rows:合并模式下只填充本产品块的行(None=该款全部行) + """ + import random + try: + color_col = router.resolve_col("色值(主规格)") + except KeyError: + color_col = None + try: + spu_col = router.resolve_col("SPU货号") + except KeyError: + spu_col = None + try: + name_col = router.resolve_col("商品名称") + except KeyError: + name_col = None + try: + en_col = router.resolve_col("英文名称") + except KeyError: + en_col = None + try: + ja_col = _ja_col(router) + except KeyError: + ja_col = None + try: + sku_code_col = router.resolve_col("SKU货号") + except KeyError: + sku_code_col = None + car1 = _carousel_col(router, 1) + detail_col = _detail_col(router) + + for row in router.find_spu_rows(spu_code): + if only_rows is not None and row not in only_rows: + continue # 合并模式:只填本产品块的行 + lvl = str(router.ws.cell(row, 1).value or "").strip().lower() + color = str(router.ws.cell(row, color_col).value or "").strip() if color_col else "" + if lvl == "spu": + if spu_col and oss_code: + router.ws.cell(row, spu_col, oss_code) + if sku_code_col and oss_code: + router.ws.cell(row, sku_code_col, oss_code) + if name_col and cn_title: + router.ws.cell(row, name_col, cn_title) + if en_col and en_title: + router.ws.cell(row, en_col, en_title) + if ja_col and ja_title: + router.ws.cell(row, ja_col, ja_title) + if car1 is not None and all_composite_urls: + router.ws.cell(row, car1, random.choice(all_composite_urls)) # SPU 轮播图1 随机 + if detail_col is not None: + links = [u for u in (all_composite_urls + list(seed_shot_urls or [])) if u] + if links: + router.ws.cell(row, detail_col, "|".join(links)) # 详情图文 | 分割 + else: + if spu_col and oss_code: + router.ws.cell(row, spu_col, oss_code) + # SKU 行与 SPU 一致:商品名称/英文名称/日语名称 + if name_col and cn_title: + router.ws.cell(row, name_col, cn_title) + if en_col and en_title: + router.ws.cell(row, en_col, en_title) + if ja_col and ja_title: + router.ws.cell(row, ja_col, ja_title) + cc = composite_by_sku.get(color) or composite_by_sku.get("") # 按色值匹配该颜色主图 + if sku_code_col and oss_code: + router.ws.cell(row, sku_code_col, oss_code) # SKU货号=SPU货号(同一货号) + if car1 is not None and cc and cc.get("url"): + router.ws.cell(row, car1, cc["url"]) # 该颜色轮播图1 + + +def _build_spu_row(spu: Dict[str, Any], spu_code: str, origin_province: str, + color: Optional[str] = None) -> Dict[str, Any]: + """构造一行 SPU(固定字段:SKC货号=code、风格=休闲、商品产地=经营站点;多颜色时用色值列区分)。""" + row: Dict[str, Any] = { + "基础信息-商品层级": "spu", + "SKC货号": spu_code, # code 路由为 SKC货号(用户要求) + "风格": "休闲", # style 路由为"休闲"(用户要求) + "商品产地": origin_province, # 产地省份不用填,经营站点填到「商品产地」 + } + if color: + row["色值(主规格)"] = color + for dbk, header in SPU_MAP.items(): + v = spu.get(dbk) + if v not in (None, ""): + row[header] = v + return row + + +def _find_price_header(router) -> str: + """定位价格列表头:任意含「申报价格」的列(美站/日站/英站…模糊匹配);找不到回退默认。""" + for k in router.column_map: + if "申报价格" in str(k): + return str(k) + return "申报价格-日本站" + + +def _build_sku_row(spu_code: str, sc: str, sk: Dict[str, Any], size: str, color: str, + warehouses: List[str], markup_percent: float = 0.0, + multi: bool = True, price_header: str = "申报价格-日本站") -> Dict[str, Any]: + """构造一行 SKU(固定字段:SPU货号、SKC货号=sku.code、规格类型2、币种 CNY、发货仓1~N 及库存 200)。 + 价格(price_header 列,如 申报价格-美国站/日本站,模糊匹配)= SKU.price × (1+markup/100),预先填好。 + 规格类型2 统一填「尺码」两个字(不是 size 参数值)。""" + row: Dict[str, Any] = { + "基础信息-商品层级": "sku", + "SPU货号": spu_code, + "SKC货号": sk.get("code") or sc, # SKC货号 = SKU 的 code(款号-颜色编码) + "色值(主规格)": color, + "规格类型2": "尺码", # 规格类型2 统一填「尺码」(不填 size 值) + "币种": "CNY", + } + for j, w in enumerate(warehouses, start=1): + row[f"发货仓{j}"] = w + row[f"发货仓{j}库存"] = 200 + for dbk, header in SKU_MAP.items(): + if dbk == "color": + continue + if dbk == "price": + header = price_header # 模糊匹配的实际价格列(申报价格-美站/日站/英站…) + v = sk.get(dbk) + if dbk == "price" and v not in (None, ""): + v = round(float(v) * (1 + markup_percent / 100), 2) # 申报价格 = price × (1+加价%) + if v not in (None, ""): + row[header] = v + return row + + +def _fill_sku_carousel(router, spu_code: str, color: str, color_col: int, + first: Dict[str, Any], sku_imgs: List[str]) -> None: + """SKU 行商品轮播图2~5:db img_url_2~5 优先,无则回退生成图;按色值列匹配所属 SKU 行。""" + for j in range(2, 6): + url = first.get(f"img_url_{j}") # db CDN url(该颜色 SKU 的 img_url_2~5) + img = url if url not in (None, "") else (sku_imgs[j - 2] if j - 2 < len(sku_imgs) else None) + if not img: + continue + col = _carousel_col(router, j) + if col is None: + continue + for row in router.find_spu_rows(spu_code): + if (str(router.ws.cell(row, 1).value or "").strip().lower() == "sku" + and str(router.ws.cell(row, color_col).value or "").strip() == color): + router.ws.cell(row, col, str(img)) + +# db SPU 字段 -> 上传模板列名 +SPU_MAP: Dict[str, str] = { + "code": "SPU货号", + "material": "材质", + "component_1": "成分1", + "component_proportion_1": "成分1成分比例", + "component_2": "成分2", + "component_proportion_2": "成分2成分比例", + "component_3": "成分3", + "component_proportion_3": "成分3成分比例", + "pattern": "图案", + "details": "细节", + "collar_style": "领型", + "care_Instructions": "护理说明", + "fabric": "面料", + "target_audience": "适用人群", + "season": "季节", + "is_transparent": "是否透明", + "layout": "版型", + "weaving_method": "织造方式", + "printing_type": "印花类型", + "fabric_texture_1": "面料纹理1", + "fabric_weight_1": "面料克重1(g/m²)", + "fabric_weight_unit_1": "面料克重1(g/m²)单位", + "lining_texture": "里料纹理", +} + +# db SKU 字段 -> 上传模板列名 +SKU_MAP: Dict[str, str] = { + "color": "色值(主规格)", + "size": "尺码", + "size_group": "尺码组别", + "size_type": "尺码类型", + "shoulder_width": "肩宽(cm)", + "bust": "胸围全围(cm)", + "clothing_length": "衣长(cm)", + "sleeve_length": "袖长(cm)", + "longest_side": "最长边(cm)", + "secondary_long_side": "次长边(cm)", + "shortest_side": "最短边(cm)", + "package_weight": "重量(g)", # 包装重量:从 db 提取 + "price": "申报价格-日本站", +} + + +def _read_spu(db_path, spu_code: str) -> Optional[Dict[str, Any]]: + conn = _connect(db_path) + row = conn.execute("SELECT * FROM SPU WHERE code = ?", (spu_code,)).fetchone() + conn.close() + return dict(row) if row else None + + +def _read_meta(router) -> tuple: + """读模板顶头元信息:经营站点(第2行第1列)、发货仓(第2行第2列)。 + + 返回 (origin_province, warehouses): + - origin_province:经营站点去掉末尾「站」(如「日本站」→「日本」) + - warehouses:发货仓按「、」分隔的列表(如「名古屋仓、inkreach——东京」→ 2 个) + """ + ws = router.ws + site = str(ws.cell(2, 1).value or "").strip() + origin_province = site[:-1] if site.endswith("站") else site + raw = str(ws.cell(2, 2).value or "").strip() + warehouses = [w.strip() for w in raw.split("、") if w.strip()] + return origin_province, warehouses + + +def _read_skus(db_path, spu_code: str, sku_code: str) -> List[Dict[str, Any]]: + """该款该颜色的全部尺码 SKU。""" + conn = _connect(db_path) + rows = conn.execute( + """SELECT s.*, p.code AS spu_code FROM SKU s + JOIN SPU p ON s.spu_id = p.id + WHERE p.code = ? AND s.code = ? + ORDER BY s.size""", (spu_code, sku_code)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def export_product( + db_path, + spu_code: str, + sku_code, # str | List[str]:单颜色或多个颜色 + template_dir: str, + template_path: str, + out_path: str, + images: Optional[List[str]] = None, + spu_per_color: bool = True, + oss_code: str = "", + cn_title: str = "", + en_title: str = "", + ja_title: str = "", + composite_urls: Optional[List[Dict[str, Any]]] = None, + seed_shot_urls: Optional[List[str]] = None, + append_to: str = "", + markup_percent: float = 0.0, +) -> Path: + """生成商品上传"已填写"模板(支持多产品合并到同一文件)。 + + sku_code :SKU 颜色编码,支持单个 str 或多个(list/tuple/逗号分隔字符串)。 + spu_per_color :True(默认)= 每个颜色导出一个 SPU 块;False = 单 SPU 下挂所有颜色 SKU 变体。 + template_dir :template_router.py 所在目录(用于 import) + template_path :商品上传模版 xlsx 路径 + images :生成的产品图路径列表(仅作用于第一个颜色块:SPU 行轮播图1 + SKU 行回退) + oss_code :设计货号(前缀+3位计数),SPU货号/SKU货号 列均填它 + cn_title :商品名称(中文标题) + en_title :英文名称(英文标题) + ja_title :日语名称(日语标题,JP 模板生成) + composite_urls:[{"sku_code","color","url","code"}] 每色三合一主图(含图床链接与货号) + seed_shot_urls :种草图图床链接列表(详情图文 | 拼接用) + append_to :已有输出文件路径;提供则在其基础上追加本产品块(一次任务多产品合并一个模板) + markup_percent :加价百分比,申报价格 = SKU.price × (1+markup/100) 预填 + 返回输出文件路径。 + """ + # 1) 读 db(支持单/多颜色) + spu = _read_spu(db_path, spu_code) + if spu is None: + raise ValueError(f"SPU {spu_code} 不存在于 db") + if isinstance(sku_code, str) and "," in sku_code: + sku_codes = [s.strip() for s in sku_code.split(",") if s.strip()] + elif isinstance(sku_code, (list, tuple)): + sku_codes = list(sku_code) + else: + sku_codes = [sku_code] + skus_by_color: List[tuple] = [] + for sc in sku_codes: + skus = _read_skus(db_path, spu_code, sc) + if not skus: + raise ValueError(f"SKU {sc} 不存在于 db(款号 {spu_code})") + skus_by_color.append((sc, skus)) + images = [str(i) for i in (images or []) if i] + + # 2) import template_router(优先 config 的 template_dir;打包后回退 _MEIPASS/model) + tdir = Path(template_dir) + candidates = [tdir] + meipass = getattr(sys, "_MEIPASS", None) + if meipass: + candidates.append(Path(meipass) / "model") + # 用户上传的模板可能在任意目录(无 template_router.py),兜底项目自带 templates/ + from graph.paths import project_root as _proj_root + candidates.append(_proj_root() / "templates") + for d in candidates: + if d.exists() and str(d) not in sys.path: + sys.path.insert(0, str(d)) + from template_router import TemplateRouter # noqa: E402 + + # append_to:合并模式从已有输出文件继续追加(一次任务多产品填一个模板) + router = TemplateRouter(append_to if append_to else template_path) + try: + origin_province, warehouses = _read_meta(router) + price_header = _find_price_header(router) # 申报价格列(美站/日站/英站…模糊匹配) + multi = len(skus_by_color) > 1 + color_col = router.resolve_col("色值(主规格)") + block_rows: List[int] = [] # 本产品块插入的所有行号(_fill_design_fields 只填这些行) + + if spu_per_color: + # 3) 单 SPU 多色:1 个 SPU 行(无色值,SPU 级信息由 _fill_design_fields 填充) + # + 全部颜色尺码 SKU 行(色值在 SKU 行区分) + block_rows.append(router.insert( + _build_spu_row(spu, spu_code, origin_province), + match="exact", + )) + for ci, (sc, skus) in enumerate(skus_by_color): + first = skus[0] + color = first.get("color") or sc + + # 该颜色全部尺码 SKU(SKU 行 SPU货号/SKU货号=spu_code,色值区分) + for i, sk in enumerate(skus): + size = sk.get("size") or f"{i+1}" + block_rows.append(router.insert( + _build_sku_row(spu_code, sc, sk, size, color, warehouses, + markup_percent=markup_percent, multi=True, + price_header=price_header), + spu_code=spu_code, match="exact", + )) + + # 3.3) 轮播图:首色 SKU 行轮播图1 = 生成首图;SKU 行按色值填 db url/生成图 + if ci == 0 and images: + col1 = _carousel_col(router, 1) + if col1 is not None: + sku_rows = router.find_sku_rows(spu_code) + if sku_rows: + router.ws.cell(min(sku_rows), col1, str(images[0])) + sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else [] + _fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs) + else: + # 4) 单 SPU + 多颜色变体:1 个 SPU 行(无色值)+ 所有颜色所有尺码 SKU 行(色值区分) + block_rows.append(router.insert( + _build_spu_row(spu, spu_code, origin_province), match="exact")) + multi_variant = len(skus_by_color) > 1 + for ci, (sc, skus) in enumerate(skus_by_color): + first = skus[0] + color = first.get("color") or sc + for i, sk in enumerate(skus): + size = sk.get("size") or f"{i+1}" + block_rows.append(router.insert( + _build_sku_row(spu_code, sc, sk, size, color, warehouses, + markup_percent=markup_percent, multi=multi_variant, + price_header=price_header), + )) + sku_imgs = [x for x in (images[1:] + images[:1]) if x][:4] if (ci == 0 and images) else [] + _fill_sku_carousel(router, spu_code, color, color_col, first, sku_imgs) + # 无 SPU 行:首图(轮播图1)由 _fill_design_fields 按 SKU 行填充 + + # 5) 设计联动字段:货号/标题/轮播图路由/详情图文(图床链接,| 分割) + if oss_code or cn_title or en_title or ja_title or composite_urls: + by_sku: Dict[str, Any] = {} + all_urls: List[str] = [] + for cc in (composite_urls or []): + if cc.get("color"): + by_sku[str(cc["color"]).strip()] = cc + if cc.get("url"): + all_urls.append(str(cc["url"])) + _fill_design_fields(router, spu_code, oss_code, cn_title, en_title, ja_title, + by_sku, all_urls, seed_shot_urls or [], only_rows=block_rows) + + out = router.save(out_path) + return Path(out) + finally: + try: + router.close() + except Exception: + pass diff --git a/graph/templates.py b/graph/templates.py new file mode 100644 index 0000000..a2ed8e4 --- /dev/null +++ b/graph/templates.py @@ -0,0 +1,144 @@ +"""固定提示词模板(规则写死,保证每条一致)+ 装配函数。 + +所有最终提示词都由四要素(motif / art_style / color_palette / composition) +用下面的模板确定性拼出,LLM 不再自由发挥,因此结构永远一致、可复用于 img2img。 + +v3:模板按国家区分(COUNTRY_TEMPLATES),每国有自己的设计风格引导段; +顶层 DEFAULT_TEMPLATES 作为兜底。文字规则统一:英文可加可不加、适配印花即可, +任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物等敏感内容。 +""" +from typing import Any, Dict, Optional + +# —— 通用固定段(所有国家共用,保证结构一致)—— +# 尺寸规则:最小约 15×18cm ~ 最大 26×32cm 之间自由选择(防模型默认出满幅大图) +SIZE_RULE = ( + "size: choose freely between a MINIMUM print area of about 15x18 cm " + "and a MAXIMUM of 26x32 cm, any size in this range fits, " + "pick the one that best suits the design, keep proportions, " + "scale naturally to the content, do NOT stretch, " + "do NOT fill the entire canvas, do NOT force full-bleed, " + "leave balanced margins around the artwork" +) +# 排除段:无衣服/模特/场景/水印 +NEG_FIXED = ( + "no garment, no shirt, no model, no mannequin, no background scene, no watermark" +) +# 文字规则(v3):可加可不加、适配印花即可;任何文字严禁敏感内容 +# 注意:正向提示词不写敏感词(no politics/no hate/no violence/no sexual…会被图像审核误判), +# 负向约束统一由 negative_prompt 承担 +TEXT_RULE = ( + "text: optional - add short original English words or a small slogan ONLY if they fit " + "the print, or keep it text-free; any text must be safe, short and original; " + "no brand names, no logos, no trademarked phrases, no real people names" +) + +# —— 国家风格引导段(每国不同:US 大胆高对比 / GB 英式自嘲与复古 / JP 卡哇伊极简 / AU 海滨户外)—— +COUNTRY_STYLE_HINT: Dict[str, str] = { + "US": "US-market aesthetic: bold confident statement graphic, high contrast, " + "clean modern vector, sporty or humorous mood", + "GB": "UK-market aesthetic: witty understated British charm, heritage-inspired motifs, " + "retro sportswear or punk-zine mood", + "JP": "JP-market aesthetic: kawaii cute or clean minimal, soft pastel-friendly, " + "polished neat lines, small cute mascot mood", + "AU": "AU-market aesthetic: laid-back coastal and outdoor vibe, nature-inspired, " + "bright fresh energy", + "MX": "MX-market aesthetic: vibrant mexican folk art, sugar skull / loteria / aztec motifs, " + "fiesta colors, festive cultural pride mood", +} + + +def _image_prompt_for(country: str) -> str: + hint = COUNTRY_STYLE_HINT.get(country, COUNTRY_STYLE_HINT["US"]) + return ( + "{motif}, {art_style}, {color_palette}, {composition}, " + "standalone pure print design, print-ready artwork, " + "isolated on pure white background, flat vector-like graphic, " + "crisp clean edges, high resolution, ultra sharp, high contrast, " + f"{hint}, {SIZE_RULE}, " + f"{NEG_FIXED}, {TEXT_RULE}" + ) + + +# 顶层默认(兜底:国家未配置时使用,内容等同 US 风格基线) +DEFAULT_TEMPLATES: Dict[str, str] = { + "image_prompt": _image_prompt_for("US"), + # 预览图:直接印在平铺白T上(无真人),用于快速看效果 + "wearable_prompt": ( + "{motif}, {art_style}, {color_palette}, {composition}, " + "printed centered on the chest of a flat-lay plain white t-shirt, " + "print sized freely between about 15x18 cm and a max of 26x32 cm, " + "scaled naturally to the artwork, not stretched, not full-bleed, " + "studio lighting, e-commerce product photo, no human model" + ), + # 复合提示词(三图模特合成):图1=模特 / 图2=纯印花设计稿 / 图3=平铺底图 → 模特穿着成品 + "composite_prompt": ( + "【图片角色,按提交顺序】图1=模特实拍图(基底);图2=纯印花设计稿;" + "图3=平铺衣服底图(颜色/面料来源)。\n" + "TASK: 把图2的印花设计印到图3底色的衣服上,并让图1的模特穿上" + "“图3底色+图2印花”的衣服。\n" + "RULES:\n" + "1.底色锁定:从图3提取衣服底色与面料,最终合成中必须100%保持不变,严禁偏色。\n" + "2.印花提取:从图2精准提取纯印花图案(线条/色号/比例),叠加到图3底色上形成合成面料。\n" + "3.主体遮罩:识别图1模特服装穿着区域(忽略皮肤/头发/背景/配饰)," + "用合成面料完整覆盖,清除原衣服颜色与图案。\n" + "4.精准贴合:合成面料严格跟随图1衣服立体结构,褶皱/扭转处印花相应变形," + "杜绝“贴纸感”与“平面涂色感”。\n" + "5.光影融合:按图1环境光方向调整亮度/对比度,印花受光影响产生明暗变化但色号不偏移。\n" + "6.纯净输出:仅输出一张最终合成图;图1背景/人物/构图/光影100%不变," + "仅替换衣服印花与底色。\n" + "DESIGN CONTENT: {motif}, {art_style}, {color_palette}, {composition}." + ), + # 复合负向(印图专用) + "composite_negative": ( + "garment changed, wrong color, distorted print, blurry, low-res, " + "human model, body, extra objects, watermark, glow, 3d render, " + "text unless part of design" + ), +} + +# —— 按国家覆盖:目前仅 image_prompt 有国家差异化;wearable/composite 共用顶层默认 —— +COUNTRY_TEMPLATES: Dict[str, Dict[str, str]] = { + cc: {"image_prompt": _image_prompt_for(cc)} for cc in COUNTRY_STYLE_HINT +} + + +def resolve_templates(tpls: Optional[Dict[str, Any]], country: Optional[str] = None) -> Dict[str, str]: + """解析最终模板:DEFAULT_TEMPLATES 兜底 → config 顶层覆盖 → config countries. 覆盖。 + + config 结构示例: + prompt_templates: + image_prompt: "..." # 顶层默认 + countries: + GB: + image_prompt: "..." # 国家专属 + """ + t = dict(DEFAULT_TEMPLATES) + if tpls: + t.update({k: v for k, v in tpls.items() if k in DEFAULT_TEMPLATES}) + countries = tpls.get("countries") or {} + if country and isinstance(countries, dict): + cc_tpls = countries.get(country) or {} + if isinstance(cc_tpls, dict): + t.update({k: v for k, v in cc_tpls.items() if k in DEFAULT_TEMPLATES}) + elif country and country in COUNTRY_TEMPLATES: + t.update(COUNTRY_TEMPLATES[country]) + return t + + +def assemble_prompts( + motif: str, + art_style: str, + palette: str, + composition: str, + tpls: Optional[Dict[str, Any]] = None, + country: Optional[str] = None, +) -> Dict[str, str]: + """用固定模板确定性装配三种提示词。tpls 可来自 config 覆盖(按国家优先)。""" + t = resolve_templates(tpls, country) + out = {} + for key in ("image_prompt", "wearable_prompt", "composite_prompt"): + out[key] = t[key].format( + motif=motif, art_style=art_style, color_palette=palette, composition=composition + ) + out["composite_negative"] = t["composite_negative"] + return out diff --git a/graph/validate.py b/graph/validate.py new file mode 100644 index 0000000..6514ace --- /dev/null +++ b/graph/validate.py @@ -0,0 +1,94 @@ +"""节点级兜底校验工具。 + +设计目标:LangGraph 流水线里每个节点都必须"失败不影响整体"。 +提供两类兜底: +1. with_fallback(node_name):装饰器,节点函数抛异常时捕获,把错误写入 state['errors'], + 并返回最小更新(不破坏其它字段),整图继续往下走。 +2. 数据校验函数:validate_rows / validate_brief,对节点产出的数据进行结构校验, + 剔除非法记录并记录原因,保证下游拿到的数据"形状正确"。 +""" +import functools +import traceback +from typing import Any, Dict, List + + +def with_fallback(node_name: str): + """装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。 + + 节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险: + 任何未预料的异常都不会让整张图中断。 + """ + + def deco(fn): + @functools.wraps(fn) + def wrapper(state: Dict[str, Any]): + try: + return fn(state) + except Exception as e: # noqa: BLE001 + tb = traceback.format_exc(limit=3) + err = { + "node": node_name, + "type": type(e).__name__, + "message": str(e)[:300], + "trace": tb[-400:], + } + errors = list(state.get("errors") or []) + errors.append(err) + # 只更新 errors,其它字段保持上一节点结果,下游继续 + return {"errors": errors} + + return wrapper + + return deco + + +def validate_rows(rows: List[Dict[str, Any]], node: str) -> List[Dict[str, Any]]: + """校验抓取/过滤后的行结构,剔除缺 topic 或非法记录,返回干净列表。 + + 同时保证每个 row 至少含 country/topic/source/kind/raw_score,缺失时给默认。 + """ + clean: List[Dict[str, Any]] = [] + dropped = 0 + for r in rows or []: + if not isinstance(r, dict): + dropped += 1 + continue + topic = (r.get("topic") or "").strip() + if not topic: + dropped += 1 + continue + r.setdefault("country", "") + r.setdefault("source", "unknown") + r.setdefault("kind", "unknown") + r.setdefault("raw_score", 0.0) + if r.get("raw_score") is None: + r["raw_score"] = 0.0 + clean.append(r) + if dropped: + # 简单记录到返回数据的副作用里(调用方会再汇总到 stats) + pass + return clean + + +def validate_brief(b: Dict[str, Any]) -> Dict[str, Any]: + """校验单条设计简报结构,补齐缺失字段,保证下游 compose 不会因缺键崩溃。""" + b = dict(b) + b.setdefault("topic", "") + b.setdefault("country", "") + b.setdefault("design_category", "Niche") + b.setdefault("risk_level", "safe") + b.setdefault("motif", b.get("topic", "")) + b.setdefault("art_style", "clean vector illustration") + b.setdefault("color_palette", "balanced modern palette") + b.setdefault("composition", "centered emblem with balanced negative space") + b.setdefault("concept", b.get("topic", "")) + b.setdefault("negative_prompt", "") + b.setdefault("image_prompt", "") + b.setdefault("wearable_prompt", "") + b.setdefault("composite_prompt", "") + b.setdefault("composite_negative", "") + return b + + +def safe_get(state: Dict[str, Any], key: str, default=None): + return state.get(key, default) diff --git a/material_library/T-shirt/O1CN01G7AxxI1HhPaUai6LV_!!320860789.webp b/material_library/T-shirt/O1CN01G7AxxI1HhPaUai6LV_!!320860789.webp new file mode 100644 index 0000000..cac96ba Binary files /dev/null and b/material_library/T-shirt/O1CN01G7AxxI1HhPaUai6LV_!!320860789.webp differ diff --git a/prompts/AU/aesthetics.yaml b/prompts/AU/aesthetics.yaml new file mode 100644 index 0000000..e9d09f4 --- /dev/null +++ b/prompts/AU/aesthetics.yaml @@ -0,0 +1,132 @@ +# 澳大利亚审美与风格-配色规则(prompts/AU/aesthetics.yaml) +style_hint: "Sunny, laid-back coastal vibe; surf / beach / BBQ / outback; relaxed and warm. Prefer: Beach, Surf, Outback, Native Wildlife, Coastal, Summer." + +extra_style_rules: + - keywords: [outback, aussie, kangaroo, surf, beach, coastal] + art_style: "laid-back coastal illustration" + color_palette: "sky blue, sand, coral, sun-bleached white" + - keywords: [koala, kangaroo, wombat, wallaby, native animal] + art_style: "cute australian wildlife illustration" + color_palette: "eucalyptus green, cream, warm brown" + - keywords: [quokka, roo, smile] + art_style: "happy quokka wildlife cartoon" + color_palette: "sand, white, soft gray, coral" + - keywords: [surf, board, wave, beach, coastal] + art_style: "retro surf beach poster" + color_palette: "turquoise, coral, sand, white" + - keywords: [outback, red centre, uluru, desert] + art_style: "australian outback landscape illustration" + color_palette: "ochre red, burnt orange, sand gold, deep blue" + - keywords: [reef, great barrier reef, fish, turtle, ocean] + art_style: "tropical reef marine illustration" + color_palette: "cyan, coral, aqua blue, white" + - keywords: [bbq, sausage, barbie, cooking, mates] + art_style: "fun aussie bbq illustration" + color_palette: "charcoal, fire red, golden brown, white" + - keywords: [footy, afl, rugby, league, cricket, sport] + art_style: "australian sport fan illustration" + color_palette: "grass green, white, navy blue, gold" + - keywords: [thongs, sunnies, holiday, summer, beach day] + art_style: "australian beach holiday illustration" + color_palette: "lemon yellow, sky blue, white, coral" + - keywords: [galah, cockatoo, parrot, bird, lorikeet] + art_style: "australian parrot illustration" + color_palette: "pink gray, white, yellow, orange" + - keywords: [didgeridoo, boomerang, aboriginal, indigenous] + art_style: "australian aboriginal-inspired geometric pattern" + color_palette: "ochre, black, white, warm red" + - keywords: [vegemite, toast, breakfast, brekkie] + art_style: "australian breakfast culture illustration" + color_palette: "golden brown, white, red, cream" + - keywords: [wattle, flower, golden wattle, native plant] + art_style: "golden wattle botanical illustration" + color_palette: "golden yellow, leaf green, brown, cream" + - keywords: [road trip, van, caravan, camping] + art_style: "australian road trip van illustration" + color_palette: "mint green, cream, burnt orange, white" + - keywords: [sydney, opera house, harbour, bridge, city] + art_style: "sydney harbour silhouette illustration" + color_palette: "sky blue, white, deep blue, gold" + - keywords: [christmas in july, summer christmas, santa beach] + art_style: "australian summer christmas illustration" + color_palette: "red, gold, green, white" + - keywords: [emu, cassowary, flightless bird] + art_style: "funny emu illustration" + color_palette: "brown, cream, gray, orange" + - keywords: [beach cricket, backyard cricket, summer sport] + art_style: "backyard cricket fun illustration" + color_palette: "green, white, yellow, red" + - keywords: [wine, vineyard, cellar door, pinot, shiraz] + art_style: "australian wine label style" + color_palette: "deep red, cream, olive, gold" + - keywords: [meat pie, sausage roll, lamington, pavlova, anzac biscuit] + art_style: "aussie food illustration" + color_palette: "golden brown, cream, berry red, mint" + - keywords: [platypus, echidna, kookaburra, emu, native wildlife] + art_style: "native wildlife illustration" + color_palette: "eucalyptus green, cream, warm brown, sky blue" + - keywords: [gold coast, sunshine coast, tropical, holiday] + art_style: "tropical queensland holiday illustration" + color_palette: "turquoise, coral, sunshine yellow, white" + +# 澳大利亚常见侵权盲区(按国家隔离) +extra_blacklist: + - collingwood # AFL 球队 + - richmond + - "west coast eagles" + - "sydney swans" + - woolworths # 本土零售商标 + - coles + - vegemite # 商标(vegemite 是 Kraft 商标,禁止文字使用) + - "tim tams" # Arnott's 商标 + - kmart + - "big w" + - carlton # AFL 球队 + - essendon + - hawthorn + - geelong + - "north melbourne" + - bulldogs + - fremantle + - "gold coast suns" + - "greater western sydney" + - afl # 联盟商标 + - "rugby league" + - nrl + - wallabies # 橄榄球国家队 + - kangaroos + - "bunnings" # 五金零售商标 + - colesworth + - anzac # ANZAC 标志受法律保护(军墓/纪念用途) + - "anzac day" + # —— 扩充:政界人物 —— + - albanese + - dutton + - morrison + - turnbull + # —— 扩充:名人 —— + - kylie minogue + - margot robbie + - chris hemsworth + - nicole kidman + - hugh jackman + - cate blanchett + # —— 扩充:品牌/机构 —— + - qantas + - telstra + - optus + - nbn + - "commonwealth bank" + - anz + - nab + - westpac + # —— 扩充:啤酒/食品商标 —— + - vb + - "victoria bitter" + - fosters + - "four n twenty" + - "golden gaytime" + # —— 扩充:赛事/联赛 —— + - "state of origin" + - "big bash" + - "cricket australia" diff --git a/prompts/AU/system_prompt.md b/prompts/AU/system_prompt.md new file mode 100644 index 0000000..aaff228 --- /dev/null +++ b/prompts/AU/system_prompt.md @@ -0,0 +1,5 @@ +# 澳大利亚专属补充指令 +- 设计口吻:阳光、松弛的沿海度假感,冲浪 / 海滩 / BBQ,温暖明亮。 +- 合规重点:避免真实品牌(如本土零售/啤酒商标)、原住民文化符号的冒犯性使用、真实人物肖像。 +- 鼓励做"澳洲野生动物(原创卡通袋鼠/考拉)、海滩日落、户外探险"等原创氛围设计。 +- 文字规则:英文短句可加可不加,与印花适配即可;任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物姓名等敏感内容。 diff --git a/prompts/GB/aesthetics.yaml b/prompts/GB/aesthetics.yaml new file mode 100644 index 0000000..01ede4b --- /dev/null +++ b/prompts/GB/aesthetics.yaml @@ -0,0 +1,96 @@ +# 英国审美与风格-配色规则(prompts/GB/aesthetics.yaml) +style_hint: "Witty, self-deprecating British humor; punk / bold lettering; tea-and-rain mood. Prefer: Sarcasm, Punk, British Humor, Royal, Tea." + +extra_style_rules: + - keywords: [royal, queen, britain, union jack, london] + art_style: "regal british emblem" + color_palette: "royal blue, crimson, gold, cream" + - keywords: [tea, afternoon tea, scone, biscuit, cuppa] + art_style: "cosy british tea illustration" + color_palette: "creamy white, sage, berry red, gold" + - keywords: [london, big ben, double decker, underground, tube, bridge] + art_style: "london cityscape line art" + color_palette: "oxblood red, navy, charcoal, cream" + - keywords: [crown, coronation, king, queen, prince, royal guard] + art_style: "regal crown emblem" + color_palette: "royal purple, gold, crimson, cream" + - keywords: [cricket, test match, ashes, wicket] + art_style: "classic cricket emblem" + color_palette: "deep green, cream, maroon, gold" + - keywords: [pub, ale, beer, lager, pint] + art_style: "traditional pub sign style" + color_palette: "dark amber, cream, forest green, gold" + - keywords: [punk, gothic, alternative, grunge, rock] + art_style: "punk rock poster style" + color_palette: "black, white, acid green, magenta" + - keywords: [countryside, cottage, garden, village, moors] + art_style: "english countryside illustration" + color_palette: "meadow green, cream, terracotta, sky blue" + - keywords: [bonfire night, guy fawkes, fireworks night] + art_style: "bonfire night emblem" + color_palette: "ember orange, charcoal, gold" + +# 英国常见侵权盲区(按国家隔离,追加到全局黑名单) +extra_blacklist: + - fortnite # Epic Games 游戏商标 + - wwe # 摔角联盟商标 + - cm punk # 真人摔角手 + WWE 角色名 + - kevin owens # 真人摔角手 + - skull and bones # 育碧(Ubisoft)游戏商标 + - daft punk # 乐队名(受版权保护) + - "m&s" # Marks & Spencer 零售商标 + - manchester united # 英超俱乐部 + - liverpool + - arsenal + - chelsea + - manchester city + - tottenham + - newcastle united + - premier league # 英超联盟商标 + - the beatles # 乐队名 + - rolling stones + - led zeppelin + - pink floyd + - bbc # 英国广播公司商标 + - sky sports + - tesco + - asda + # —— 扩充:王室成员(肖像权/法律保护)—— + - king charles + - queen elizabeth + - prince william + - prince harry + - meghan markle + - royal family + # —— 扩充:政界人物 —— + - rishi sunak + - keir starmer + - boris johnson + - theresa may + # —— 扩充:更多足球俱乐部 —— + - celtic + - rangers + - leeds united + - west ham + - aston villa + - everton + # —— 扩充:名人/虚构角色 —— + - adele + - ed sheeran + - harry styles + - david beckham + - james bond + - sherlock + - doctor who + - downton abbey + - harry potter + # —— 扩充:品牌零售 —— + - sainsbury's + - morrisons + - boots + - john lewis + - primark + - argos + - british airways + - virgin + - royal mail diff --git a/prompts/GB/system_prompt.md b/prompts/GB/system_prompt.md new file mode 100644 index 0000000..2262452 --- /dev/null +++ b/prompts/GB/system_prompt.md @@ -0,0 +1,5 @@ +# 英国专属补充指令 +- 设计口吻:英式自嘲幽默、朋克精神、茶与雨的氛围,粗体字标(zine 风)。 +- 合规重点:避免皇室成员真实肖像、现役政客、足球俱乐部真实队徽与名称、WWE/摔角手真实姓名、游戏与乐队商标(Fortnite / Skull and Bones / Daft Punk 等)。 +- 可做"英伦复古旅行 / 伦敦街景 / 英式讽刺梗"等原创氛围,避免任何真实商标与受保护 IP。 +- 文字规则:英式短句/标语可加可不加,与印花适配即可;任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物姓名等敏感内容。 diff --git a/prompts/JP/aesthetics.yaml b/prompts/JP/aesthetics.yaml new file mode 100644 index 0000000..700e82f --- /dev/null +++ b/prompts/JP/aesthetics.yaml @@ -0,0 +1,140 @@ +# 日本审美与风格-配色规则(prompts/JP/aesthetics.yaml) +style_hint: "Kawaii / minimalist / anime-inspired; clean lines, Tokyo street edge, original kanji accents. Prefer: Kawaii, Minimalist, Ukiyo-e, Sakura, Yokai, Tokyo Neon, Zen." + +extra_style_rules: + - keywords: [kanji, tokyo, samurai, anime, manga] + art_style: "japanese pop art illustration" + color_palette: "indigo, vermilion, cream, ink black" + - keywords: [kawaii, cute, mascot, sanrio style, chibi] + art_style: "kawaii mascot illustration" + color_palette: "baby pink, mint, cream, soft lavender" + - keywords: [sakura, cherry blossom, hanami, flower] + art_style: "delicate cherry blossom watercolor illustration" + color_palette: "sakura pink, white, pale green, soft gray" + - keywords: [ukiyo-e, wave, woodblock, great wave] + art_style: "ukiyo-e woodblock wave style" + color_palette: "indigo blue, white, vermilion, ink black" + - keywords: [torii, shrine, temple, japan travel] + art_style: "torii gate silhouette illustration" + color_palette: "vermilion red, ink black, gold, cream" + - keywords: [yokai, oni, kitsune, fox, ghost, tengu] + art_style: "original yokai folklore illustration" + color_palette: "indigo, vermilion, ink black, gold" + - keywords: [sushi, ramen, onigiri, bento, udon, mochi, matcha] + art_style: "cute japanese food illustration" + color_palette: "cream white, coral, matcha green, soy black" + - keywords: [maneki neko, lucky cat, daruma, good luck] + art_style: "maneki-neko lucky charm illustration" + color_palette: "gold, red, black, white" + - keywords: [shiba, corgi, inu, cat, neko, pet] + art_style: "healing japanese pet illustration" + color_palette: "warm brown, cream, caramel, soft gray" + - keywords: [fuji, mount fuji, sunrise, mountain] + art_style: "minimalist mount fuji sunrise illustration" + color_palette: "vermilion, indigo, white, pale blue" + - keywords: [crane, tsuru, koi, fish, phoenix] + art_style: "elegant japanese crane and koi motif" + color_palette: "gold, indigo, vermilion, ink black" + - keywords: [kimono, obi, washi, textile, yukata] + art_style: "kimono textile pattern style" + color_palette: "indigo, white, gold, vermilion" + - keywords: [origami, paper, geometric] + art_style: "origami geometric paper art" + color_palette: "soft pink, sky blue, paper white, gold" + - keywords: [zen, bonsai, garden, rock, minimalist] + art_style: "zen garden minimalist illustration" + color_palette: "stone gray, sand beige, moss green, ink black" + - keywords: [neon, tokyo night, akihabara, street, cyber] + art_style: "tokyo neon night street illustration" + color_palette: "neon pink, electric blue, black, cyan" + - keywords: [matsuri, festival, fireworks, hanabi, summer] + art_style: "summer matsuri fireworks illustration" + color_palette: "firework gold, indigo, vermilion, white" + - keywords: [harajuku, street fashion, j-fashion, gyaru, lolita] + art_style: "harajuku street fashion illustration" + color_palette: "pink, purple, black, white" + - keywords: [shibori, indigo dye, tie dye] + art_style: "shibori indigo dye pattern" + color_palette: "indigo blue, white, pale blue" + - keywords: [setsubun, tanabata, star festival, seasonal] + art_style: "japanese seasonal festival illustration" + color_palette: "purple, gold, white, indigo" + - keywords: [onsen, hot spring, bath, sento, towel] + art_style: "onsen relaxation illustration" + color_palette: "steam white, warm brown, indigo, coral" + - keywords: [snow monkey, snow, winter, yuki] + art_style: "cozy japanese winter illustration" + color_palette: "frost blue, white, charcoal, vermilion" + - keywords: [wagashi, dessert, sweet, daifuku, dango, taiyaki] + art_style: "japanese sweet dessert illustration" + color_palette: "matcha green, cream, pink, red bean brown" + - keywords: [konbini, convenience, late night, snack] + art_style: "konbini night snack illustration" + color_palette: "midnight blue, warm yellow, white, coral" + - keywords: [sumo, rikishi, dohyo, tournament] + art_style: "sumo wrestler illustration" + color_palette: "vermilion, white, ink black, gold" + +# 日本常见侵权盲区(按国家隔离) +extra_blacklist: + - pokemon + - naruto + - "one piece" + - "dragon ball" + - hello kitty + - sanrio + - jujutsu kaisen # 咒术回战 + - "demon slayer" # 鬼灭之刃 + - "attack on titan" # 进击的巨人 + - "my hero academia" + - "sailor moon" + - digimon + - yu-gi-oh + - "super mario" + - zelda + - "studio ghibli" + - totoro + - "spirited away" + - kiki + - "evangelion" + - gundam + - kirby + - "animal crossing" + - "splatoon" + - vocaloid + - hatsune miku + - "final fantasy" + - kingdom hearts + # —— 扩充:更多动漫/游戏 IP —— + - "death note" + - "one punch man" + - "chainsaw man" + - "spy x family" + - "street fighter" + - "monster hunter" + - "resident evil" + - "metal gear" + - "castlevania" + - "my melody" + - kuromi + - cinnamoroll + - "pompompurin" + # —— 扩充:偶像/艺人(肖像权)—— + - akb48 + - arashi + - bts + - "utada hikaru" + - "yonezu kenshi" + # —— 扩充:政治/皇室 —— + - emperor + - naruhito + - akihito + - shinzo abe + # —— 扩充:日本大牌零售 —— + - uniqlo + - muji + - daiso + - "7-eleven" + - "family mart" + - lawson + - nitori diff --git a/prompts/JP/system_prompt.md b/prompts/JP/system_prompt.md new file mode 100644 index 0000000..e4d2e63 --- /dev/null +++ b/prompts/JP/system_prompt.md @@ -0,0 +1,5 @@ +# 日本专属补充指令 +- 设计口吻:可爱(kawaii) / 极简 / 动漫感,干净线条,可点缀原创汉字。 +- 合规重点:严禁使用知名动漫 IP 与角色(宝可梦 / 火影 / 海贼王 / 龙珠 / Hello Kitty / Sanrio 等)、真实艺人肖像。 +- 鼓励做"原创可爱角色 / 极简植物 / 原创汉字标语"等自有元素,避免任何受保护角色与商标。 +- 文字规则:英文或原创汉字可加可不加,与印花适配即可;任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物姓名等敏感内容。 diff --git a/prompts/MX/aesthetics.yaml b/prompts/MX/aesthetics.yaml new file mode 100644 index 0000000..c8030bd --- /dev/null +++ b/prompts/MX/aesthetics.yaml @@ -0,0 +1,96 @@ +# 墨西哥审美与风格-配色规则(prompts/MX/aesthetics.yaml) +style_hint: "Vibrant Mexican folk art; Day of the Dead / calavera / Loteria / Aztec / Talavera / Papel Picado motifs; fiesta colors; cultural pride. Prefer: Sugar Skull, Mexican Folk Art, Loteria, Aztec, Talavera, Alebrije, Chicano, Papel Picado, Fiesta." + +extra_style_rules: + - keywords: [day of the dead, dia de los muertos, calavera, skull, catrina, halloween] + art_style: "vibrant sugar-skull folk art illustration" + color_palette: "marigold orange, magenta, deep purple, black, gold" + - keywords: [cempasuchil, marigold, flower, floral, wreath] + art_style: "marigold flower wreath folk illustration" + color_palette: "marigold orange, golden yellow, leaf green, cream" + - keywords: [pan de muerto, bread of the dead] + art_style: "sweet folk illustration of day-of-the-dead bread" + color_palette: "golden brown, cream, pink, white" + - keywords: [loteria, mexican card, bingo] + art_style: "retro loteria card illustration with bold frame" + color_palette: "cream, crimson, teal, gold" + - keywords: [aztec, mayan, mexico, aguila, eagle, calendar, sun] + art_style: "aztec geometric pattern emblem" + color_palette: "jade green, terracotta, obsidian black, gold" + - keywords: [quetzalcoatl, feathered serpent, jaguar, ocelot, mesoamerican, toltec] + art_style: "stylized mesoamerican geometric art" + color_palette: "jade green, turquoise, gold, obsidian black" + - keywords: [mariposa monarca, monarch butterfly, butterfly] + art_style: "delicate monarch butterfly folk pattern" + color_palette: "burnt orange, black, white, gold" + - keywords: [talavera, ceramic, pottery, tile, azulejo] + art_style: "talavera ceramic pattern style" + color_palette: "cobalt blue, white, yellow, green" + - keywords: [papel picado, cut paper, banner, fiesta flag, bunting] + art_style: "colorful papel picado cut-paper banner style" + color_palette: "hot pink, turquoise, lime green, orange, purple" + - keywords: [cactus, sombrero, taco, avocado, chili, tamale, burrito, enchilada] + art_style: "playful colorful mexican food icon illustration" + color_palette: "cactus green, chili red, corn yellow, avocado green" + - keywords: [lucha libre, wrestling, mask, luchador] + art_style: "bold retro lucha libre poster graphic" + color_palette: "bright red, electric blue, gold, black" + - keywords: [mariachi, guitar, music, banda, corrido] + art_style: "folk music flat illustration" + color_palette: "teal, coral, cream, black" + - keywords: [charro, charreria, cowboy, horse, rancho] + art_style: "bold charro cowboy folk illustration" + color_palette: "black, silver, red, gold" + - keywords: [alebrije, oaxaca, mythical creature] + art_style: "whimsical oaxacan alebrije folk art" + color_palette: "fuchsia, turquoise, hot orange, cream" + - keywords: [chicano, lowrider, fiesta, quinceanera, vintage mexico] + art_style: "colorful chicano lowrider folk art" + color_palette: "fuchsia, turquoise, hot orange, cream" + - keywords: [independencia, flag, patriotic, viva mexico, september] + art_style: "patriotic mexican flag color-block emblem" + color_palette: "green, white, red, gold" + - keywords: [navidad, christmas, pinata, posada] + art_style: "colorful mexican christmas pinata illustration" + color_palette: "green, red, gold, white" + - keywords: [retro mexico, vintage poster, 1950s, travel poster] + art_style: "retro 1950s mexican travel poster" + color_palette: "teal, cream, terracotta, black" + - keywords: [xolo, perro, dog, mexican hairless] + art_style: "cute xoloitzcuintle dog illustration" + color_palette: "black, pink, turquoise" + - keywords: [cenote, beach, caribbean, riviera maya, sunset] + art_style: "tropical cenote beach flat illustration" + color_palette: "turquoise, sand, coral, white" + +# 墨西哥常见侵权盲区(按国家隔离,追加到全局黑名单) +extra_blacklist: + - frida kahlo # Frida Kahlo Corporation 商标(服装类注册,激进下架) + - frida # FKC 注册商标之一 + - diego rivera # 真人画家 + 遗产商标 + - fifa # 国际足联商标 + - world cup 2026 # FIFA 注册商标 + - wc26 # FIFA 商标变体 + - el tri # 墨西哥国家队商标/昵称 + - mexican national team + - club america # Liga MX 俱乐部 + - chivas + - tigres + - cruz azul + - pumas + - america de mexico + - el chapo # 毒枭真实人物,绝对禁止 + - narco + - cartel + - coco # 迪士尼《寻梦环游记》IP + - speedy gonzales # 华纳角色 + - virgen de guadalupe # 宗教形象,避免商用 + - guadalupe + - peso pluma # 真人歌手 + - bad bunny # 真人歌手 + - j balvin # 真人歌手 + - karol g # 真人歌手 + - selena quintanilla # 真人歌手 + - luis miguel # 真人歌手 + - canelo alvarez # 真人拳击手 + - escudo nacional # 墨西哥国徽受法律保护 diff --git a/prompts/MX/system_prompt.md b/prompts/MX/system_prompt.md new file mode 100644 index 0000000..9d4465c --- /dev/null +++ b/prompts/MX/system_prompt.md @@ -0,0 +1,11 @@ +# 墨西哥专属补充指令 +- 设计口吻:墨西哥民间艺术(folk art)、亡灵节(Día de los Muertos)、糖骷髅(calavera/catrina)、Loteria 卡牌、阿兹特克几何图案、Chicano 复古;鲜艳节日配色(红/绿/白、品红、橙、金),家庭与节日氛围。 +- 合规重点(墨西哥特有): + * Frida Kahlo / Diego Rivera:Frida Kahlo Corporation 持有 "Frida Kahlo"/"Frida"/"FK" 服装类商标且激进下架——任何文字与肖像一律禁止。 + * FIFA 2026 世界杯(墨西哥为东道国):官方名称/logo/吉祥物/口号/队徽全部受保护;国家名、城市名、国旗配色、通用足球元素(球/球靴/球迷文化短语)安全。 + * Liga MX 俱乐部(Club América / Chivas / Tigres / Cruz Azul / Pumas 等)队徽与名称禁止。 + * 真实人物:西语艺人(Peso Pluma / Bad Bunny / J Balvin / Karol G / Selena / Luis Miguel)、拳手(Canelo)、政客——肖像权。 + * 毒枭/毒品文化(El Chapo / narco / cartel)绝对禁止,涉及犯罪美化。 + * 宗教形象(瓜达卢佩圣母 Virgen de Guadalupe)避免商用;墨西哥国徽受法律保护。 +- 可做"亡灵节氛围 / 原创糖骷髅 / Loteria 风格卡牌 / 阿兹特克纹样 / 墨西哥美食与仙人掌 / 复古墨西哥海报 / Chicano 文化氛围"等原创设计,避免任何真实商标与受保护 IP。 +- 文字规则:英文或西班牙语短句(如 ¡Viva México!)可加可不加,与印花适配即可;任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物姓名等敏感内容。 diff --git a/prompts/US/aesthetics.yaml b/prompts/US/aesthetics.yaml new file mode 100644 index 0000000..cedd95c --- /dev/null +++ b/prompts/US/aesthetics.yaml @@ -0,0 +1,103 @@ +# 美国审美与风格-配色规则(prompts/US/aesthetics.yaml) +# 该文件被 graph/loader.build_country_config 读取,合并进 country_config。 +style_hint: "Bold vintage / retro Americana, humorous and punchy, high-contrast poster style. Prefer: Vintage, Funny, Patriotic, Sports, Retro." + +# 国家专属风格-配色优先级规则(最具体,优先于全局规则) +extra_style_rules: + - keywords: [americana, usa, patriot, freedom, flag] + art_style: "bold patriotic emblem" + color_palette: "navy, red, cream, gold" + - keywords: [halloween, pumpkin, trick or treat, spooky] + art_style: "playful halloween illustration" + color_palette: "orange, black, purple, lime green" + - keywords: [christmas, santa, xmas, holiday season] + art_style: "festive christmas emblem" + color_palette: "holly red, pine green, gold, cream" + - keywords: [barbecue, bbq, smoker, grill, tailgate] + art_style: "retro barbecue badge" + color_palette: "charred brown, ketchup red, mustard yellow, cream" + - keywords: [camping, hike, national park, outdoors, adventure] + art_style: "rustic adventure badge" + color_palette: "forest green, slate, cream, burnt orange" + - keywords: [vintage sports, retro team, classic jersey, baseball] + art_style: "vintage sports badge" + color_palette: "faded red, cream, navy, gold" + - keywords: [military, veteran, army, marine, navy, air force] + art_style: "veteran tribute emblem" + color_palette: "olive, khaki, navy, gold" + - keywords: [july fourth, independence, fireworks, star spangled] + art_style: "independence day emblem" + color_palette: "patriotic red, white, royal blue" + - keywords: [cowboy, western, texas, rodeo, country] + art_style: "western cowboy badge" + color_palette: "brown, cream, oxblood red, mustard" + +# 美国常见侵权盲区(追加到全局黑名单,按国家隔离) +extra_blacklist: + - super bowl # NFL 超级碗(注册商标) + - dallas cowboys # NFL 球队 + - new york yankees # MLB 球队 + - los angeles lakers # NBA 球队 + - golden state warriors + - new england patriots + - kansas city chiefs + - wendy's # 快餐商标 + - burger king + - taco bell + - chick-fil-a + - under armour # 运动品牌 + - new balance + - amazon + - tesla + - google + # —— 扩充:政治人物(形象/肖像权)—— + - donald trump + - joe biden + - obama + - kamala harris + # —— 扩充:更多职业球队 —— + - boston celtics + - chicago bulls + - miami dolphins + - green bay packers + - philadelphia eagles + - san francisco 49ers + - denver broncos + - pittsburgh steelers + - seattle seahawks + - las vegas raiders + - los angeles dodgers + - boston red sox + - chicago cubs + - atlanta braves + - houston astros + # —— 扩充:大学校队 —— + - notre dame + - alabama crimson tide + - ohio state + - michigan wolverines + # —— 扩充:名人 —— + - taylor swift + - beyonce + - drake + - elon musk + # —— 扩充:品牌/IP —— + - nike + - adidas + - mcdonald's + - coca-cola + - pepsi + - starbucks + - apple + - microsoft + - netflix + - star wars + - lego + - barbie + - minecraft + - harry potter + - batman + - superman + - walmart + - costco + - target diff --git a/prompts/US/system_prompt.md b/prompts/US/system_prompt.md new file mode 100644 index 0000000..aee3a12 --- /dev/null +++ b/prompts/US/system_prompt.md @@ -0,0 +1,5 @@ +# 美国专属补充指令 +- 设计口吻:美式复古 / 爱国 / 幽默夸张,海报式高对比。 +- 合规重点:避免真实人物(明星/政客/运动员肖像权)、现役职业球队商标与联赛 logo(NFL/NBA/MLB/NHL)、迪士尼/漫威等版权 IP。 +- 即使是"致敬",也不要照搬受保护元素,提取安全的"氛围"(如音乐节、公路旅行、复古运动)做原创设计。 +- 文字规则:英文可加可不加,与印花适配即可;任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物姓名等敏感内容。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2a5d66b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "pod-trend-agent" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "pytrends>=4.7.1", + "pyyaml>=6.0", + "requests>=2.31.0", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cf254c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# POD 热点抓取 Agent (LangGraph 工程化版) 依赖 +langgraph>=0.2 +pytrends +requests +urllib3 +pyyaml diff --git a/scripts/gen_mx_prompts.py b/scripts/gen_mx_prompts.py new file mode 100644 index 0000000..ea60aff --- /dev/null +++ b/scripts/gen_mx_prompts.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""用墨西哥主题简报装配 MX 提示词产物(Google Trends 429 限流期间离线验证模板效果)。 + +构造 5 个墨西哥风格 safe 简报 → prompt_node 用 MX 模板装配 image_prompt → +输出 output/MX/pure_print_prompts.{json,md}(结构同 regen 脚本)。 +""" +import json +import sys +import datetime +from pathlib import Path + +BASE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE)) + +import yaml +from graph.nodes.prompt_node import prompt_node + +MX_BRIEFS = [ + {"topic": "dia de los muertos catrina", "design_category": "Style", + "motif": "elegant catrina sugar skull with marigold flower crown", + "art_style": "vibrant sugar-skull folk art illustration", + "color_palette": "marigold orange, magenta, deep purple, black, gold", + "composition": "centered portrait emblem with floral frame", "risk_level": "safe"}, + {"topic": "loteria mexicana cards", "design_category": "Niche", + "motif": "retro loteria card with el corazon symbol", + "art_style": "retro loteria card illustration with bold frame", + "color_palette": "cream, crimson, teal, gold", + "composition": "card-style centered layout with banner", "risk_level": "safe"}, + {"topic": "aztec calendar sun", "design_category": "Pattern", + "motif": "aztec sun calendar geometric emblem", + "art_style": "aztec geometric pattern emblem", + "color_palette": "jade green, terracotta, obsidian black, gold", + "composition": "concentric circular sun emblem", "risk_level": "safe"}, + {"topic": "taco fiesta food", "design_category": "Niche", + "motif": "happy taco with avocado and chili peppers", + "art_style": "playful colorful mexican food icon illustration", + "color_palette": "cactus green, chili red, corn yellow, avocado green", + "composition": "centered food icon with border", "risk_level": "safe"}, + {"topic": "lucha libre luchador mask", "design_category": "Style", + "motif": "luchador wrestler mask with stars", + "art_style": "bold retro lucha libre poster graphic", + "color_palette": "bright red, electric blue, gold, black", + "composition": "centered mask emblem with rays", "risk_level": "safe"}, + {"topic": "talavera ceramic tile", "design_category": "Pattern", + "motif": "talavera blue ceramic tile floral motif", + "art_style": "talavera ceramic pattern style", + "color_palette": "cobalt blue, white, yellow, green", + "composition": "repeating tile pattern with central medallion", "risk_level": "safe"}, + {"topic": "monarch butterfly migration", "design_category": "Style", + "motif": "monarch butterfly among marigold flowers", + "art_style": "delicate monarch butterfly folk pattern", + "color_palette": "burnt orange, black, white, gold", + "composition": "centered butterfly with floral border", "risk_level": "safe"}, + {"topic": "charro horse rider", "design_category": "Style", + "motif": "charro cowboy with sombrero on horseback", + "art_style": "bold charro cowboy folk illustration", + "color_palette": "black, silver, red, gold", + "composition": "centered rider emblem with banner", "risk_level": "safe"}, +] + +config = yaml.safe_load(open(BASE / "config.yaml", encoding="utf-8")) +mx_cfg = yaml.safe_load(open(BASE / "configs" / "countries" / "MX.yaml", encoding="utf-8")) + +state = { + "country": "MX", "config": config, "country_config": mx_cfg, + "screened": MX_BRIEFS, "stats": {}, +} +out = prompt_node(state) +briefs = out["briefs"] + +out_dir = BASE / "output" / "MX" +out_dir.mkdir(parents=True, exist_ok=True) +result = { + "country": "MX", "country_name": "墨西哥", + "template": "pure_print_15x18cm_to_26x32cm_v3", + "template_source": "config.yaml prompt_templates.countries.MX", + "generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "total": len(briefs), "safe": len(briefs), "review": 0, + "note": "离线演示产物(Google Trends 429 限流期间用墨西哥主题简报装配);正式数据需等限流恢复后跑流水线", + "items": [ + {"topic": b["topic"], "category": b.get("design_category"), "verdict": "safe", + "motif": b["motif"], "art_style": b["art_style"], + "color_palette": b["color_palette"], "composition": b["composition"], + "pure_print_prompt": b["image_prompt"]} + for b in briefs + ], +} +(out_dir / "pure_print_prompts.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + +lines = [ + "# 墨西哥(MX)纯印花设计提示词产物(15×18cm ~ 26×32cm · v3 国家化模板)\n", + f"- 生成时间:{result['generated_at']}", + "- 模板来源:config.yaml → prompt_templates.countries.MX", + f"- 说明:{result['note']}", + "- 规则:尺寸 **约 15×18cm ~ 26×32cm 自由选择**;英文/西语短标语可加可不加;**任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物(含 Frida Kahlo)/毒枭等敏感内容**\n", +] +for i, it in enumerate(result["items"], 1): + lines.append(f"## {i}. {it['topic']} [{it['category']}] ✅ 可直接用\n") + lines.append(f"- **motif**:{it['motif']}") + lines.append(f"- **art_style**:{it['art_style']}") + lines.append(f"- **color_palette**:{it['color_palette']}") + lines.append(f"- **composition**:{it['composition']}\n") + lines.append("```text") + lines.append(it["pure_print_prompt"]) + lines.append("```\n") +(out_dir / "pure_print_prompts.md").write_text("\n".join(lines), encoding="utf-8") + +print(f"[MX] 产物生成完成:{len(briefs)} 条") +for b in briefs: + print(f" {b['topic']} | MX-market={'MX-market' in b['image_prompt']} | Spanish={'Spanish slogan' in b['image_prompt']}") diff --git a/scripts/regen_pure_print_prompts.py b/scripts/regen_pure_print_prompts.py new file mode 100644 index 0000000..55d7adf --- /dev/null +++ b/scripts/regen_pure_print_prompts.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +"""用 v3 国家化纯印花模板重新生成各国提示词产物。 + +- 模板来源:config.yaml 的 prompt_templates(countries. 按国家覆盖,resolve_templates 解析), + 与流水线 prompt_node 完全一致,保证产物=实际生图提示词。 +- 数据源优先 output//llm_verified_prompts.json(含 verdict), + 否则 output//design_briefs.json(risk_level 全 safe 时视作 verdict=safe)。 +- 输出:output//pure_print_prompts.{json,md} +""" +import argparse +import datetime +import json +import os +import sys + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, BASE) + +from graph.templates import resolve_templates # noqa: E402 + +COUNTRY_NAMES = {"US": "美国", "GB": "英国", "JP": "日本", "AU": "澳大利亚"} +STATUS = {"safe": "✅ 可直接用", "review": "⚠️ 需人工复核", "blocked": "🚫 已拦截"} + + +def load_briefs(country: str): + """优先 llm_verified_prompts.json,否则 design_briefs.json。""" + verified = os.path.join(BASE, "output", country, "llm_verified_prompts.json") + briefs_path = os.path.join(BASE, "output", country, "design_briefs.json") + if os.path.exists(verified): + with open(verified, "r", encoding="utf-8") as f: + data = json.load(f) + items = data.get("design_briefs", []) + # 归一化 verdict 字段名 + for b in items: + if "verdict" not in b and "risk_level" in b: + b["verdict"] = b["risk_level"] + return [b for b in items if b.get("motif")] + if os.path.exists(briefs_path): + with open(briefs_path, "r", encoding="utf-8") as f: + data = json.load(f) + items = data.get("design_briefs", []) + for b in items: + if "verdict" not in b: + b["verdict"] = b.get("risk_level") or ("safe" if b.get("safe_for_print") else "review") + return [b for b in items if b.get("motif")] + return [] + + +def gen_country(country: str): + briefs = load_briefs(country) + if not briefs: + print(f"[{country}] 无简报数据(output/{country}/llm_verified_prompts.json 或 design_briefs.json 缺失/无 motif),跳过") + return False + + with open(os.path.join(BASE, "config.yaml"), "r", encoding="utf-8") as f: + import yaml + tpls = yaml.safe_load(f).get("prompt_templates") or {} + t = resolve_templates(tpls, country) + image_tpl = t["image_prompt"] + + out_items = [] + for b in briefs: + out_items.append({ + "topic": b.get("topic"), + "category": b.get("design_category") or b.get("category"), + "score": round(float(b.get("score") or 0), 3), + "verdict": b.get("verdict"), + "compliance_note": b.get("compliance_note") or b.get("concept") or b.get("risk_reasons"), + "motif": b.get("motif"), + "art_style": b.get("art_style"), + "color_palette": b.get("color_palette"), + "composition": b.get("composition"), + "pure_print_prompt": image_tpl.format( + motif=b.get("motif"), art_style=b.get("art_style"), + color_palette=b.get("color_palette"), composition=b.get("composition")), + }) + + result = { + "country": country, + "country_name": COUNTRY_NAMES.get(country, country), + "template": "pure_print_15x18cm_to_26x32cm_v3", + "template_source": "config.yaml prompt_templates.countries." + country, + "generated_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "total": len(out_items), + "safe": sum(1 for i in out_items if i["verdict"] == "safe"), + "review": sum(1 for i in out_items if i["verdict"] in ("review", "blocked")), + "items": out_items, + } + + out_dir = os.path.join(BASE, "output", country) + os.makedirs(out_dir, exist_ok=True) + json_path = os.path.join(out_dir, "pure_print_prompts.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + + lines = [ + f"# {result['country_name']}({country})纯印花设计提示词产物(15×18cm ~ 26×32cm · v3 国家化模板)\n", + f"- 生成时间:{result['generated_at']}", + f"- 模板版本:`pure_print_15x18cm_to_26x32cm_v3`,来源:config.yaml → prompt_templates.countries.{country}", + "- 规则:尺寸在**约 15×18cm ~ 26×32cm 区间内自由选择**(防默认出满幅大图,不拉伸、不铺满、留边距);英文**可加可不加**,与印花适配即可;**任何文字严禁政治/宗教/仇恨/暴力/性/品牌/商标/真实人物等敏感内容**", + f"- 总数 {result['total']} 条:✅ safe {result['safe']} 条(可直接生成) / ⚠️ review {result['review']} 条(需人工复核)\n", + ] + for i, it in enumerate(result["items"], 1): + verdict = it["verdict"] + tag = STATUS.get(verdict, verdict) + lines.append(f"## {i}. {it['topic']} [{it['category'] or 'N/A'}] (score {it['score']}) {tag}\n") + note = it["compliance_note"] + lines.append(f"- **合规/说明**:{note if note else '(无)'}") + lines.append(f"- **motif**:{it['motif']}") + lines.append(f"- **art_style**:{it['art_style']}") + lines.append(f"- **color_palette**:{it['color_palette']}") + lines.append(f"- **composition**:{it['composition']}\n") + lines.append("```text") + lines.append(it["pure_print_prompt"]) + lines.append("```\n") + + md_path = os.path.join(out_dir, "pure_print_prompts.md") + with open(md_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + # 校验 + with open(json_path, "r", encoding="utf-8") as f: + chk = json.load(f) + bad = [i["topic"] for i in chk["items"] if "choose freely between a MINIMUM print area of about 15x18 cm" not in i["pure_print_prompt"]] + print(f"[{country}] 生成完成: {len(chk['items'])} 条 (safe {chk['safe']} / review {chk['review']}) | " + f"新模板未命中: {bad or '无'} | 旧模板残留: {sum(1 for i in chk['items'] if 'designed to fit a maximum print area' in i['pure_print_prompt'])}") + return True + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("-c", "--country", default="all", + help="国家代码 US/GB/JP/AU,或 all(默认,处理所有有数据的国家)") + args = ap.parse_args() + + if args.country == "all": + countries = [c for c in ("US", "GB", "JP", "AU")] + else: + countries = [args.country.upper()] + + for cc in countries: + try: + gen_country(cc) + except Exception as e: # noqa: BLE001 + print(f"[{cc}] 失败: {e}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_jp_flow.py b/scripts/test_jp_flow.py new file mode 100644 index 0000000..04bf4c1 --- /dev/null +++ b/scripts/test_jp_flow.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +"""JP 流程测试:JPTM001 黑色 × 2 个产品(缓存热点模式)。 + +步骤: +1) 用 mock 种子(6 style + 6 related)生成 JP 主题简报(JP-market 模板装配)→ 写入 output/JP/design_briefs.json 作为缓存 +2) run_product_batch:tasks=[JPTM001-BL01 × 2],count=2 → 2 个产品各绑一个热点 +3) 验证:选色/热点分配/used 去重/JP 标题模板路由/模板导出 +""" +import json +import sys +import time +from pathlib import Path + +BASE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE)) + +import yaml +from graph.templates import assemble_prompts + +config = yaml.safe_load(open(BASE / "config.yaml", encoding="utf-8")) +# 种子词 6 个 +config["seed_provider"] = "mock" +cfg = config.setdefault("seed_provider_cfg", {}) +cfg["max_style_seeds"] = 6 +cfg["max_related_seeds"] = 6 +# 图像后端 mock、不上传图床 +config["compose"]["backend"] = "mock" +config["product"]["backend"] = "mock" +config["oss"]["enabled"] = False +config["seed_shot"]["count"] = 1 + +jp_topics = [ + ("kawaii cat cafe", "Style", "original kawaii cat with latte art", "kawaii style, soft rounded shapes", "pink, cream, caramel", "centered mascot"), + ("tokyo neon night", "Style", "original neon street lamps silhouette", "retro cyberpunk flat vector", "magenta, cyan, black", "symmetrical emblem"), + ("shiba inu summer", "Style", "original shiba inu with watermelon slice", "cute japanese mascot illustration", "orange, red, white", "centered"), + ("sakura spring", "Style", "original cherry blossom branch with petals", "soft minimal japanese print", "sakura pink, white, mint", "corner flourish"), + ("osaka takoyaki food", "Style", "original takoyaki balls with bonito flakes", "playful food illustration", "golden brown, red, green", "centered badge"), + ("mt fuji sunrise", "Style", "original mt fuji with rising sun rays", "ukiyo-e inspired flat design", "indigo, red, cream", "symmetrical"), +] +tpls = config.get("prompt_templates") or {} +briefs = [] +for topic, cat, motif, style, palette, comp in jp_topics: + prompts = assemble_prompts(motif, style, palette, comp, tpls, "JP") + briefs.append({ + "country": "JP", "topic": topic, "design_category": cat, "risk_level": "safe", + "score": 80 - len(briefs) * 3, "motif": motif, "art_style": style, + "color_palette": palette, "composition": comp, + "image_prompt": prompts["image_prompt"], + "composite_prompt": prompts["composite_prompt"], + "composite_negative": prompts["composite_negative"], + }) + +out_dir = BASE / "output" / "JP" +out_dir.mkdir(parents=True, exist_ok=True) +(out_dir / "design_briefs.json").write_text( + json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "total": len(briefs), + "design_briefs": briefs}, ensure_ascii=False, indent=2), encoding="utf-8") +print(f"[setup] JP 缓存热点已写入: {len(briefs)} 条(mock 种子 6/6)") +p0 = briefs[0]["image_prompt"] +print("[setup] 模板校验: JP-market 风格段 =", "JP-market" in p0, "| 尺寸上限 26x32 =", "26x32" in p0) + +from graph.product_batch import run_product_batch + +tasks = [{"spu": "JPTM001", "skus": "JPTM001-BL01"}, # 黑色 + {"spu": "JPTM001", "skus": "JPTM001-BL01"}] # 数量 2:同一款号生成 2 个产品 +out = run_product_batch("JP", config, BASE, BASE, tasks, 2, log_q=None) +print("\n=== 测试结果 ===") +prods = out.get("product") or [] +print("产品数:", len(prods)) +for r in prods: + print(f" {r.get('spu_code')} | {r.get('sku_code')} | 色={r.get('color')} | topic={r.get('topic')} | " + f"template={bool(r.get('template_path'))} | design={bool(r.get('design_path'))}") +print("errors:", [e.get("message", "")[:80] for e in out.get("errors") or []]) + +used = json.loads((out_dir / "used_designs.json").read_text(encoding="utf-8")).get("used", []) if (out_dir / "used_designs.json").exists() else [] +print("used_designs 记录:", len(used), "条 →", [u.get("topic") for u in used]) diff --git a/scripts/test_real_jp.py b/scripts/test_real_jp.py new file mode 100644 index 0000000..a9ef3c3 --- /dev/null +++ b/scripts/test_real_jp.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""真实 API 全链路验证:JP JPTM001 黑色 × 1(oss 真上传)。""" +import json +import sys +from pathlib import Path + +BASE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE)) + +import yaml +from graph.product_batch import run_product_batch + +config = yaml.safe_load(open(BASE / "dist_v2" / "config.yaml", encoding="utf-8")) +config["seed_provider"] = "openai" +config["llm_screen"]["provider"] = "openai" +config["compose"]["backend"] = "openai" +config["product"]["backend"] = "openai" +config["oss"]["enabled"] = True +config["seed_shot"]["count"] = 1 +print("使用配置: llm_model =", config["llm_screen"].get("model"), "| img_model =", config["compose"].get("model")) + +out = run_product_batch("JP", config, BASE, BASE / "dist_v2", + [{"spu": "JPTM001", "skus": "JPTM001-BL01"}], 1, log_q=None) +print("\n=== 结果 ===") +prods = out.get("product") or [] +print("产品数:", len(prods)) +for r in prods: + print(f" {r.get('spu_code')} | {r.get('sku_code')} | design={bool(r.get('design_path'))} " + f"| composite={bool(r.get('composite_path'))} | en_title={bool(r.get('en_title'))} " + f"| ja_title={bool(r.get('ja_title'))} | oss_code={r.get('oss_code')}") + print(" URLs:", {k: v[:60] for k, v in r.items() if k.endswith("_url")}) +print("errors:", [e.get("message", "")[:100] for e in out.get("errors") or []]) diff --git a/templates/template_router.py b/templates/template_router.py new file mode 100644 index 0000000..75ddf04 --- /dev/null +++ b/templates/template_router.py @@ -0,0 +1,442 @@ +# -*- coding: utf-8 -*- +""" +商品上传模版 —— 模版表解析 + 行路由 + 数据插入 +================================================ + +解析目标:当前文件夹下「商品上传模版 (1).xlsx」的【模版】工作表。 + +表结构(【模版】sheet): + 第 1 行 元信息表头(经营站点 / 发货仓 / 类目 / 运费模版 ...) + 第 2 行 元信息值 + 第 3 行 分组行(基础信息(SKU/SPU均必填)、SPU商品属性、商品规格、尺码表 ...) + 第 4 行 列名行(商品层级、SPU货号、商品名称 ...) + 第 5 行 填写说明 + 第 6 行 空行(含数据验证下拉)——数据区起始行,由本模块负责路由 + 插入 + +完整表头 = 「分组-列名」,例如「基础信息-商品层级」;无分组的列直接用列名, +例如「SPU货号」。这一组合后的表头就是路由与插入数据的键。 + +路由规则(核心函数 route): + - 按「基础信息-商品层级」(A列,取值 spu / sku / 单sku商品)路由: + * 不传 spu_code:返回数据区第一个空行(新行位置) + * 传 spu_code(SPU货号):返回该 SPU 所在行**下方紧邻的空行**, + 用于把 SKU 变体插到所属 SPU 行后面,实现 SPU-SKU 关联 + - match 参数控制匹配模式: + * "exact"(默认):精准匹配 —— 货号/层级去空格后完全相等(不区分大小写) + * "fuzzy" :模糊匹配 —— 货号/层级包含关键字即命中(不区分大小写) + * 兼容中文别名:精准/精确 -> exact,模糊/包含 -> fuzzy + - 若该层级/货号已存在,可指定 update=True 直接覆盖其行 + +用法示例: + from template_router import TemplateRouter + + r = TemplateRouter(r"商品上传模版 (1).xlsx") + + # 1) 按层级路由:拿到应写入的行号 + row = r.route("spu") # -> 数据区第一个空行 + row = r.route("sku", spu_code="A001") # -> SPU 货号 A001 所在行之后(精准) + row = r.route("sku", spu_code="A0", match="fuzzy") # -> 模糊匹配,命中 A001 之后 + row = r.route("sku", spu_code="a00", match="模糊") # 中文别名等价写法 + + # 2) 直接插入一行(自动路由;insert 同样支持 match 参数) + r.insert({ + "基础信息-商品层级": "spu", + "SPU货号": "A001", + "商品名称": "纯棉白T恤", + "商品产地": "中国", + "产地省份": "广东省", + }) + r.insert({ + "商品层级": "sku", # 列名也可直接作键 + "SPU货号": "A001", + "SKU货号": "A001-白色-M", + "商品名称": "纯棉白T恤 白色 M", + "尺码": "M", + "申报价格-日本站": 1290, + "发货仓1库存": 100, + }, spu_code="A001") # 自动插到 A001 后面 + r.insert({"商品层级": "sku", "SPU货号": "A001", "SKU货号": "A001-白色-L", ...}, + match="fuzzy") # 模糊匹配货号路由 + + r.save() # 默认输出到 <原名>_已填写.xlsx +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.utils import column_index_from_string, get_column_letter + +# -------------------------------------------------------------------------- +# 常量:模版表关键行列 +# -------------------------------------------------------------------------- +SHEET_NAME = "模版" +GROUP_ROW = 3 # 分组行 +HEADER_ROW = 4 # 列名行 +DATA_START = 6 # 数据区起始行(第 5 行为说明,第 6 行为空行,数据默认从第 6 行开始填) +LEVEL_COL = 1 # A 列:基础信息-商品层级 +SPU_CODE_COL = 2 # B 列:SPU货号 +VALID_LEVELS = ("spu", "sku", "单sku商品") + +_GROUP_BRACKET = re.compile(r"([^)]*)|\([^)]*\)") # 去掉分组名里的括号说明 + + +class TemplateRouter: + """解析【模版】表,并提供 路由 + 插入 能力。""" + + def __init__(self, path: str | Path, sheet: str = SHEET_NAME): + self.path = Path(path) + self.wb = load_workbook(self.path, data_only=False) + if sheet not in self.wb.sheetnames: + raise ValueError(f"工作簿中不存在工作表 {sheet!r},现有: {self.wb.sheetnames}") + self.ws = self.wb[sheet] + + # 完整表头 -> 列号 (如 "基础信息-商品层级" -> 1) + self.header_map: dict[str, int] = {} + # 列名(去分组) -> 列号 (如 "商品层级" -> 1) + self.column_map: dict[str, int] = {} + # 列号 -> 完整表头 + self.col_headers: dict[int, str] = {} + self._build_headers() + + self._used_rows: set[int] | None = None # 已占用行缓存,见 _refresh() + + # ------------------------------------------------------------------ + # 表头解析 + # ------------------------------------------------------------------ + def _build_headers(self) -> None: + for col in range(1, self.ws.max_column + 1): + raw_group = self.ws.cell(GROUP_ROW, col).value + raw_name = self.ws.cell(HEADER_ROW, col).value + group = _GROUP_BRACKET.sub("", str(raw_group)).strip() if raw_group else "" + name = str(raw_name).strip() if raw_name else "" + if not name: + continue + header = f"{group}-{name}" if group else name + self.header_map[header] = col + self.column_map[name] = col + self.col_headers[col] = header + + # ------------------------------------------------------------------ + # 内部工具 + # ------------------------------------------------------------------ + @staticmethod + def _normalize_match(match: str) -> str: + """把 match 参数归一化为 exact / fuzzy,兼容中文别名。""" + m = str(match).strip().lower() + alias = {"精准": "exact", "精确": "exact", "完全": "exact", + "模糊": "fuzzy", "包含": "fuzzy", "部分": "fuzzy"} + m = alias.get(m, m) + if m not in ("exact", "fuzzy"): + raise ValueError(f"match 必须是 exact(精准) 或 fuzzy(模糊),收到: {match!r}") + return m + + def _refresh_used_rows(self) -> None: + """扫描数据区,收集 A 列(商品层级)非空的行号集合。""" + used: set[int] = set() + for row in range(DATA_START, self.ws.max_row + 1): + v = self.ws.cell(row, LEVEL_COL).value + if v is not None and str(v).strip(): + used.add(row) + self._used_rows = used + + def first_empty_row(self, start: int = DATA_START) -> int: + """返回数据区第一个空行(A 列为空)。""" + if self._used_rows is None: + self._refresh_used_rows() + row = max(start, DATA_START) + while row in self._used_rows: + row += 1 + return row + + def find_spu_rows(self, spu_code: str, match: str = "exact") -> list[int]: + """ + 按 SPU货号(B列)查找所有行号。 + + match="exact"(默认):精准匹配,去首尾空格后完全相等(不区分大小写); + match="fuzzy" :模糊匹配,货号包含关键字即命中(不区分大小写)。 + """ + spu_code = str(spu_code).strip().lower() + match = self._normalize_match(match) + hits = [] + for row in range(DATA_START, self.ws.max_row + 1): + v = self.ws.cell(row, SPU_CODE_COL).value + if v is None: + continue + v = str(v).strip().lower() + if (match == "exact" and v == spu_code) or (match == "fuzzy" and spu_code in v): + hits.append(row) + return hits + + def find_sku_rows(self, spu_code: str, match: str = "exact") -> list[int]: + """按 SPU货号(B列)+ 层级=sku(A列)查找所有 SKU 行号(单 SPU 多色时无 SPU 行,SKU 行即全部)。""" + spu_code = str(spu_code).strip().lower() + match = self._normalize_match(match) + hits = [] + for row in range(DATA_START, self.ws.max_row + 1): + lv = self.ws.cell(row, LEVEL_COL).value + if lv is None or str(lv).strip().lower() != "sku": + continue + v = self.ws.cell(row, SPU_CODE_COL).value + if v is None: + continue + v = str(v).strip().lower() + if (match == "exact" and v == spu_code) or (match == "fuzzy" and spu_code in v): + hits.append(row) + return hits + + def find_level_rows(self, level: str, match: str = "exact") -> list[int]: + """ + 按 商品层级(A列)查找所有行号。 + + match="exact"(默认):精准匹配,去首尾空格后完全相等(不区分大小写); + match="fuzzy" :模糊匹配,层级包含关键字即命中(不区分大小写)。 + """ + level = str(level).strip().lower() + match = self._normalize_match(match) + hits = [] + for row in range(DATA_START, self.ws.max_row + 1): + v = self.ws.cell(row, LEVEL_COL).value + if v is None: + continue + v = str(v).strip().lower() + if (match == "exact" and v == level) or (match == "fuzzy" and level in v): + hits.append(row) + return hits + + # ------------------------------------------------------------------ + # 核心路由函数 + # ------------------------------------------------------------------ + def route(self, level: str | None = None, + spu_code: str | None = None, + update: bool = False, + match: str = "exact") -> int: + """ + 根据「基础信息-商品层级」(A列) 路由到模版表中的一行,返回行号。 + + 参数 + ---- + level : 商品层级,取值 spu / sku / 单sku商品(不区分大小写)。 + 仅用于未提供 spu_code 时定位「该层级最后一行之后」的空行。 + spu_code : SPU货号。提供时路由到该货号所在行: + - update=False(默认):返回其下方第一个空行(插入新行); + - update=True :返回该货号最后一行(原地覆盖更新)。 + 未找到匹配行会抛 KeyError。 + update : 是否原地更新已存在的行。 + match : 匹配模式(对 spu_code 与 level 均生效): + - "exact"(默认):精准匹配,去首尾空格后完全相等(不区分大小写); + - "fuzzy" :模糊匹配,包含关键字即命中(不区分大小写)。 + 兼容中文别名:精准/精确/完全 -> exact,模糊/包含/部分 -> fuzzy。 + 模糊匹配命中多行时,插入定位取最后命中行,更新取最后命中行。 + + 返回 + ---- + int 行号(从 7 开始)。 + """ + match = self._normalize_match(match) + + if spu_code is not None: + rows = self.find_spu_rows(spu_code, match) + if not rows: + raise KeyError(f"未找到 SPU货号 = {spu_code!r} 的{('模糊' if match == 'fuzzy' else '精准')}匹配行") + last = max(rows) + if update: + return last + # 从该 SPU 行之后找第一个空行(保证 SPU 与 SKU 相邻) + return self.first_empty_row(last + 1) + + if level is not None: + level_l = str(level).strip().lower() + if match == "exact" and level_l not in VALID_LEVELS: + raise ValueError(f"商品层级取值必须是 {VALID_LEVELS} 之一,收到: {level!r}") + if update: + rows = self.find_level_rows(level_l, match) + if not rows: + raise KeyError(f"未找到商品层级 = {level!r} 的{('模糊' if match == 'fuzzy' else '精准')}匹配行") + return max(rows) + rows = self.find_level_rows(level_l, match) + if rows: + return self.first_empty_row(max(rows) + 1) + + return self.first_empty_row() + + # ------------------------------------------------------------------ + # 数据插入 + # ------------------------------------------------------------------ + def resolve_col(self, key: str | int) -> int: + """把键解析为列号:完整表头 / 列名 / 列号 / Excel 列字母。""" + if isinstance(key, int): + return key + key_s = str(key).strip() + if key_s in self.header_map: + return self.header_map[key_s] + if key_s in self.column_map: + return self.column_map[key_s] + if re.fullmatch(r"[A-Za-z]{1,3}", key_s): + return column_index_from_string(key_s.upper()) + raise KeyError(f"无法识别的表头: {key!r}(可用完整表头/列名/列号/列字母)") + + def insert(self, data: dict, spu_code: str | None = None, + level: str | None = None, update: bool = False, + match: str = "exact") -> int: + """ + 插入一行数据到模版表。data 键支持:完整表头(推荐)、列名、列号、列字母。 + + - 若 data 中带「商品层级/基础信息-商品层级」则自动取其值路由; + - 传 spu_code 会把该行路由到对应 SPU 行之后(SKU 关联); + - update=True 时覆盖已存在的目标行,否则写入新空行; + - match 控制货号/层级的匹配模式:exact(精准,默认) / fuzzy(模糊),见 route()。 + + 返回写入的行号。 + """ + match = self._normalize_match(match) + + # 1) 解析层级与 spu_code + lv = level + if lv is None: + for k in ("基础信息-商品层级", "商品层级", LEVEL_COL): + if k in data: + lv = data[k] + break + + # 只有「SKU 层」才需要按 SPU货号 关联路由;SPU/单sku商品 层走新行路由 + use_spu_route = spu_code is not None + if spu_code is None: + if lv is not None and str(lv).strip().lower() == "sku" and "SPU货号" in data: + use_spu_route = True # sku 层 -> 插到所属 SPU 行之后 + elif lv is None and "SPU货号" in data and self.find_spu_rows(data["SPU货号"], match): + use_spu_route = True # 无层级但货号已存在 -> 视为该 SPU 的变体行 + spu = spu_code if use_spu_route else None + if use_spu_route and spu is None: + spu = data["SPU货号"] + if not self.find_spu_rows(spu, match): + use_spu_route = False # 无 SPU 行(单 SPU 多色,不填 SPU 行)→ 按层级末尾追加 + spu = None + + # 2) 路由到目标行 + row = self.route(lv, spu, update=update, match=match) + + # 3) 写入 + for key, value in data.items(): + if value is None: + continue + try: + col = self.resolve_col(key) + except KeyError: + continue # 未知键静默跳过,避免整行失败 + self.ws.cell(row, col, value) + + if self._used_rows is not None: + self._used_rows.add(row) + return row + + # ------------------------------------------------------------------ + # 保存 + # ------------------------------------------------------------------ + def save(self, path: str | Path | None = None) -> Path: + """ + 保存工作簿。默认输出到 <源文件名>_已填写.xlsx,不覆盖原始模版; + 显式传 path(或 path 与源文件相同时)则按指定路径保存。 + """ + out = Path(path) if path else self.path.with_name(f"{self.path.stem}_已填写.xlsx") + out = out.with_suffix(".xlsx") if out.suffix.lower() != ".xlsx" else out + self.wb.save(out) + return out + + def close(self) -> None: + self.wb.close() + + +# -------------------------------------------------------------------------- +# 演示:路由 + 插入 一个 SPU 与两个 SKU +# -------------------------------------------------------------------------- +def demo(src: str | Path = r"商品上传模版 (1).xlsx") -> Path: + r = TemplateRouter(src) + + # 1) 按层级路由 —— spu 层应落在数据区第一行(行 7) + row = r.route("spu") + print(f"[route] spu -> 第 {row} 行") + assert row == DATA_START + + # 2) 插入 SPU 层 + r.insert({ + "基础信息-商品层级": "spu", + "SPU货号": "A001", + "商品名称": "纯棉圆领白T恤 基础款", + "英文名称": "Cotton Crew Neck White T-Shirt", + "日语名称": "コットン クルーネック 白Tシャツ", + "商品产地": "中国", + "产地省份": "广东省", + "品牌名": "Inkreach", + "季节": "四季", + "风格": "休闲", + }) + + # 3) 按 SPU货号 路由 —— sku 应插到 A001 之后(行 8) + row = r.route("sku", spu_code="A001") + print(f"[route] sku(A001) -> 第 {row} 行") + assert row == DATA_START + 1 + + # 4) 插入 SKU 变体 1 / 2(自动关联到 A001 下方) + r.insert({ + "基础信息-商品层级": "sku", + "SPU货号": "A001", + "商品名称": "纯棉圆领白T恤 白色 M", + "SKU货号": "A001-W-M", + "色值(主规格)": "白色", + "尺码": "M", + "申报价格-日本站": 1290, + "币种": "JPY", + "发货仓1": "名古屋仓", + "发货仓1库存": 100, + "重量(g)": 180, + }, spu_code="A001") + r.insert({ + "商品层级": "sku", + "SPU货号": "A001", + "商品名称": "纯棉圆领白T恤 白色 L", + "SKU货号": "A001-W-L", + "色值(主规格)": "白色", + "尺码": "L", + "申报价格-日本站": 1290, + "币种": "JPY", + "发货仓1": "名古屋仓", + "发货仓1库存": 120, + "重量(g)": 200, + }, spu_code="A001") + + # 5) 验证 + assert r.ws.cell(DATA_START, 1).value == "spu" and r.ws.cell(DATA_START, 2).value == "A001" + assert r.ws.cell(DATA_START + 1, 1).value == "sku" and r.ws.cell(DATA_START + 1, 2).value == "A001" + assert r.ws.cell(DATA_START + 2, 1).value == "sku" and r.ws.cell(DATA_START + 2, 2).value == "A001" + print(f"[ok] 行 {DATA_START} = SPU(A001),行 {DATA_START+1}/{DATA_START+2} = 其下 SKU 变体,SPU-SKU 已关联") + + # 6) 匹配模式演示 + # 精准匹配:完整货号 A001 -> 其后第一个空行 + row = r.route("sku", spu_code="A001", match="exact") + print(f"[route] sku(A001, exact) -> 第 {row} 行") + assert row == DATA_START + 3 + # 模糊匹配:货号片段 A0 -> 命中 A001 所有行,取最后一行之后 + row = r.route("sku", spu_code="A0", match="fuzzy") + print(f"[route] sku(A0, fuzzy) -> 第 {row} 行") + assert row == DATA_START + 3 + # 模糊匹配 + 中文别名 + update:覆盖 A001 最后一行 + row = r.route("sku", spu_code="a00", match="模糊", update=True) + print(f"[route] sku(a00, 模糊, update) -> 第 {row} 行") + assert row == DATA_START + 2 + # 层级模糊匹配:关键字 s 命中所有行 + row = r.route("s", match="fuzzy", update=True) + print(f"[route] level(s, fuzzy, update) -> 第 {row} 行") + assert row == DATA_START + 2 + print("[ok] 精准/模糊两种匹配模式均工作正常(含中文别名)") + + out = r.save() + r.close() + print(f"[save] 已写出 -> {out}") + return out + + +if __name__ == "__main__": + demo() diff --git a/ui_app.py b/ui_app.py new file mode 100644 index 0000000..20c9d26 --- /dev/null +++ b/ui_app.py @@ -0,0 +1,1109 @@ +"""POD 热点抓取 Agent —— 桌面 UI(Tkinter 轻量版)。 + +功能(简洁版): +- 国家多选(US/GB/JP/AU) +- LLM 后端选择(mock/static/openai_compat/openai/deepseek/qwen/moonshot) +- 种子数量上限(style/related) +- 运行:后台线程调用 graph.agent.run_country,print 输出重定向到日志区 +- 结果表格:国家/热点词/类别/风险/分数;双击看完整详情 +- 打开产物目录 + +打包(PyInstaller): + pyinstaller -F -w ui_app.py --add-data "config.yaml;." --add-data "configs;configs" --add-data "prompts;prompts" +数据文件(config.yaml/configs/prompts)打进 _MEIPASS;output/ 与 .cache/ 写在 exe 旁运行目录。 + +自检:python ui_app.py --self-test(无 GUI,跑通 GB mock 核心链路,验证打包环境)。 +""" +import contextlib +import io +import json +import os +import queue +import shutil +import sys +import threading +import time +from pathlib import Path + +import tkinter as tk +from tkinter import filedialog, ttk, messagebox +import yaml + +from graph.agent import run_country + +COUNTRIES = ["US", "GB", "JP", "AU", "MX"] +# LLM 只适配 OpenAI 兼容协议:openai(真调用,可在下方配置自定义模型/URL/Key)/ mock(无 key 演示占位) +PROVIDERS = ["openai", "mock"] + + +def resource_root() -> Path: + """数据根:开发=脚本目录;打包=_MEIPASS(config.yaml/configs/prompts 解压处)。""" + if getattr(sys, "frozen", False): + return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent)) + return Path(__file__).resolve().parent + + +def runtime_root() -> Path: + """运行根(可写):打包=exe 旁;开发=脚本目录。output/.cache 写这里。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent + + +OUTPUT_ROOT = runtime_root() / "output" + + +def config_root() -> Path: + """配置生效根:exe 旁的可编辑副本优先(config.yaml 存在时),缺失则用内置默认(_MEIPASS)。 + + 开发模式 = 脚本目录(即项目根,行为不变)。 + """ + if getattr(sys, "frozen", False): + run = Path(sys.executable).resolve().parent + if (run / "config.yaml").exists(): + return run + return Path(getattr(sys, "_MEIPASS", run)) + return Path(__file__).resolve().parent + + +def ensure_defaults() -> None: + """打包后首次运行:把内置默认 config.yaml/configs/prompts 复制到 exe 旁(已存在不覆盖), + 用户可直接编辑这份默认配置;exe 内置的作为兜底。""" + if not getattr(sys, "frozen", False): + return + run = Path(sys.executable).resolve().parent + data = Path(getattr(sys, "_MEIPASS", run)) + for rel in ("config.yaml", "configs", "prompts"): + src = data / rel + dst = run / rel + if src.exists() and not dst.exists(): + try: + if src.is_dir(): + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + print(f"[UI] 已生成默认配置: {dst}") + except Exception as e: # noqa: BLE001 + print(f"[UI] 默认配置生成失败 {rel}: {e}") + + +def load_config() -> dict: + with open(config_root() / "config.yaml", encoding="utf-8") as f: + return yaml.safe_load(f) + + +def load_spus(country: str = None) -> list: + """从 db 读可选 SPU 列表(可按国家过滤;UI 选品下拉用)。""" + try: + from graph.product import list_spus + pcfg = load_config().get("product") or {} + dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db")) + return list_spus(dbp, country) or [] + except Exception: + return [] + + +def load_colors(spu_code: str) -> list: + """款号 → 颜色列表(UI 颜色下拉用)。""" + try: + from graph.product import list_colors + pcfg = load_config().get("product") or {} + dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db")) + return list_colors(dbp, spu_code) or [] + except Exception: + return [] + + +def resolve_data_path(rel: str) -> Path: + """数据文件相对路径解析:exe 旁自定义优先 → _MEIPASS 内置兜底。""" + p = Path(rel) + if p.is_absolute(): + return p + for root in (runtime_root(), resource_root()): + cand = root / p + if cand.exists(): + return cand + return resource_root() / p + + +# 全局日志文件:UI 启动时创建(logs/<时间戳>.log),所有 print(采集+运行)同时落盘 +LOG_FILE = None + + +class StdoutRedirector(io.TextIOBase): + """把 print 输出转发到 UI 队列 + 写入全局日志文件(带时间戳,详细记录每步含错误)。""" + + def __init__(self, q: queue.Queue): + self._q = q + + def write(self, s: str) -> int: + if s: + self._q.put(("log", s)) + if LOG_FILE: + try: + LOG_FILE.write(f"[{time.strftime('%H:%M:%S')}] {s}") + LOG_FILE.flush() + except Exception: + pass + return len(s) + + def flush(self): + if LOG_FILE: + try: + LOG_FILE.flush() + except Exception: + pass + + +def apply_openai_cfg(config: dict, oai: dict) -> None: + """把 UI 的 OpenAI 配置写入 llm_screen + compose。 + + - API Key 不在 UI 显示:始终从 config.yaml(llm_screen.api_key / compose.api_key) + 或环境变量(LLM_API_KEY / OPENAI_API_KEY)读取。 + - LLM 与图像的 Base URL 独立(生图模型可能走不同网关)。 + """ + if not oai: + return + ls = config.setdefault("llm_screen", {}) + cp = config.setdefault("compose", {}) + if oai.get("llm_base_url"): + ls["base_url"] = oai["llm_base_url"] + if oai.get("img_base_url"): + cp["base_url"] = oai["img_base_url"] + if oai.get("llm_model"): + ls["model"] = oai["llm_model"] + if oai.get("image_model"): + cp["model"] = oai["image_model"] + + +def _apply_provider_mode(config: dict, provider: str) -> None: + """OpenAI 模式开关:openai=全链路真 LLM/真生图;mock=全 mock 演示。 + + 联动:seed_provider / llm_screen.provider / compose.backend / product.backend。 + API Key 已在 config.yaml 或环境变量配置好(用户已配)。 + """ + on = (provider or "").strip().lower() == "openai" + config["seed_provider"] = provider or "mock" + ls = config.setdefault("llm_screen", {}) + ls["provider"] = "openai" if on else "mock" + cp = config.setdefault("compose", {}) + cp["backend"] = "openai" if on else "mock" + pp = config.setdefault("product", {}) + pp["backend"] = "openai" if on else "mock" + print(f"[UI] 模式开关: {'OpenAI(真 LLM + 真生图)' if on else 'Mock(演示)'} | " + f"llm_screen={ls['provider']} compose={cp['backend']} product={pp['backend']}") + + +def fetch_keywords(country, provider, max_style, max_related, log_q, oai=None): + """后台线程:仅跑「采集+去重过滤」链路(seed→fetch→filter), + 把过滤后的关键词(数量不定)实时回传 UI 显示。""" + from graph.agent import build_country_config + from graph.nodes.fetch_node import fetch_node + from graph.nodes.filter_node import filter_node + from graph.nodes.seed_node import seed_node + + config = load_config() + config["seed_provider"] = provider + cfg = config.setdefault("seed_provider_cfg", {}) + cfg["max_style_seeds"] = max_style + cfg["max_related_seeds"] = max_related + apply_openai_cfg(config, oai) + _apply_provider_mode(config, provider) # OpenAI 模式开关(采集只用 seed,其余为后续统一) + + old_stdout = sys.stdout + sys.stdout = StdoutRedirector(log_q) + try: + cc = build_country_config(config, country, config_root()) + state = { + "country": country, "config": config, "country_config": cc, + "prompts_dir": str(config_root() / "prompts" / country), + "output_dir": str(OUTPUT_ROOT / country), + "raw_rows": [], "filtered_rows": [], "errors": [], "stats": {}, + } + log_q.put(("log", f"\n===== 采集热点 {country}(provider={provider})=====\n")) + st = seed_node(state) + state["country_config"] = st.get("country_config") or cc + state["stats"] = st.get("stats") or {} + state["errors"] = list(st.get("errors") or []) + ft = fetch_node(state) + state["raw_rows"] = ft.get("raw_rows") or [] + state["errors"] += list(ft.get("errors") or []) + fl = filter_node(state) + kept = fl.get("filtered_rows") or [] + + # 去重(大小写不敏感,保留首次出现顺序)+ 限长 + seen, keywords = set(), [] + for r in kept: + t = (r.get("topic") or "").strip() + if not t or t.lower() in seen: + continue + seen.add(t.lower()) + keywords.append({ + "topic": t, "source": r.get("source", ""), + "kind": r.get("kind", ""), "raw_score": r.get("raw_score"), + }) + log_q.put(("log", f"[{country}] 采集原始 {len(state['raw_rows'])} 条 → 规则过滤保留 {len(kept)} 条 → 去重后 {len(keywords)} 个关键词\n")) + if keywords: + # 采集成功 → ① 清除该国旧热点简报缓存(design_briefs.json), + # 下次「运行」走完整流水线用新采集热点重建简报(fetch 用 collected 缓存,不重复撞 Google) + out_dir = OUTPUT_ROOT / country + cache_f = out_dir / "design_briefs.json" + if cache_f.exists(): + try: + cache_f.unlink() + log_q.put(("log", f"[UI] 已清除 {country} 旧热点简报缓存,下次运行将使用新采集热点重建\n")) + except Exception as e: # noqa: BLE001 + log_q.put(("log", f"[UI] 清除旧热点缓存失败: {e}\n")) + # ② 把"过滤后"的关键词写入 collected_keywords.json(运行流水线 fetch 节点优先读取) + try: + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "collected_keywords.json").write_text( + json.dumps({"country": country, + "collected_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "keywords": keywords}, ensure_ascii=False, indent=2), + encoding="utf-8") + log_q.put(("log", f"[UI] 已缓存过滤后热点 {len(keywords)} 条(collected_keywords.json),运行将直接用\n")) + except Exception as e: # noqa: BLE001 + log_q.put(("log", f"[UI] 写入热点缓存失败: {e}\n")) + log_q.put(("fetched", {"country": country, "keywords": keywords})) + except Exception as e: # noqa: BLE001 + import traceback + log_q.put(("log", f"采集失败: {e}\n{traceback.format_exc()}\n")) + log_q.put(("error", None)) + finally: + sys.stdout = old_stdout + try: + _pack_cache(country, log_q) # 采集完成 → 自动打包缓存/去重数据 + except Exception as e: # noqa: BLE001 + log_q.put(("log", f"[UI] 缓存打包失败: {e}\n")) + + +def run_pipeline(countries, provider, max_style, max_related, log_q, + spu_code="", sku_code="", spu_count=0, + spu_tasks=None, oai=None, markup_percent=0.0, code_prefix="DG", + template_path=""): + """后台线程:缓存热点产品流程(选定国家后不再全部重跑种子词/抓取)。 + + 1) 有缓存热点(output/<国家>/design_briefs.json)→ 直接加载,按 SPU 数量取未用热点, + 每款号一个热点,生成设计稿 → 三图合成 → 多模态标题 → 模板导出。 + 2) 无缓存 → 先跑一次完整流水线生成缓存,再继续。 + spu_tasks: [{"spu": "DG004", "skus": "..."}] 多款号批量选品 + spu_count: 款号数量上限(≥1;热点数按它来) + oai: {"llm_base_url","img_base_url","llm_model","image_model"}(Key 从 config/env 读取) + markup_percent: 加价百分比(按 SKU 最低价,后续定价用) + code_prefix: 货号前缀(OSS 上传 key:{国家}/{时间戳}/{前缀+3位计数}_{4位随机}.jpg,000 起最多 999) + template_path: 用户上传的自定义商品上传模板(xlsx 绝对路径),后续模板导出从此模板解析 + """ + config = load_config() + config["seed_provider"] = provider + cfg = config.setdefault("seed_provider_cfg", {}) + cfg["max_style_seeds"] = max_style + cfg["max_related_seeds"] = max_related + apply_openai_cfg(config, oai) + # OpenAI 模式开关:provider=openai → 全链路真 LLM/真生图;mock → 全 mock 演示 + _apply_provider_mode(config, provider) + if spu_code or sku_code or spu_count or spu_tasks or markup_percent or code_prefix or template_path: + p = config.setdefault("product", {}) + if spu_code: + p["spu_code"] = spu_code + if template_path: + p["template_path"] = template_path # 自定义模板:template_export 从此解析 + if sku_code: + p["sku_code"] = sku_code + if spu_tasks: + p["spu_tasks"] = spu_tasks + p.pop("spu_code", None) # 任务清单优先 + if spu_count: + p["spu_count"] = spu_count + if markup_percent: + p["markup_percent"] = markup_percent + if code_prefix: + p["code_prefix"] = code_prefix + # 任务扩展:每个「集合」(款-颜色集 + 独立数量 count)按自己数量复制 N 份(N=该集合设计数); + # 每份 skus 保留颜色集合(多色)→ 每个设计配全部颜色各出一张主图 + if spu_tasks or spu_code: + base = [dict(t) for t in (spu_tasks or [{"spu": spu_code, "skus": sku_code}])] + tasks_list = [] + for t in base: + n = int(t.get("count") or 0) or spu_count or 1 # 每集合独立数量,无则全局 + for _ in range(n): + tt = dict(t) + tt.pop("count", None) + tasks_list.append(tt) + config.setdefault("product", {})["spu_tasks"] = tasks_list + if spu_count: + config.setdefault("product", {})["spu_count"] = spu_count + old_stdout = sys.stdout + sys.stdout = StdoutRedirector(log_q) + results = {} + task_ts = time.strftime("%Y%m%d%H%M%S") # 任务开始时间戳(OSS 路径段) + try: + from graph.product_batch import load_cached_briefs, run_product_batch + for c in countries: + log_q.put(("log", f"\n===== 开始处理 {c}(缓存热点模式)=====\n")) + # 只要有采集缓存(collected_keywords)或简报缓存 → 缓存模式: + # run_product_batch 内部简报不足会自动「从采集缓存生成简报」,绝不触发 seed/fetch/Google + has_cache = bool(load_cached_briefs(runtime_root() / "output" / c)) \ + or (runtime_root() / "output" / c / "collected_keywords.json").exists() + if has_cache: + out = run_product_batch(c, config, config_root(), runtime_root(), + list(spu_tasks or []), spu_count, log_q, + task_timestamp=task_ts) + items = out.get("product") or [] + errs = out.get("errors") or [] + log_q.put(("log", f"[{c}] 缓存热点完成:{len(items)} 个产品(每个热点对应一款)\n")) + else: + log_q.put(("log", f"[{c}] 无任何采集缓存 → 走完整流水线(seed+fetch+screen,首次需 Google)…\n")) + state = run_country(c, config, config_root(), output_root=runtime_root(), task_timestamp=task_ts) + items = state.get("briefs", []) or [] + errs = state.get("errors") or [] + log_q.put(("log", f"[{c}] 完整流水线完成:简报 {len(items)} 条,兜底错误 {len(errs)}\n")) + results[c] = items + ts_dir = runtime_root() / "output" / countries[0] / task_ts + log_q.put(("log", f"\n✅ 任务完成,产物文件夹:{ts_dir}\n" + f" (缓存/去重记录在 {runtime_root() / 'output' / countries[0]} 根目录,不进任务文件夹)\n")) + log_q.put(("done", results)) + except Exception as e: # noqa: BLE001 + import traceback + log_q.put(("log", f"运行失败: {e}\n{traceback.format_exc()}\n")) + log_q.put(("error", None)) + finally: + sys.stdout = old_stdout + try: + for c in countries: + _pack_cache(c, log_q) # 运行完成 → 自动打包缓存/去重数据 + except Exception as e: # noqa: BLE001 + log_q.put(("log", f"[UI] 缓存打包失败: {e}\n")) + + +def _pack_cache(country: str, log_q=None) -> str: + """运行/采集完成后,把该国缓存+去重数据打包成 zip(output/cache_packs/<国>_<时间戳>.zip), + 内含 design_briefs / used_designs / collected_keywords / products.json + .cache 关键缓存, + 方便直接查看缓存与去重效果。""" + import zipfile + src = OUTPUT_ROOT / country + pack_dir = OUTPUT_ROOT / "cache_packs" + pack_dir.mkdir(parents=True, exist_ok=True) + stamp = time.strftime("%Y%m%d_%H%M%S") + zip_path = pack_dir / f"{country}_{stamp}.zip" + files = [ + ("design_briefs.json", "简报(热点+风格+提示词)"), + ("used_designs.json", "已用记录(去重)"), + ("collected_keywords.json", "采集过滤结果"), + ("product/products.json", "产品产物清单"), + ] + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for rel, _ in files: + p = src / rel + if p.exists(): + zf.write(p, f"output/{country}/{rel}") + # .cache 关键缓存(种子/抓取,各取最新) + cache_root = runtime_root() / ".cache" + for sub in ("seeds", "google_trends"): + d = cache_root / sub + if not d.exists(): + continue + picks = sorted(d.glob(f"*{country}*")) or [] + if sub == "google_trends": + picks = sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:10] + for f in picks: + try: + zf.write(f, f".cache/{sub}/{f.name}") + except Exception: + pass + if log_q: + log_q.put(("log", f"[UI] 缓存打包完成: {zip_path}\n")) + return str(zip_path) + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("POD 热点抓取 Agent") + self.geometry("920x660") + self._q = queue.Queue() + self._busy = False + self._results = {} + # 运行日志落盘:logs/<时间戳>.log(exe 旁 logs 目录),记录本次打开软件的所有日志 + global LOG_FILE + try: + log_dir = runtime_root() / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + LOG_FILE = open(log_dir / f"{time.strftime('%Y%m%d_%H%M%S')}.log", + "a", encoding="utf-8", buffering=1) + except Exception as e: # noqa: BLE001 + LOG_FILE = None + print(f"[UI] 日志文件创建失败(不影响运行): {e}") + self._build_ui() + self._load_cache() + self.after(100, self._poll) + + def _default_provider(self) -> str: + """默认模式:config.yaml llm_screen.provider(用户已配 key → openai 默认)。""" + try: + return "openai" if (load_config().get("llm_screen") or {}).get("provider") == "openai" else "mock" + except Exception: + return "mock" + + # ---------- UI 构建 ---------- + def _build_ui(self): + top = ttk.Frame(self, padding=8) + top.pack(fill="x") + ttk.Label(top, text="国家:").pack(side="left") + self.country_var = tk.StringVar(value="US") + for c in COUNTRIES: + ttk.Radiobutton(top, text=c, value=c, variable=self.country_var, + command=self._on_country_selected).pack(side="left", padx=4) + ttk.Label(top, text=" 模式:").pack(side="left", padx=(14, 0)) + self.provider_var = tk.StringVar(value=self._default_provider()) + ttk.Radiobutton(top, text="OpenAI(真 LLM+生图)", value="openai", + variable=self.provider_var).pack(side="left", padx=2) + ttk.Radiobutton(top, text="Mock 演示", value="mock", + variable=self.provider_var).pack(side="left", padx=2) + ttk.Label(top, text=" 种子上限:").pack(side="left", padx=(14, 0)) + self.style_var = tk.StringVar(value="12") + ttk.Entry(top, textvariable=self.style_var, width=4).pack(side="left") + ttk.Label(top, text="/").pack(side="left") + self.related_var = tk.StringVar(value="12") + ttk.Entry(top, textvariable=self.related_var, width=4).pack(side="left") + # 右上角:热点状态(缓存时间 / 采集时间) + self.hotspot_time_var = tk.StringVar(value="") + ttk.Label(top, textvariable=self.hotspot_time_var, anchor="e", + foreground="#185FA5").pack(side="right") + + # —— OpenAI 配置说明(所有配置从 config.yaml 读取:key/URL/模型,前端不配置)—— + oai = ttk.LabelFrame(self, text="OpenAI 配置(全部读取 config.yaml:API Key / Base URL / 模型,不在前端配置)", padding=4) + oai.pack(fill="x", padx=8, pady=(2, 0)) + ttk.Label(oai, text="LLM / 图像网关与模型在 config.yaml 的 llm_screen / compose 段配置", + foreground="#5F5E5A").pack(side="left") + + # —— 商品上传模板(必须上传后才能开始任务;template_export 从该模板解析并填充)—— + tpl = ttk.LabelFrame(self, text="商品上传模板(必须上传 .xlsx 后才能运行)", padding=4) + tpl.pack(fill="x", padx=8, pady=(2, 0)) + ttk.Button(tpl, text="📁 选择模板…", command=self._choose_template).pack(side="left") + self.template_path_var = tk.StringVar(value="") + self.template_name_var = tk.StringVar(value="(未选择)") + ttk.Label(tpl, textvariable=self.template_name_var, anchor="w", + foreground="#993C1D").pack(side="left", padx=(8, 0)) + + # —— 选品面板:国家→款号→颜色→添加任务(支持多款号 + 数量上限)—— + sel = ttk.LabelFrame(self, text="选品(选定国家自动加载款号;款号 → 颜色 → 添加任务)", padding=6) + sel.pack(fill="x", padx=8, pady=(4, 0)) + + # 左:款号列表 + lf1 = ttk.Frame(sel) + lf1.pack(side="left", padx=4) + ttk.Label(lf1, text="款号(选定后自动加载颜色与最低价):").pack(anchor="w") + self.spu_list = tk.Listbox(lf1, selectmode="extended", height=5, width=14, exportselection=False) + self.spu_list.pack(side="left") + self.spu_list.bind("<>", self._on_spu_selected) + self.min_price_var = tk.StringVar(value="") + ttk.Label(lf1, textvariable=self.min_price_var, anchor="w", foreground="#993C1D").pack(fill="x", pady=(4, 0)) + + # 中:颜色多选(点击即多选)+ 添加按钮 + lf2 = ttk.Frame(sel) + lf2.pack(side="left", padx=(10, 4)) + ttk.Label(lf2, text="当前款号颜色(点击多选,显示色名/最低价):").pack(anchor="w") + self.sku_list = tk.Listbox(lf2, selectmode="multiple", height=5, width=34, exportselection=False) + self.sku_list.pack(side="left") + b2 = ttk.Frame(lf2) + b2.pack(side="left", padx=4) + ttk.Button(b2, text="全选", command=self._select_all_colors).pack(fill="x") + ttk.Button(b2, text="清空", command=lambda: self.sku_list.selection_clear(0, "end")).pack(fill="x", pady=2) + ttk.Button(b2, text="+添加选品", command=self._add_task).pack(fill="x", pady=(4, 0)) + ttk.Label(b2, text="该款SPU/设计数(1-N):", anchor="w").pack(fill="x", pady=(6, 0)) + self.count_var = tk.StringVar(value="10") + ttk.Entry(b2, textvariable=self.count_var, width=6).pack(fill="x") + + # 右:任务清单 + lf3 = ttk.Frame(sel) + lf3.pack(side="left", padx=4) + ttk.Label(lf3, text="选品任务清单:").pack(anchor="w") + self.task_stat_var = tk.StringVar(value="0 个任务") + ttk.Label(lf3, textvariable=self.task_stat_var, anchor="w", + foreground="#993C1D").pack(anchor="w") + self.task_list = tk.Listbox(lf3, height=5, width=40, exportselection=False) + self.task_list.pack(side="left") + b3 = ttk.Frame(lf3) + b3.pack(side="left", padx=4) + ttk.Button(b3, text="删选中", command=self._del_task).pack(fill="x") + ttk.Button(b3, text="清空", command=self._clear_tasks).pack(fill="x", pady=2) + + # 加价百分比(供后续定价使用)+ 货号前缀 + lf4 = ttk.Frame(sel) + lf4.pack(side="left", padx=(12, 0)) + ttk.Label(lf4, text="加价%:").pack(anchor="w") + self.markup_var = tk.StringVar(value="0") + ttk.Entry(lf4, textvariable=self.markup_var, width=7).pack(anchor="w", pady=(2, 0)) + ttk.Label(lf4, text="按 SKU 最低价加价", anchor="w", foreground="#5F5E5A").pack(anchor="w", pady=(4, 0)) + + lf5 = ttk.Frame(sel) + lf5.pack(side="left", padx=(12, 0)) + ttk.Label(lf5, text="货号前缀:").pack(anchor="w") + self.code_prefix_var = tk.StringVar(value="DG") + ttk.Entry(lf5, textvariable=self.code_prefix_var, width=8).pack(anchor="w", pady=(2, 0)) + ttk.Label(lf5, text="前缀+3位计数(000起)\nOSS: 国家/时间戳/货号_4位随机", anchor="w", + foreground="#5F5E5A").pack(anchor="w", pady=(4, 0)) + + self._sku_map = {} + self._tasks: list = [] + self._reload_spus() + + bar = ttk.Frame(self, padding=(8, 0)) + bar.pack(fill="x") + self.fetch_btn = ttk.Button(bar, text="📥 采集热点", command=self._on_fetch) + self.fetch_btn.pack(side="left") + self.run_btn = ttk.Button(bar, text="▶ 运行", command=self._on_run) + self.run_btn.pack(side="left", padx=6) + ttk.Button(bar, text="打开产物目录", command=self._open_output).pack(side="left", padx=8) + ttk.Button(bar, text="打开日志目录", command=self._open_logs).pack(side="left") + ttk.Button(bar, text="清空日志", command=self._clear_log).pack(side="left") + + ttk.Label(self, text="运行日志:").pack(anchor="w", padx=8) + self.log_text = tk.Text(self, height=8, state="disabled") + self.log_text.pack(fill="x", padx=8) + + ttk.Label(self, text="设计简报(双击查看完整提示词):").pack(anchor="w", padx=8) + cols = ("country", "topic", "category", "risk", "score") + self.tree = ttk.Treeview(self, columns=cols, show="headings", height=12) + for col, title, w, anchor in zip( + cols, ["国家", "热点词", "类别", "风险", "分数"], + [52, 340, 100, 70, 64], + ["center", "w", "w", "center", "center"], + ): + self.tree.heading(col, text=title) + self.tree.column(col, width=w, anchor=anchor) + self.tree.pack(fill="both", expand=True, padx=8, pady=(2, 8)) + self.tree.bind("", self._show_detail) + + def _log(self, s: str): + self.log_text.config(state="normal") + self.log_text.insert("end", s) + self.log_text.see("end") + self.log_text.config(state="disabled") + + def _reload_spus(self): + """按当前所选国家加载该国款号到款号列表,并刷新颜色。""" + country = self.country_var.get() + self._spu_list = load_spus(country) + self.spu_list.delete(0, "end") + for s in self._spu_list: + self.spu_list.insert("end", s["code"]) + if self._spu_list: + self.spu_list.selection_set(0) + self._on_spu_selected() + + def _on_country_selected(self): + """切换国家:刷新该国款号 + 重新加载该国热点缓存。""" + self._reload_spus() + self._load_cache() + + def _on_spu_selected(self, _evt=None): + sel = self.spu_list.curselection() + if not sel: + self.sku_list.delete(0, "end") + self.min_price_var.set("") + return + spu = self._spu_list[sel[0]]["code"] if sel[0] < len(self._spu_list) else None + if not spu: + return + colors = load_colors(spu) + self._sku_map = {c["sku_code"]: c for c in colors} + self.sku_list.delete(0, "end") + for c in colors: + price = c.get("price") + price_txt = f" ¥{price:g}" if price not in (None, "") else "" + self.sku_list.insert("end", f"{c['sku_code']} {c.get('color', '')}{price_txt}") + # 最低价标注(该款号所有颜色 SKU 中的最低价) + prices = [float(c["price"]) for c in colors if c.get("price") not in (None, "")] + if prices: + self.min_price_var.set(f"最低价: ¥{min(prices):g}") + else: + self.min_price_var.set("") + if colors: + self.sku_list.selection_set(0) # 默认选中第一个颜色 + + def _current_spu(self): + sel = self.spu_list.curselection() + if sel and sel[0] < len(self._spu_list): + return self._spu_list[sel[0]]["code"] + return "" + + def _add_task(self): + """把当前选中的【款号】加入任务清单(可多选、可重复新增)。 + 每个款号一条「集合」:skus=选中颜色集合(多色用逗号分隔), + count=当前数量输入框值(每集合独立数量,= 该集合的 SPU/设计数)。""" + sel_idx = self.spu_list.curselection() + if not sel_idx: + messagebox.showwarning("提示", "请先选中至少一个款号(可 Ctrl/Shift 多选)") + return + sel_spus = [] + for i in sel_idx: + if i < len(self._spu_list): + sel_spus.append(self._spu_list[i]["code"]) + if not sel_spus: + return + try: + per_count = max(int(self.count_var.get() or 0), 1) + except ValueError: + per_count = 1 + raw_colors = [self.sku_list.get(i).split(" ")[0] for i in self.sku_list.curselection()] + if not raw_colors: + raw_colors = [self._sku_map and next(iter(self._sku_map))] + if not raw_colors[0]: + messagebox.showwarning("提示", "当前款号无颜色数据") + return + color_codes = [c.split("-")[-1] for c in raw_colors] # 颜色码(如 BL01) + for spu in sel_spus: + colors_of_spu = load_colors(spu) or [] + skus = [] + for cc in color_codes: + m = next((c["sku_code"] for c in colors_of_spu + if c["sku_code"].split("-")[-1] == cc), None) + skus.append(m or (colors_of_spu[0]["sku_code"] if colors_of_spu else cc)) + # 每款一条「集合」(可重复新增),颜色集合逗号分隔 + 独立数量 + self._tasks.append({"spu": spu, "skus": ",".join(skus), "count": per_count}) + self._refresh_tasks() + + def _upsert_task(self, spu: str, skus: str): + for t in self._tasks: + if t["spu"] == spu: + t["skus"] = skus + break + else: + self._tasks.append({"spu": spu, "skus": skus}) + self._refresh_tasks() + + def _del_task(self): + sel = self.task_list.curselection() + if not sel: + return + for i in reversed(sel): + self._tasks.pop(i) + self._refresh_tasks() + + def _clear_tasks(self): + self._tasks = [] + self._refresh_tasks() + + def _refresh_tasks(self): + self.task_list.delete(0, "end") + for t in self._tasks: + skus = t["skus"] or "(首色)" + cnt = t.get("count") or 0 + cnt_txt = f" × 设计数{cnt}" if cnt else "" + self.task_list.insert("end", f"{t['spu']} ← {skus}{cnt_txt}") + # 显示当前任务统计(集合数 × 各自数量 = 总 SPU/设计数) + total = sum(int(t.get("count") or 0) for t in self._tasks) + self.task_stat_var.set(f"{len(self._tasks)} 个集合 · 总设计数 {total}(每集合独立数量)") + + def _select_all_colors(self): + self.sku_list.selection_set(0, "end") + + def _load_cache(self): + """前台热点显示(两个独立配置文件): + ① collected_keywords.json = 完整采集池(全量,不随用量减少) + ② used_designs.json = 已用记录 → 已用热点从显示列表移除(用完即消失) + ③ design_briefs.json 只是运行简报(不决定前台显示),无 collected 时兜底。""" + c = self.country_var.get() + self._results = {} + out_dir = OUTPUT_ROOT / c + ga = "" + # ① 完整采集池 → 剔除已用 → 显示"可用热点" + try: + cp = out_dir / "collected_keywords.json" + if cp.exists(): + cdata = json.loads(cp.read_text(encoding="utf-8")) + kws = cdata.get("keywords") or [] + # 已用 topic 集合(used_designs.json) + used_topics = set() + up = out_dir / "used_designs.json" + if up.exists(): + try: + ud = json.loads(up.read_text(encoding="utf-8")).get("used", []) or [] + used_topics = {str(u.get("topic", "")).strip().lower() for u in ud} + except Exception: + pass + avail = [k for k in kws + if str(k.get("topic", "")).strip().lower() not in used_topics] + self._results[c] = [ + {"topic": k.get("topic", ""), + "source": k.get("source", "collected"), + "raw_score": k.get("raw_score")} for k in avail + ] + ga = (f"{str(cdata.get('collected_at', ''))}" + f"(可用 {len(avail)} / 采集 {len(kws)},已用 {len(used_topics)})") + except Exception: + pass + # ② 兜底:无 collected 时读简报 + if not self._results.get(c): + try: + p = out_dir / "design_briefs.json" + if p.exists(): + data = json.loads(p.read_text(encoding="utf-8")) + self._results[c] = data.get("design_briefs", []) or [] + ga = str(data.get("generated_at", "")) + except Exception: + pass + self._refresh_table() + total = sum(len(x) for x in self._results.values()) + if total: + self.hotspot_time_var.set(f"可用热点 {total} 条 · {ga or '时间未知'}") + self._log(f"[UI] 已加载 {c} 可用热点 {total} 条({ga or '未知'})\n") + else: + self.hotspot_time_var.set(f"{c} 无热点(点「📥 采集热点」)") + + # ---------- 采集(独立步骤)---------- + def _oai_cfg(self) -> dict: + """前端不再配置 OpenAI:所有 key/URL/模型从 config.yaml 读取(返回空,不覆盖配置)。""" + return {} + + def _on_fetch(self): + """仅采集+去重过滤热点关键词,直接显示在结果列表。""" + if self._busy: + return + try: + ms = int(self.style_var.get()) + mr = int(self.related_var.get()) + except ValueError: + messagebox.showerror("参数错误", "种子上限必须是数字") + return + try: + int(self.count_var.get()) + except ValueError: + messagebox.showerror("参数错误", "数量上限必须是数字") + return + self._clear_log() + self._results = {} + self._busy = True + self.fetch_btn.config(state="disabled", text="采集中…") + self.run_btn.config(state="disabled") + threading.Thread( + target=fetch_keywords, + args=(self.country_var.get(), self.provider_var.get(), ms, mr, self._q, + self._oai_cfg()), + daemon=True, + ).start() + + # ---------- 运行 ---------- + def _choose_template(self): + """选择自定义商品上传模板(.xlsx),后续 template_export 从该模板解析。 + 导入时校验「经营站点」是否与当前国家一致(如 JP → 日本站),不一致则导入失败。""" + path = filedialog.askopenfilename( + title="选择商品上传模板", + filetypes=[("Excel 模板", "*.xlsx"), ("Excel 文件", "*.xls")], + ) + if not path: + return + # 站点校验:模板第 1-2 行含「经营站点」+ 站点名(美国站/日本站/英国站/澳洲站…) + site_ok, site_name = self._check_template_site(path) + if not site_ok: + messagebox.showerror( + "模板导入失败", + f"模板经营站点「{site_name or '未知'}」与当前国家「{self.country_var.get()}」不一致,请重新导入对应站点模板。", + ) + self._log(f"[UI] 模板导入失败:站点「{site_name or '未知'}」≠ 国家 {self.country_var.get()}\n") + return + self.template_path_var.set(path) + self.template_name_var.set(f"已选: {Path(path).name}") + self._log(f"[UI] 商品上传模板已选择: {path}({site_name})\n") + + def _check_template_site(self, path: str): + """读取模板经营站点(A1=经营站点 → A2=站点名),返回 (是否匹配当前国家, 站点名)。""" + # 国家 → 允许的站点关键词 + country_sites = { + "US": ("美国站", "美国"), + "GB": ("英国站", "英国"), + "JP": ("日本站", "日本"), + "AU": ("澳大利亚站", "澳洲站", "澳洲"), + "MX": ("墨西哥站", "墨西哥"), + } + allowed = country_sites.get(str(self.country_var.get()).upper(), ()) + site_name = "" + try: + import openpyxl + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + ws = None + for _sn in wb.sheetnames: + if "模版" in _sn or "模板" in _sn: + ws = wb[_sn] + break + if ws is None: + ws = wb.worksheets[0] + # 定位「经营站点」表头所在列(第 1 行),站点名在下一行同列(如 A1=经营站点 → A2=美国站) + site_col = None + for c in range(1, 6): + v = ws.cell(1, c).value + if v and "站点" in str(v): + site_col = c + break + if site_col is not None: + v2 = ws.cell(2, site_col).value + if v2: + site_name = str(v2).strip() + wb.close() + except Exception: # noqa: BLE001 + return True, "" # 读不到站点信息 → 放行(不阻塞) + if not site_name: + return True, "" # 模板无站点信息 → 放行 + if any(k in site_name for k in allowed): + return True, site_name + return False, site_name + + def _missing_basemaps(self, tasks): + """任务开始前校验 basemap/<款号>/<颜色>/ 是否存在底图;返回缺失的 (款号/颜色) 列表。""" + cfg = {} + try: + import yaml as _yaml + cfg = _yaml.safe_load((config_root() / "config.yaml").read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + pass + bm_rel = (cfg.get("product") or {}).get("basemap_dir", "basemap") + bm_root = None + for r in (runtime_root(), config_root()): + cand = r / bm_rel + if cand.exists(): + bm_root = cand + break + if bm_root is None: + bm_root = config_root() / bm_rel + missing = [] + for t in tasks: + spu = str(t.get("spu", "")) + for sc in str(t.get("skus") or "").split(","): + sc = sc.strip() + if not sc: + continue + d = bm_root / spu / sc + if not d.is_dir() or not any(p.is_file() for p in d.iterdir()): + missing.append(f"{spu}/{sc}") + return missing + + def _on_run(self): + if self._busy: + return + if not self.template_path_var.get().strip(): + messagebox.showwarning("提示", "请先上传商品上传模板(📁 选择模板…)后才能开始任务") + return + countries = [self.country_var.get()] + try: + ms = int(self.style_var.get()) + mr = int(self.related_var.get()) + spu_count = int(self.count_var.get()) + markup = float(self.markup_var.get()) + except ValueError: + messagebox.showerror("参数错误", "种子上限 / 款号数量 / 加价% 必须是数字") + return + if spu_count < 1: + messagebox.showerror("参数错误", "款号数量必须 ≥ 1(不支持无上限)") + return + if markup < 0: + messagebox.showerror("参数错误", "加价%不能为负数") + return + + # 任务清单优先;空则用当前选中款号+颜色(保留旧行为) + tasks = list(self._tasks) + if not tasks: + spu = self._current_spu() + if not spu: + messagebox.showwarning("提示", "请先添加选品(款号 → 颜色 → +添加选品)") + return + sel_colors = [self.sku_list.get(i).split(" ")[0] for i in self.sku_list.curselection()] + if not sel_colors: + messagebox.showwarning("提示", "请至少选择一个颜色") + return + tasks = [{"spu": spu, "skus": ",".join(sel_colors)}] + + # ① 兜底校验:任务开始前检查所有款号颜色的 basemap 底图是否存在; + # 存在缺失 → 直接报错提醒,不开始任何任务 + missing = self._missing_basemaps(tasks) + if missing: + messagebox.showerror( + "底图缺失,任务未开始", + "以下款号颜色缺少底图(basemap/<款号>/<颜色>/ 无图片):\n\n " + + "\n ".join(missing[:10]) + + ("\n …" if len(missing) > 10 else "") + + "\n\n请补齐底图后重新运行(本次未执行任何任务)。", + ) + self._log("[UI] 底图缺失校验未通过,任务未开始: " + ", ".join(missing[:10]) + "\n") + return + + self._clear_log() + self._results = {} + self._busy = True + self.run_btn.config(state="disabled", text="运行中…") + self.fetch_btn.config(state="disabled") + threading.Thread( + target=run_pipeline, + args=(countries, self.provider_var.get(), ms, mr, self._q, + "", "", spu_count, tasks, self._oai_cfg(), markup, + self.code_prefix_var.get().strip() or "DG", + self.template_path_var.get().strip()), + daemon=True, + ).start() + + def _poll(self): + try: + n = 0 + while True: + kind, payload = self._q.get_nowait() + if kind == "log": + self.log_text.config(state="normal") + self.log_text.insert("end", payload) + self.log_text.see("end") + self.log_text.config(state="disabled") + n += 1 + if n >= 200: # 大批量日志时分批刷新,避免 UI 卡顿导致显示延迟 + self.log_text.update_idletasks() + n = 0 + elif kind == "error": + self._busy = False + self.run_btn.config(state="normal", text="▶ 运行") + self.fetch_btn.config(state="normal", text="📥 采集热点") + elif kind == "fetched": + self._results = {payload.get("country"): payload.get("keywords") or []} + self._busy = False + self.run_btn.config(state="normal", text="▶ 运行") + self.fetch_btn.config(state="normal", text="📥 采集热点") + self._refresh_table() + now = time.strftime("%Y-%m-%d %H:%M:%S") + self.hotspot_time_var.set(f"采集成功 {now} · {len(payload.get('keywords') or [])} 条") + self._log(f"[UI] 采集完成:{len(payload.get('keywords') or [])} 个关键词,时间 {now}\n") + messagebox.showinfo("采集完成", + f"热点采集成功:{len(payload.get('keywords') or [])} 个关键词\n时间:{now}") + elif kind == "done": + self._results = payload or {} + self._busy = False + self.run_btn.config(state="normal", text="▶ 运行") + self.fetch_btn.config(state="normal", text="📥 采集热点") + self._refresh_table() + self._load_cache() # 运行完成 → 刷新前台:用掉的热点从列表移除 + except queue.Empty: + pass + self.after(100, self._poll) + + # ---------- 结果 ---------- + def _refresh_table(self): + self.tree.delete(*self.tree.get_children()) + for c, briefs in self._results.items(): + for b in briefs: + score = b.get("score") + self.tree.insert("", "end", values=( + c, b.get("topic", ""), + b.get("design_category") or b.get("kind") or b.get("spu_code") or "(采集)", + b.get("risk_level") or "-", + round(float(score), 3) if score not in (None, "") else "-")) + + def _show_detail(self, _evt): + sel = self.tree.selection() + if not sel: + return + country, topic = self.tree.item(sel[0], "values")[0], self.tree.item(sel[0], "values")[1] + for c, briefs in self._results.items(): + if c != country: + continue + for b in briefs: + if b.get("topic") == topic: + self._detail_window(b) + return + + def _detail_window(self, b): + win = tk.Toplevel(self) + win.title(f"{b.get('topic', '')} 详情") + win.geometry("780x540") + txt = tk.Text(win, wrap="word", padx=10, pady=10) + txt.pack(fill="both", expand=True) + for zh, k in [ + ("国家", "country"), ("热点词", "topic"), ("类别", "design_category"), + ("风险", "risk_level"), ("风险原因", "risk_reasons"), ("概念", "concept"), + ("主体 motif", "motif"), ("艺术风格", "art_style"), ("配色", "color_palette"), + ("构图", "composition"), ("负向", "negative_prompt"), + ("image_prompt", "image_prompt"), ("composite_prompt", "composite_prompt"), + ]: + v = b.get(k) + if v in (None, "", []): + continue + txt.insert("end", f"【{zh}】\n{json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v}\n\n") + txt.config(state="disabled") + + # ---------- 工具 ---------- + def _clear_log(self): + self.log_text.config(state="normal") + self.log_text.delete("1.0", "end") + self.log_text.config(state="disabled") + + def _open_output(self): + os.makedirs(OUTPUT_ROOT, exist_ok=True) + try: + os.startfile(OUTPUT_ROOT) # Windows + except AttributeError: + import subprocess + subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(OUTPUT_ROOT)]) + + def _open_logs(self): + log_dir = runtime_root() / "logs" + os.makedirs(log_dir, exist_ok=True) + try: + os.startfile(log_dir) + except AttributeError: + import subprocess + subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(log_dir)]) + + +def self_test(): + """无 GUI 自检:验证打包环境(依赖/数据文件/核心链路),结果写入运行根 self_test_result.txt。""" + import traceback + lines: list = [] + + def log(s): + print(s) + lines.append(str(s)) + + try: + log("=== POD Agent 自检 ===") + log(f"数据根: {resource_root()}") + log(f"运行根: {runtime_root()}") + log(f"配置根: {config_root()} (exe 旁配置优先)") + cfg = load_config() + log(f"config 加载 OK | seed_provider: {cfg.get('seed_provider')} | 国家: {cfg.get('countries')}") + # 自检不上传图床(避免 mock 占位图污染用户 OSS bucket) + if "oss" in cfg: + cfg["oss"]["enabled"] = False + for c in COUNTRIES: + p = config_root() / "prompts" / c / "aesthetics.yaml" + log(f" prompts/{c}: {'OK' if p.exists() else 'MISSING!'}") + state = run_country("GB", cfg, config_root(), output_root=runtime_root()) + briefs = state.get("briefs", []) or [] + errs = state.get("errors") or [] + log(f"GB 流水线完成:简报 {len(briefs)} 条,兜底错误 {len(errs)}") + if briefs: + log(f"样例: {briefs[0].get('topic')} | {briefs[0].get('risk_level')} | {briefs[0].get('design_category')}") + out = runtime_root() / "output" / "GB" + log(f"产物目录: {out} 存在={'是' if out.exists() else '否'}") + log("自检完成 OK") + except Exception as e: # noqa: BLE001 + log(f"自检失败: {e}") + log(traceback.format_exc()) + finally: + try: + (runtime_root() / "self_test_result.txt").write_text( + "\n".join(lines), encoding="utf-8") + except Exception: + pass + + +def main(): + # windowed 打包(console=False)下 sys.stdout/stderr 为 None,print 会崩;兜底为丢弃流 + if sys.stdout is None: + sys.stdout = io.StringIO() + if sys.stderr is None: + sys.stderr = io.StringIO() + ensure_defaults() + if "--self-test" in sys.argv: + self_test() + return + App().mainloop() + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..58630eb --- /dev/null +++ b/uv.lock @@ -0,0 +1,426 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "pod-trend-agent" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pytrends" }, + { name = "pyyaml" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytrends", specifier = ">=4.7.1" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "requests", specifier = ">=2.31.0" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytrends" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pandas" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/65/a242fd8fbe98c11bd51f0b57d4752905396a99c42c91c3213c3f44e741c8/pytrends-4.9.2.tar.gz", hash = "sha256:691c6e36b1aeaa4754f3692bdbad0dff446e528ffb052eee2e7f139aaa2c6989", size = 247162, upload-time = "2023-04-13T23:17:21.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/ba/7a24a3723c790000faf880505ff1cc46f4d29f46dd353037938a070c4d23/pytrends-4.9.2-py3-none-any.whl", hash = "sha256:d7d0ee956be2f6e3e9fc09376c4615efb9347039d6d1f46e6f4326a5b7a14f67", size = 15352, upload-time = "2023-04-13T23:17:18.88Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]