From 2ff588a65dffb29c3e93f3c26df405de3ea5ec09 Mon Sep 17 00:00:00 2001 From: wangyunlong <3080433063@qq.com> Date: Mon, 7 Sep 2026 15:14:54 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20[workflow]=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/schedules.json | 20 +++- config/workflows.json | 8 +- docs/runbook.md | 4 +- docs/workflow-console.md | 9 +- .../content_marketing/chanmama_scraper.py | 23 +++- .../migrations/005_style_product_profile.sql | 4 + .../data/tools/schema_gyxx_super_data.sql | 5 + .../content_marketing/xingtu_scraper_v2.py | 30 +++-- .../modules/product_commerce/__init__.py | 3 +- .../export_bitable_records.py | 16 +-- .../jd_self_inventory_sales_collector.py | 42 +------ .../sync_monthly_sales_sheet.py | 6 +- .../tmall_baibu_apply_common.py | 77 +++++++++++-- src/gyxx_flow/web/app.js | 10 +- src/gyxx_flow/web/index.html | 20 ++-- .../test_buyin_metric_snapshots.py | 39 +++++++ .../test_erp_all_shop_daily_backfill.py | 20 +++- .../test_sync_monthly_sales_sheet.py | 7 +- .../test_tmall_baibu_apply.py | 104 ++++++++++++++++++ tests/test_catalog.py | 41 ++++--- tests/test_cli_workflows.py | 2 +- tests/test_product_commerce_module.py | 7 +- tests/test_relocated_project.py | 2 +- tests/test_scheduler_cli.py | 2 +- tests/test_scheduler_service.py | 2 +- tests/test_script_catalog.py | 4 +- tests/test_server_scheduler_deployment.py | 4 +- 27 files changed, 373 insertions(+), 138 deletions(-) diff --git a/config/schedules.json b/config/schedules.json index 2facbb9..d38ed11 100644 --- a/config/schedules.json +++ b/config/schedules.json @@ -114,7 +114,7 @@ "workflow_id": "product.erp_all_shop_daily" }, { - "at": "10:00", + "at": "12:00", "business_date_offset_days": 0, "days": [ "Tuesday" @@ -143,6 +143,24 @@ "kind": "interval_days", "workflow_id": "product.style_analysis.interval" }, + { + "anchor_date": "2026-09-04", + "at": "14:00", + "business_date_offset_days": 0, + "enabled": true, + "every_days": 3, + "kind": "interval_days", + "workflow_id": "product.jd_video_upload" + }, + { + "anchor_date": "2026-09-04", + "at": "14:00", + "business_date_offset_days": 0, + "enabled": true, + "every_days": 3, + "kind": "interval_days", + "workflow_id": "product.video_upload" + }, { "at": "08:30", "days": [ diff --git a/config/workflows.json b/config/workflows.json index 21dac7a..72b27fc 100644 --- a/config/workflows.json +++ b/config/workflows.json @@ -1638,7 +1638,7 @@ { "id": "product.video_upload", "module": "product_commerce", - "trigger": "manual", + "trigger": "scheduled", "execution": { "steps": [ { @@ -1703,12 +1703,12 @@ "label": "天猫话题关键词", "hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。" }, - "note": "前端手动选择旗舰店或箱包店后执行;脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。" + "note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动选择旗舰店或箱包店后执行。脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。" }, { "id": "product.jd_video_upload", "module": "product_commerce", - "trigger": "manual", + "trigger": "scheduled", "execution": { "steps": [ { @@ -1765,7 +1765,7 @@ "source_project": "product", "task_name": "JdVideoUpload" }, - "note": "前端手动幂等工作流;每次点击从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。" + "note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动触发。每次从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。" }, { "id": "product.tmall_baibu_apply", diff --git a/docs/runbook.md b/docs/runbook.md index 67a0605..2c5b59e 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -51,7 +51,7 @@ uv run gyxx scripts run --date 2026-08-01 --execute ### 天猫视频上传工作流 -天猫视频上传注册为手动幂等工作流 `product.video_upload`,不会被常驻调度器自动触发。前端“立即运行”窗口会显示“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”单选下拉框,并允许填写天猫话题完整名称或关键词;正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行: +天猫视频上传注册为幂等定时工作流 `product.video_upload`,按 `config/schedules.json` 以 2026-09-04 为起始日每 3 天 14:00(Asia/Shanghai)执行;前端仍可手动选择“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”并立即运行,也可填写天猫话题完整名称或关键词。正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行: ```bash uv run gyxx run product.video_upload --date 2026-08-07 @@ -74,7 +74,7 @@ uv run gyxx scripts run product.video_upload.run --date 2026-08-07 --arg=--recor ### 京东视频上传工作流 -京东视频上传注册为手动幂等工作流 `product.jd_video_upload`。它不会被常驻调度器自动触发;同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`: +京东视频上传注册为幂等定时工作流 `product.jd_video_upload`,按 `config/schedules.json` 以 2026-09-04 为起始日每 3 天 14:00(Asia/Shanghai)执行;前端仍可手动立即运行,同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`: ```bash uv run gyxx run product.jd_video_upload --date 2026-08-07 diff --git a/docs/workflow-console.md b/docs/workflow-console.md index ffd8c69..41377f2 100644 --- a/docs/workflow-console.md +++ b/docs/workflow-console.md @@ -41,7 +41,7 @@ uv run gyxx schedule run - 查看工作流定义、执行步骤、定时规则和下一次启动时间; - 修改定时类型、一个或多个时间、日期规则、启停状态和业务日期偏移; - 配置实际支持业务通知的工作流、发送应用和一个或多个收件人; -- 在云端数据库中增删改查商品 ID、ERP 款式编码和各业务飞书目标表; +- 在云端数据库中增删改查业务目标表、平台画像目标和启停状态;款式名、ERP 款式编码、各平台商品编码及大/小 NFC 数统一在“产品生命进程”维护; - 查看昨天或指定执行日的逐工作流运行汇总、异常原因和修复建议; - 以安全预演或正式执行方式手动触发已注册工作流; - 查看最近运行状态、步骤统计和经过脱敏的错误详情。 @@ -52,9 +52,10 @@ uv run gyxx schedule run ## 商品与款式配置 -侧栏“商品与款式配置”维护商品经营和内容营销共用的动态配置。页面提供款式、品牌、平台、 -商品 ID、ERP 编码、启停状态和各业务飞书目标表的查询与 CRUD;保存后从下一次新启动的工作流 -生效。编辑和删除带行版本校验,旧页面不能覆盖其他操作员已保存的新版本。 +侧栏“商品与款式配置”维护商品经营和内容营销共用的动态目标配置。页面提供款式、品牌、平台、 +启停状态、平台画像和各业务飞书目标表的查询与 CRUD;款式身份字段以产品生命进程为准,动态表 +中的历史 ERP/商品 ID 只作为兼容快照。保存后从下一次新启动的工作流生效。编辑和删除带行版本 +校验,旧页面不能覆盖其他操作员已保存的新版本。 配置存储在云端 PostgreSQL,旧飞书配置主表不再属于运行路径。首次迁移、字段模型、受影响 工作流和失败回退边界见 [工作流动态配置](dynamic-workflow-config.md)。 diff --git a/src/gyxx_flow/modules/content_marketing/chanmama_scraper.py b/src/gyxx_flow/modules/content_marketing/chanmama_scraper.py index 253317f..5a9a886 100644 --- a/src/gyxx_flow/modules/content_marketing/chanmama_scraper.py +++ b/src/gyxx_flow/modules/content_marketing/chanmama_scraper.py @@ -43,6 +43,7 @@ TARGET_URLS = [ "https://www.chanmama.com/bloggerRank/ujU3Wwa9udJc59DKO5dAA37ILJPsWvyg.html?activeTab=aweme", "https://www.chanmama.com/bloggerRank/ulaJWIvluqa_q6bQZzLdkzmLXNz64tgg.html?activeTab=aweme", ] +CHANMAMA_HOME_URL = "https://www.chanmama.com/" DOWNLOAD_DIR = str(PATHS.raw_root / "chanmama") COOKIE_FILE = str(PATHS.browser_cookie_file) COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒) @@ -486,7 +487,17 @@ def navigate_to_target(driver, url): if attempt >= CHANMAMA_NAVIGATION_ATTEMPTS: break print(f"⚠️ 目标页未完成渲染,重新打开一次: {exc}") - time.sleep(2) + # A same-URL reload can preserve the half-mounted SPA state. Go + # through the site root once so the next target gets a fresh app + # mount while retaining the authenticated browser session. + try: + driver.get(CHANMAMA_HOME_URL) + WebDriverWait(driver, 15).until( + EC.presence_of_element_located((By.TAG_NAME, "body")) + ) + except Exception as reset_exc: + print(f"⚠️ 蝉妈妈页面重置失败,继续重试目标页: {reset_exc}") + time.sleep(1) raise RuntimeError(f"蝉妈妈目标页加载失败: {url}; {last_error}") from last_error @@ -536,6 +547,16 @@ _VIDEO_LIST_READY_SCRIPT = r""" && style.opacity !== '0' && rect.width > 0 && rect.height > 0; }; const body = (document.body?.innerText || '').replace(/\s+/g, ' '); + // Current Chanmama renders the list under aweme-record-wrapper before the + // virtual table has stable row geometry. The page is usable once this + // visible root exposes the list/export semantics, even if the table rows + // are still being measured. + if (/视频记录|导出数据/.test(body) + && [...document.querySelectorAll( + '.aweme-record-wrapper,.list-auth-wrap-block,.list-box' + )].some((el) => visible(el))) { + return true; + } if (/预估曝光|曝光量/.test(body) && [...document.querySelectorAll('table,[role="row"],[class*="video"],[class*="list"],[class*="record"]')] .some((el) => visible(el) && /预估曝光|曝光量|播放量/.test(el.innerText || ''))) { diff --git a/src/gyxx_flow/modules/content_marketing/data/tools/migrations/005_style_product_profile.sql b/src/gyxx_flow/modules/content_marketing/data/tools/migrations/005_style_product_profile.sql index 40171fc..8435829 100644 --- a/src/gyxx_flow/modules/content_marketing/data/tools/migrations/005_style_product_profile.sql +++ b/src/gyxx_flow/modules/content_marketing/data/tools/migrations/005_style_product_profile.sql @@ -3,6 +3,8 @@ ALTER TABLE cmt_styles ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '', ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] DEFAULT '{}', ADD COLUMN IF NOT EXISTS platform_product_ids JSONB DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS big_nfc_count INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS small_nfc_count INTEGER NOT NULL DEFAULT 1, ADD COLUMN IF NOT EXISTS product_type VARCHAR(128) DEFAULT '', ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '', ADD COLUMN IF NOT EXISTS colors TEXT[] DEFAULT '{}', @@ -17,6 +19,8 @@ ALTER TABLE cmt_styles ADD COLUMN IF NOT EXISTS product_researched_at TIMESTAMPTZ; COMMENT ON COLUMN cmt_styles.platform_product_ids IS 'Product IDs grouped by Feishu platform'; +COMMENT ON COLUMN cmt_styles.big_nfc_count IS 'Large NFC product count maintained by product lifecycle'; +COMMENT ON COLUMN cmt_styles.small_nfc_count IS 'Small NFC product count maintained by product lifecycle'; COMMENT ON COLUMN cmt_styles.product_source_urls IS 'Public pages used to verify product facts'; COMMENT ON COLUMN cmt_styles.product_research_status IS 'verified, partial, or pending'; diff --git a/src/gyxx_flow/modules/content_marketing/data/tools/schema_gyxx_super_data.sql b/src/gyxx_flow/modules/content_marketing/data/tools/schema_gyxx_super_data.sql index d331c0d..663732c 100644 --- a/src/gyxx_flow/modules/content_marketing/data/tools/schema_gyxx_super_data.sql +++ b/src/gyxx_flow/modules/content_marketing/data/tools/schema_gyxx_super_data.sql @@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS cmt_styles ( brand VARCHAR(128) DEFAULT '', erp_style_codes TEXT[] DEFAULT '{}', platform_product_ids JSONB DEFAULT '{}'::jsonb, + big_nfc_count INTEGER NOT NULL DEFAULT 1, + small_nfc_count INTEGER NOT NULL DEFAULT 1, product_type VARCHAR(128) DEFAULT '', dimensions VARCHAR(255) DEFAULT '', colors TEXT[] DEFAULT '{}', @@ -35,6 +37,9 @@ COMMENT ON TABLE cmt_styles IS '款式表'; COMMENT ON COLUMN cmt_styles.name IS '款式名称'; COMMENT ON COLUMN cmt_styles.style_category IS '款式风格'; COMMENT ON COLUMN cmt_styles.platform IS '平台分组,如天猫'; +COMMENT ON COLUMN cmt_styles.platform_product_ids IS '产品生命进程统一维护的平台商品编码,固定键 tm/jd/jd_self/dy'; +COMMENT ON COLUMN cmt_styles.big_nfc_count IS '大 NFC 商品数量'; +COMMENT ON COLUMN cmt_styles.small_nfc_count IS '小 NFC 商品数量'; CREATE INDEX IF NOT EXISTS idx_cmt_styles_platform ON cmt_styles (platform); diff --git a/src/gyxx_flow/modules/content_marketing/xingtu_scraper_v2.py b/src/gyxx_flow/modules/content_marketing/xingtu_scraper_v2.py index cbde5f3..1d663bc 100644 --- a/src/gyxx_flow/modules/content_marketing/xingtu_scraper_v2.py +++ b/src/gyxx_flow/modules/content_marketing/xingtu_scraper_v2.py @@ -90,6 +90,7 @@ BUYIN_MARKET_URL = ( "&btm_show_id=a1499f13-89eb-4bc0-99f5-ceb3b8f2e600" ) BUYIN_DETAIL_URL_MARK = "/dashboard/servicehall/daren-profile" +BUYIN_SEARCH_INPUT_SELECTOR = "input[placeholder*='搜达人昵称']" # --buyin 仍可显式强制使用抖店达人广场;日报默认保留星图主采集,未命中 # 的任务由主入口在同一批次内切换到抖店补采。 @@ -755,17 +756,19 @@ def dismiss_popups(page: Page) -> None: def select_nickname_search(page: Page) -> None: if CONTENT_SOURCE == "buyin": - if not click_by_text(page, "找达人", exact=True, timeout=5000): - page.evaluate( - """ - () => { - const target = [...document.querySelectorAll('button, a, div, span')] - .find((el) => (el.innerText || '').trim() === '找达人'); - target?.click(); - } - """ + # Buyin has a global "找达人" navigation item. Clicking the first + # text match can leave the authenticated market and land on + # www.douyinec.com, where there is no creator-search input at all. + # The market route already owns the search box, so target the box + # directly and repair the route when a stale page is still open. + input_locator = page.locator(f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible").first + if not input_locator.count(): + current_path = urlparse(str(getattr(page, "url", "") or "")).path.rstrip( + "/" ) - page.wait_for_timeout(800) + if current_path != "/dashboard/servicehall/daren-square": + page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=30000) + input_locator.wait_for(state="visible", timeout=15000) return if not click_by_text(page, "昵称找人", exact=True, timeout=5000): @@ -805,7 +808,12 @@ def search_creator(page: Page, creator_name: str, creator_id: str | None = None) select_nickname_search(page) # Keep the platform's original input locator. The query value is the # only thing that changes between the ID attempt and nickname fallback. - input_locator = page.locator("input:visible").first + input_selector = ( + f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible" + if CONTENT_SOURCE == "buyin" + else "input:visible" + ) + input_locator = page.locator(input_selector).first input_locator.wait_for(state="visible", timeout=30000) input_locator.click() previous_result_signature = ( diff --git a/src/gyxx_flow/modules/product_commerce/__init__.py b/src/gyxx_flow/modules/product_commerce/__init__.py index 35ba4a8..e924e5f 100644 --- a/src/gyxx_flow/modules/product_commerce/__init__.py +++ b/src/gyxx_flow/modules/product_commerce/__init__.py @@ -21,11 +21,10 @@ PRODUCT_SCHEDULED_WORKFLOW_IDS = ( "product.erp_all_shop_daily", "product.market_rank", "product.tmall_baibu_apply", -) -PRODUCT_MANUAL_WORKFLOW_IDS = ( "product.video_upload", "product.jd_video_upload", ) +PRODUCT_MANUAL_WORKFLOW_IDS = () PRODUCT_WORKFLOW_IDS = PRODUCT_SCHEDULED_WORKFLOW_IDS + PRODUCT_MANUAL_WORKFLOW_IDS PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60 PRODUCT_RESOURCE = "module:product_commerce" diff --git a/src/gyxx_flow/modules/product_commerce/export_bitable_records.py b/src/gyxx_flow/modules/product_commerce/export_bitable_records.py index 2ad9b8b..b0d42bd 100644 --- a/src/gyxx_flow/modules/product_commerce/export_bitable_records.py +++ b/src/gyxx_flow/modules/product_commerce/export_bitable_records.py @@ -23,7 +23,7 @@ def safe_name(value): def load_style_meta(): - """优先从飞书读取 ERP 编码,失败则回退 styles_input.json。""" + """只从产品生命进程读取 ERP 编码;不使用本地旧配置回退。""" result = {} try: for item in get_erp_styles_input(): @@ -35,19 +35,7 @@ def load_style_meta(): if result: return result except Exception as exc: - print(f"[WARN] 动态读取 ERP 编码失败,使用本地文件回退: {exc}") - - if not STYLE_INPUT.exists(): - return {} - items = read_json(STYLE_INPUT) - for item in items: - style = item.get("style_name") or item.get("款式") or item.get("style") - if not style: - continue - codes = item.get("erp_style_codes") or item.get("erp款式编码") or item.get("codes") or [] - if isinstance(codes, str): - codes = [x.strip() for x in codes.split(",") if x.strip()] - result[style] = {"erp_style_codes": codes} + raise RuntimeError("产品生命进程身份源不可用,拒绝读取 styles_input.json") from exc return result diff --git a/src/gyxx_flow/modules/product_commerce/jd_self_inventory_sales_collector.py b/src/gyxx_flow/modules/product_commerce/jd_self_inventory_sales_collector.py index 406a108..e37a8d6 100644 --- a/src/gyxx_flow/modules/product_commerce/jd_self_inventory_sales_collector.py +++ b/src/gyxx_flow/modules/product_commerce/jd_self_inventory_sales_collector.py @@ -49,49 +49,15 @@ from jd_peer_product_data_collector import ( # noqa: E402 INVENTORY_URL = "https://ppzh.jd.com/inventoryweb/brand/view/supplychainAnalysis/inventoryDetail.html" DEFAULT_OUTPUT_DIR = RAW_DATA_ROOT / "jd自营库存销量数据" -# 保留硬编码作为灾备回退;运行时优先从飞书多维表格读取。 -_STATIC_STYLE_SKU_GROUPS = [ - {"style_name": "盖亚斜挎", "skus": ["100044949353", "100087299891"]}, - {"style_name": "极星pro", "skus": ["100117005088", "100100271235"]}, - {"style_name": "盖亚微单", "skus": ["100108144308", "100108144324"]}, - {"style_name": "盖世m1", "skus": ["100191634938"]}, - { - "style_name": "星迹2", - "skus": [ - "100108144306", - "100150785348", - "100108144260", - "100100271371", - "100241562067", - "100241562047", - ], - }, - {"style_name": "星云2", "skus": ["100160619963", "100160619965", "100248422862"]}, - { - "style_name": "宙斯", - "skus": ["100221530889", "100306128160", "100306128166", "100255125975", "100239269869"], - }, - {"style_name": "阿波罗x1", "skus": ["100058323820", "100058323830", "100058916832", "100058916818"]}, - {"style_name": "逐星", "skus": ["100165451136", "100165451142"]}, - {"style_name": "星云mini", "skus": ["100215176791", "100215176793"]}, - {"style_name": "晨星2", "skus": ["100287829888", "100287829866"]}, - {"style_name": "极星双肩", "skus": ["100247532920", "100247532928"]}, - {"style_name": "凌云air", "skus": ["100291530148", "100218450063"]}, - {"style_name": "觅光", "skus": ["100241563697"]}, - {"style_name": "云卷2", "skus": ["100329997886"]}, - {"style_name": "拓界mode", "skus": ["100251533359", "100251533369"]}, -] - - def _get_style_sku_groups(): - """优先从飞书读取京东自营 SKU 分组,失败则回退硬编码。""" + """从产品生命进程读取完整身份的京东自营 SKU 分组,失败则不采集。""" try: groups = get_jd_self_sku_groups() if groups: return groups except Exception as exc: - print(f"[WARN] 动态读取京东自营 SKU 配置失败,使用硬编码回退: {exc}") - return _STATIC_STYLE_SKU_GROUPS + print(f"[FAIL] 产品生命进程身份源不可用,跳过京东自营采集: {exc}") + return [] STYLE_SKU_GROUPS = _get_style_sku_groups() @@ -101,7 +67,7 @@ def _print_preflight() -> None: """启动期轻量打印:京东自营周汇总分组里今日待采款式数。""" try: styles = get_jd_self_styles() - print(f"[JD_SELF] 飞书表格 周汇总待采 {len(styles)} 款 (仅要求 SKU 非空)") + print(f"[JD_SELF] 产品生命进程 周汇总待采 {len(styles)} 款 (要求 ERP + 京东自营 SKU)") except Exception as exc: print(f"[WARN] 飞书读取失败, 跳过 preflight: {exc}") diff --git a/src/gyxx_flow/modules/product_commerce/sync_monthly_sales_sheet.py b/src/gyxx_flow/modules/product_commerce/sync_monthly_sales_sheet.py index b39b502..c794fb7 100644 --- a/src/gyxx_flow/modules/product_commerce/sync_monthly_sales_sheet.py +++ b/src/gyxx_flow/modules/product_commerce/sync_monthly_sales_sheet.py @@ -25,6 +25,7 @@ try: ) from .config.style_config_loader import ( StyleConfigLoader, + has_complete_style_identity, ) from .lark_cli_runtime import run_lark_cli except ImportError: # Direct script execution from the module root. @@ -36,6 +37,7 @@ except ImportError: # Direct script execution from the module root. ) from config.style_config_loader import ( StyleConfigLoader, + has_complete_style_identity, ) from lark_cli_runtime import run_lark_cli @@ -328,7 +330,7 @@ def plan_erp_styles( str(code).strip() for code in raw_codes if str(code).strip() ) ) - if not codes: + if not codes or not has_complete_style_identity(style_config): missing.append(target.product_name) continue key = normalized_name(style_name) @@ -402,7 +404,7 @@ def require_complete_erp_mappings(missing_products: Sequence[str]) -> None: if not missing: return raise RuntimeError( - "以下产品在飞书款式配置中缺少款式映射或 ERP 款式编码," + "以下产品在产品生命进程中缺少款式映射、ERP 款式编码或平台商品编码," "已停止正式写入且目标表未清空: " + "、".join(missing) ) diff --git a/src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_common.py b/src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_common.py index 7007b9d..ed2f8db 100644 --- a/src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_common.py +++ b/src/gyxx_flow/modules/product_commerce/tmall_baibu_apply_common.py @@ -169,6 +169,8 @@ _BATCH_FAILURE_RE = re.compile( ) _DRAFT_COUNT_RE = re.compile(r"草稿\s*[((]\s*(?P\d+)\s*[))]") _DRAFT_EMPTY_MARKERS = ("暂无数据", "数据为空", "暂无商品", "没有符合条件的商品") +_ANNOUNCEMENT_MARKERS = ("公告", "通知") +_ANNOUNCEMENT_DISMISS_LABELS = ("我已阅读完成", "关闭") _EDITOR_CLOSE_SELECTORS = ( "button[aria-label='关闭此对话框']", "[role='button'][aria-label='关闭此对话框']", @@ -627,6 +629,58 @@ def _visible_locator(context: Any, selectors: Iterable[str]) -> Any | None: return None +def _dismiss_blocking_announcements(page: Any) -> bool: + """Close a visible Tmall announcement dialog that blocks page actions. + + The old-shop activity page can show an announcement overlay after the + account identity has already loaded. The underlying ``商品批量导入`` + button remains present, but a normal click is intercepted by the dialog. + Only dialogs whose own text contains an announcement marker are touched; + business dialogs are left alone. + """ + + dismissed = False + for context in _page_contexts(page): + get_by_role = getattr(context, "get_by_role", None) + if not callable(get_by_role): + continue + try: + dialogs = get_by_role("dialog") + count = min(dialogs.count(), 12) + except Exception: + continue + for index in range(count): + dialog = dialogs.nth(index) + try: + if not dialog.is_visible(): + continue + dialog_text = _normalized_page_text( + dialog.inner_text(timeout=2_000) + ) + except Exception: + continue + if not any(marker in dialog_text for marker in _ANNOUNCEMENT_MARKERS): + continue + + candidates: list[Any] = [] + for label in _ANNOUNCEMENT_DISMISS_LABELS: + candidates.extend(_visible_text_candidates(dialog, label)) + if not candidates: + close = _visible_locator(dialog, _EDITOR_CLOSE_SELECTORS) + if close is not None: + candidates.append(close) + for candidate in candidates: + try: + candidate.click(timeout=8_000) + dismissed = True + break + except Exception: + continue + if dismissed: + return True + return False + + def _find_password_login_form( page: Any, *, @@ -860,6 +914,8 @@ def _open_batch_import( ) -> _BatchImportStatus | None: """Open the import dialog, failing closed on an active prior operation.""" + _dismiss_blocking_announcements(page) + # The account marker is rendered before the 最近操作 panel on this SPA. # Capture the panel after it has had a short chance to hydrate; otherwise a # previous terminal summary can appear only after the dialog is opened and @@ -886,6 +942,7 @@ def _open_batch_import( deadline = time.monotonic() + 20 while time.monotonic() < deadline: + _dismiss_blocking_announcements(page) if _click_text(page, ("商品批量导入",)): return previous current = _latest_batch_import_status(_page_text(page)) @@ -993,26 +1050,28 @@ def _return_to_draft_list(page: Any, entry: BaibuEntry) -> None: """Leave the current editor and wait until the draft list is usable. Tmall keeps the editor open after ``下一步`` saves a product. The next - product must be selected from the draft list, so explicitly navigate back - before the loop reads the draft count. The visible 草稿 tab is preferred; - closing the editor and browser history are fallbacks for the two page - variants used by the old and new seller consoles. + product must be selected from the draft list, so close the editor first + and only then navigate back before the loop reads the draft count. This + prevents a click on the 草稿 tab or the next 完善商品 action from being + intercepted by the still-open drawer. Browser history remains a fallback + for the two page variants used by the old and new seller consoles. """ deadline = time.monotonic() + _DRAFT_NAVIGATION_TIMEOUT_SECONDS close_attempted = False back_attempted = False while time.monotonic() < deadline: - if _click_draft_tab(page) and _draft_list_is_ready(page): - return - if not close_attempted: close_attempted = True if _close_editor(page): # Let the drawer close animation finish before trying the - # tab again or falling back to browser history. + # draft list or falling back to browser history. time.sleep(0.5) - continue + if _draft_list_is_ready(page): + return + + if _click_draft_tab(page) and _draft_list_is_ready(page): + return if not back_attempted: back_attempted = True diff --git a/src/gyxx_flow/web/app.js b/src/gyxx_flow/web/app.js index 1eb8af0..4d6d6a8 100644 --- a/src/gyxx_flow/web/app.js +++ b/src/gyxx_flow/web/app.js @@ -1291,14 +1291,14 @@ const platforms = Array.isArray(record.platforms) ? record.platforms : []; const enabledPlatforms = platforms.filter((platform) => platform.enabled !== false); const itemCount = enabledPlatforms.reduce((total, platform) => total + (Array.isArray(platform.item_ids) ? platform.item_ids.length : 0), 0); - const platformRows = enabledPlatforms.slice(0, 4).map((platform) => `${esc(platform.platform || "未填写平台")}${Array.isArray(platform.item_ids) && platform.item_ids.length ? `${platform.item_ids.length} 个 ID` : "待补 ID"}`).join(""); + const platformRows = enabledPlatforms.slice(0, 4).map((platform) => `${esc(platform.platform || "未填写平台")}${Array.isArray(platform.item_ids) && platform.item_ids.length ? `${platform.item_ids.length} 个兼容 ID` : "由生命进程维护"}`).join(""); const morePlatforms = enabledPlatforms.length > 4 ? `+${enabledPlatforms.length - 4}` : ""; const destinationNames = dynamicConfigDestinations(record).filter((item) => item.enabled && item.url).slice(0, 3).map((item) => `${esc(item.label)}`).join(""); const moreTargets = targetCount > 3 ? `+${targetCount - 3}` : ""; return `
${esc(configInitial(style))}
${esc(style)}${esc(brand)}${record.note ? ` · ${esc(record.note)}` : ""}配置 #${Number(record.id)}
-
${enabledPlatforms.length}个平台 · ${itemCount} 个商品 ID
${platformRows || '暂无平台映射'}${morePlatforms}
-
ERP${configCodeList(record.erp_codes)}
${destinationNames || '尚未配置目标地址'}${moreTargets}
+
${enabledPlatforms.length}个平台 · ${itemCount} 个兼容 ID
${platformRows || '暂无平台目标'}${morePlatforms}
+
身份产品生命进程
${destinationNames || '尚未配置目标地址'}${moreTargets}
${record.enabled ? "启用" : "停用"}${targetCount} 个目标已接入
`; @@ -1335,9 +1335,9 @@ function dynamicConfigPlatformRow(platform = {}) { const enabled = platform.enabled !== false; return `
-
${esc(platform.platform || "新平台")}平台级商品身份
+
${esc(platform.platform || "新平台")}平台画像与启停
- +
`; } diff --git a/src/gyxx_flow/web/index.html b/src/gyxx_flow/web/index.html index 5146c92..4d9a66e 100644 --- a/src/gyxx_flow/web/index.html +++ b/src/gyxx_flow/web/index.html @@ -74,7 +74,7 @@ @@ -351,7 +351,7 @@
STYLE CONFIGURATION WORKSPACE

款式配置中心

-

一款一条配置,集中维护 ERP、飞书业务目标和各平台商品身份。

+

一款一条配置,集中维护飞书业务目标、平台画像和启停;商品身份请在产品生命进程维护。

配置来源云端 PostgreSQL
@@ -362,7 +362,7 @@
统一配置,按款式生效

新启动的工作流会读取这里的最新版本。

-
01ERP 款式编码02业务目标地址03平台商品身份
+
01业务目标地址02平台画像表03启停状态
@@ -386,7 +386,7 @@
- +
正在读取云端配置…
@@ -560,7 +560,7 @@